Filed here because issues are disabled on pestphp/pest-plugin-mutate. The code
referenced below lives in that package.
What happens
On a class that many tests cover, every mutation dies before it runs:
proc_open(): posix_spawn() failed: Argument list too long
at vendor/symfony/process/Process.php:411
The mutation is reported as not measured, and with a shard-per-directory CI setup the
whole run fails.
Why
MutationTest::start() selects the covering tests by building one regex fragment per test
and joining all of them into a single argv element:
// pest-plugin-mutate/src/MutationTest.php:92
'--filter="'.implode('|', $filters).'"',
Linux caps one argv element at MAX_ARG_STRLEN = PAGE_SIZE * 32
(include/uapi/linux/binfmts.h). x86_64 always has 4 KiB pages, so the cap is exactly
131072 bytes, and it cannot be tuned — ulimit raises the total ARG_MAX, never the
per-element limit. macOS has no per-element cap at all, which is why this reproduces in CI
and not on a developer machine.
Measured on one real mutation, in a small utility class that essentially every component
test reaches (2076 covering tests across 179 classes):
| encoding |
bytes |
vs 131072 |
| one fragment per test (current) |
172,779 |
over |
| factor the class prefix |
124,987 |
fits, +5% only |
collapse each class to Cls:: |
4,205 |
fits, 31x headroom |
It is over two limits, not one
Worth knowing before picking a fix: PCRE also refuses that pattern.
preg_match(): Compilation failed: regular expression is too large at offset 163235
So even a transport that allowed a 170 KB argument would hand PHPUnit a filter it cannot
compile. The encoding itself has to get shorter; moving the same payload elsewhere is not
enough.
The TODO above the loop cannot fix it as written
// TODO: we should pass the tests to run in another way, maybe via cache, mutation or env variable
A cache or file carrier works. An environment variable does not:
do_execveat_common() runs the same copy_strings() over envp as over argv, and the
MAX_ARG_STRLEN check lives inside it — the environment is under the identical
per-element cap. Flagging it only so that route is not chosen and found wanting later.
What we did, and would gladly contribute
An adaptive encoding, applied in order:
- Factor the class prefix — always.
Cls::(.*)a|Cls::(.*)b becomes Cls::(.*)(a|b).
Lossless: replayed against the real test list, both forms select the identical set. The
inner parentheses matter — without them the alternation binds to the whole pattern
instead of the tail.
- Collapse the heaviest class to a bare
Cls:: if it still does not fit, largest
first, stopping as soon as it fits. That is a strict superset of the original selection
(2894 vs 2243 tests on the measured case), so it can only ever run MORE tests — it can
never turn a killed mutant into a survivor.
- Budget below the hard cap (we use 96 KiB): a filter sized to the exact limit is one new
test away from failing again.
- Throw with the measured length if even a full collapse does not fit, rather than
dropping the filter. Dropping it would run the whole suite per mutant and look like a
fast green.
- Print whenever a collapse fires — which classes, how many tests it widened, the
resulting length. A silent widening would make the score certify more than the run
measured.
One caveat worth documenting alongside it: widening is safe only while the ordinary
suite is green. A test already failing for unrelated reasons counts as a kill.
Result on the case above: 163,246 → 95,614 bytes, four classes collapsed, no test lost.
We run this as a composer-patches patch today and would happily send it as a PR if the
approach looks right. We did not want to open with an unsolicited patch to a file that
already carries a TODO about redesigning this exact thing.
Environment
pestphp/pest-plugin-mutate v4.0.1, pestphp/pest 4.7.5, PHP 8.4
- Linux amd64 container (fails), macOS arm64 (does not — no per-element cap)
What happens
On a class that many tests cover, every mutation dies before it runs:
The mutation is reported as not measured, and with a shard-per-directory CI setup the
whole run fails.
Why
MutationTest::start()selects the covering tests by building one regex fragment per testand joining all of them into a single argv element:
Linux caps one argv element at
MAX_ARG_STRLEN = PAGE_SIZE * 32(
include/uapi/linux/binfmts.h). x86_64 always has 4 KiB pages, so the cap is exactly131072 bytes, and it cannot be tuned —
ulimitraises the totalARG_MAX, never theper-element limit. macOS has no per-element cap at all, which is why this reproduces in CI
and not on a developer machine.
Measured on one real mutation, in a small utility class that essentially every component
test reaches (2076 covering tests across 179 classes):
Cls::It is over two limits, not one
Worth knowing before picking a fix: PCRE also refuses that pattern.
So even a transport that allowed a 170 KB argument would hand PHPUnit a filter it cannot
compile. The encoding itself has to get shorter; moving the same payload elsewhere is not
enough.
The TODO above the loop cannot fix it as written
// TODO: we should pass the tests to run in another way, maybe via cache, mutation or env variableA cache or file carrier works. An environment variable does not:
do_execveat_common()runs the samecopy_strings()overenvpas overargv, and theMAX_ARG_STRLENcheck lives inside it — the environment is under the identicalper-element cap. Flagging it only so that route is not chosen and found wanting later.
What we did, and would gladly contribute
An adaptive encoding, applied in order:
Cls::(.*)a|Cls::(.*)bbecomesCls::(.*)(a|b).Lossless: replayed against the real test list, both forms select the identical set. The
inner parentheses matter — without them the alternation binds to the whole pattern
instead of the tail.
Cls::if it still does not fit, largestfirst, stopping as soon as it fits. That is a strict superset of the original selection
(2894 vs 2243 tests on the measured case), so it can only ever run MORE tests — it can
never turn a killed mutant into a survivor.
test away from failing again.
dropping the filter. Dropping it would run the whole suite per mutant and look like a
fast green.
resulting length. A silent widening would make the score certify more than the run
measured.
One caveat worth documenting alongside it: widening is safe only while the ordinary
suite is green. A test already failing for unrelated reasons counts as a kill.
Result on the case above: 163,246 → 95,614 bytes, four classes collapsed, no test lost.
We run this as a
composer-patchespatch today and would happily send it as a PR if theapproach looks right. We did not want to open with an unsolicited patch to a file that
already carries a TODO about redesigning this exact thing.
Environment
pestphp/pest-plugin-mutatev4.0.1,pestphp/pest4.7.5, PHP 8.4