Skip to content

[mutate] Windows: forward-slash outer argv[0] fails to launch per-mutant children, every mutant silently reports killed #1916

Open

Description

@joshdaugherty

Correction, see the comment below for the full account: the mechanism this report originally described — CliConfiguration.php:64's hardcoded literal reaching the per-mutant child — is wrong. That literal is local to fromArguments(), discarded after building an ArgvInput for option parsing, and never returned. The per-mutant child's command is actually built from the outer --mutate process's own argv[0] (via MutationTestRunner::setOriginalArguments()), so the fix is at the call site (e.g. an @php-prefixed Composer script on Windows, which normalizes the separator), not in this project. The reproduction, symptom, and impact described below are all still accurate; only the attributed cause is not.

Filing here because issues are disabled on pestphp/pest-plugin-mutate. Related but distinct from #1843, which is a different root cause (a Symfony Process deadlock inside a mutant's own test) reaching the same class of symptom — any non-isSuccessful() process outcome gets scored as a kill rather than reported as its own state.

What:

  • Bug Fix

Description:

On Windows, CliConfiguration::fromArguments() hardcodes the per-mutant child process's argv[0] as the literal forward-slash string 'vendor/bin/pest':

// src/Support/Configuration/CliConfiguration.php:64
$filteredArguments = ['vendor/bin/pest'];

This string is reused verbatim in MutationTest::start() to build every per-mutant Process:

// src/MutationTest.php:85-93
$process = new Process(
    command: [
        ...$originalArguments,   // starts with the hardcoded 'vendor/bin/pest'
        '--bail',
        '--filter="'.implode('|', $filters).'"',
    ],
    env: $envs,
    timeout: $this->calculateTimeout(),
);

On Windows, a forward-slash relative path in the first element of a Symfony Process array command fails to launch. Isolated in a minimal reproduction against symfony/process 7.4.13, same working directory, only the separator and extension varied:

'vendor/bin/pest'          exit 1, stderr: 'vendor' is not recognized as an internal or external command, operable program or batch file.
'vendor\bin\pest'          exit 0, runs correctly
'vendor/bin/pest.bat'      exit 1, same error
'vendor\bin\pest.bat'      exit 0, runs correctly

Only the separator matters; the .bat extension does not change the outcome either way.

Because MutationTest::hasFinished() reads $this->process->isSuccessful() and maps a non-zero exit to MutationTestResult::Tested (killed) rather than treating a launch failure as its own state, every mutant whose covering-test child fails to launch this way is reported as killed, indistinguishable from a genuine kill:

// src/MutationTest.php:139-150
if ($this->process->isSuccessful()) {
    $this->updateResult(MutationTestResult::Untested);
    // ...
    return true;
}

$this->updateResult(MutationTestResult::Tested);

Reproduction

On Windows, running --mutate --covered-only --path=<any file> against a real project: every mutant reports as tested/killed, with a score of 100%, and real (non-instant) per-mutant wall-clock time — because each child process takes roughly 1.5-2s to fail to launch and report the error, which reads as plausible execution time. Confirmed by disabling a test known to kill a specific mutant and re-running: the mutant still reports killed, because the process that should have run that test never launched at all.

Instrumented MutationTest.php directly (two fwrite(STDERR, ...) calls printing the constructed command, exit code, and isSuccessful()) and ran a 38-mutant pass on one file: all 38 child processes failed to launch ('vendor' is not recognized...), all 38 were marked killed, reported score 100.00%.

Impact

This is not a narrow edge case — it affects every --mutate invocation on Windows, regardless of how the outer pest --mutate ... command was invoked (bare, absolute path, .bat, through composer exec, through a composer.json script). The outer invocation's form is irrelevant, because the failing command is built from the hardcoded literal in CliConfiguration::fromArguments(), not from the outer process's own argv. On an affected machine, mutation testing produces a plausible-looking, fully fabricated 100% for every run, with no error and no indication anything is wrong.

Suggested fix (superseded, see correction comment)

The CliConfiguration.php change originally proposed here would not fix anything, since that code path is never spawned. What would help: MutationTest::start() normalizes $originalArguments[0]'s path separator (DIRECTORY_SEPARATOR, or Symfony\Component\Filesystem\Path::normalize()) before building the per-mutant Process, so the child's launch does not depend on how the outer process happened to be invoked.

Environment

  • pestphp/pest-plugin-mutate v3.0.5
  • symfony/process 7.4.13
  • Windows 11, Git Bash / MSYS2, PHP 8.4.20 (Herd)

Activity

  1. joshdaugherty commented on Sep 15, 2026

    @joshdaugherty
    Author

    Correcting the mechanism claimed in this report. The observed symptom (forward-slash argv[0] fails to launch on Windows, silently scored as a kill) is real and reproduces. The attribution to CliConfiguration.php:64's 'vendor/bin/pest' literal is wrong.

    That literal is local to CliConfiguration::fromArguments(). It is used only to build a Symfony\Component\Console\Input\ArgvInput for parsing the mutate-specific CLI flags (--path, --covered-only, and so on), and is discarded there — fromArguments()'s own return $arguments; returns the original argument list passed in, never $filteredArguments. It never reaches MutationTest::start().

    What actually reaches MutationTest::start() is $originalArguments, set via MutationTestRunner::setOriginalArguments() from Mutate::handleArguments($arguments)'s return value — which is the outer --mutate process's own argv (minus the options fromArguments() consumed, plus an appended --coverage-php=…). So the per-mutant child's argv[0] is inherited from however the outer process was actually invoked, not from a hardcoded string.

    Confirmed by testing the fix this points at: this repository's composer.json ran its mutate script as a bare pest --testsuite=Feature --mutate … (no vendor/bin/ prefix, no @php). Rewriting it to @php vendor/bin/pest --testsuite=Feature --mutate … — Composer's @php directive normalizes the separator before PHP runs — eliminated the launch failure entirely: the same 38-mutant Auth.php run went from 38-of-38 failed launches (scored killed, reported 100.00%) to a genuine run with real per-mutant timing and a real (non-fabricated) result.

    So the fix is available at the call site rather than needing a change in this project, for anyone hitting the same symptom on Windows: make sure the outer --mutate invocation's own argv[0] is backslash-separated (an @php-prefixed Composer script, or an explicitly backslashed path if invoking vendor\bin\pest directly) — not a change to CliConfiguration.php, which was never the source. The suggested fix in the original report (normalizing the literal at CliConfiguration.php:64) would not have addressed this, since that literal is never spawned.

    What might still be worth a framework-level fix, if the maintainers want it: MutationTest::start() trusts the outer process's own argv[0] verbatim for every per-mutant child, with no normalization. A caller whose own invocation happens to produce a separator the child can't use (as a bare Windows invocation does) gets a silent, plausible-looking fabricated result rather than an error — hasFinished() maps any non-zero exit, launch failure included, to MutationTestResult::Tested. Normalizing argv[0]'s separator once, in MutationTest::start(), before building the child command, would close this regardless of how the outer process was invoked.

  2. changed the title [-][mutate] Windows: hardcoded forward-slash 'vendor/bin/pest' fails to launch, every mutant silently reports killed[/-] [+][mutate] Windows: forward-slash outer argv[0] fails to launch per-mutant children, every mutant silently reports killed[/+] on Sep 15, 2026
  3. drpark commented on Oct 14, 2026

    @drpark

    Confirming on another setup, with a fix that works at the plugin level.

    Environment: Windows 11, PHP 8.4.15 ZTS (x64, not Herd), Xdebug 3.5.3, Pest 3.8.6, pestphp/pest-plugin-mutate v3.0.5, symfony/process v7.4.8. Outer command: php vendor/bin/pest ... --mutate.

    Symptom, same as reported: 2,647 mutants over a Laravel app/Domain, all scored as killed, 100.00 %, with ~0.25 s per mutant that looked like real work. A test asserting nothing (expect(true)->toBeTrue()) also gave 100 %. Each child failed with 'vendor' n'est pas reconnu en tant que commande interne... (French Windows).

    Fix applied locally (composer patch), in MutationTest::start():

    command: [
        ...(PHP_OS_FAMILY === 'Windows' ? [PHP_BINARY] : []),
        ...$originalArguments,
        '--bail',
        '--filter="'.implode('|', $filters).'"',
    ],

    Running the child through PHP_BINARY makes the launch independent of how the outer process was invoked (separator, .bat proxy, path with spaces: Symfony Process quotes it). After the fix, the same run reports 78.43 % (571 survivors), and the no-assertion test drops to 0 %.

    Data point for #1927: with this prefix and symfony/process v7.4.8, the quoted --filter containing | does not break here. A line covered by two tests runs both children correctly (checked with two no-assertion tests: 0 %, no false kill). That may be because the command line now starts with a quoted token, or a difference between symfony/process 7.4 and 8.1.

    Two more fixes were needed on this setup to get trustworthy numbers: the drive-letter absolute path one (pestphp/pest-plugin-mutate#36, otherwise covers() / --class create 0 mutations), and a timeout issue filed as #1968 (the 5 s minimum per-mutant timeout is shorter than a child boot under Xdebug, so some untested mutants are scored as killed).

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