Skip to content

Mutation testing: --filter exceeds the kernel's per-argument limit (and PCRE's compile limit) on widely-covered classes #1771

Description

@yeapea

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:

  1. 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.
  2. 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.
  3. Budget below the hard cap (we use 96 KiB): a filter sized to the exact limit is one new
    test away from failing again.
  4. 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.
  5. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions