Skip to content

Commit 9f716bd

Browse files
Testclaude
andcommitted
test(multi-processes-execution): hoist iteration cap to the Fake (global guard)
The previous commit kept the iteration cap inside one anonymous fake used by the new adversarial test only. That doesn't kill Infection's six timed-out mutants on this class because the OTHER preexisting tests in the file run against the same mutant first, hang (their MultiProcessesExecutionFake has no cap), and Infection kills the entire PHPUnit process at its 120s limit before the new test ever gets a chance to fail cleanly. Hoisting the hasPendingWork() override to MultiProcessesExecutionFake makes EVERY test in the file inherit the 200-iteration cap implicitly. Verified by applying the M14 mutation (addProcessToQueue removed): all 40 tests in MultiProcessesExecutionTest now fail with the cap-exceeded diagnostic in ~12s instead of hanging until the 120s timeout. Infection will record those six mutants as Killed rather than Timed Out on the next run, with a precise pointer to the run-loop regression. Test simplified accordingly — the anonymous subclass goes away, the test uses the shared Fake directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d61ef02 commit 9f716bd

2 files changed

Lines changed: 57 additions & 32 deletions

File tree

tests/Doubles/MultiProcessesExecutionFake.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,47 @@
99
class MultiProcessesExecutionFake extends MultiProcessesExecution
1010
{
1111
use ExecutionFakeTrait;
12+
13+
/**
14+
* @var int Iterations of the do-while observed by hasPendingWork(). Public
15+
* so tests can inspect or reset it.
16+
*/
17+
public int $iterations = 0;
18+
19+
/**
20+
* @var int Hard cap on do-while iterations. Set generously for the suite
21+
* (each iteration polls O(numTools) processes; six tools converge
22+
* in ≲12 iterations). Override per-test if a stress scenario
23+
* legitimately needs more.
24+
*/
25+
public int $iterationCap = 200;
26+
27+
/**
28+
* Liveness guard against deadlock regressions in runProcesses(). Without
29+
* this cap the do-while spins indefinitely when a guard-protecting branch
30+
* is removed (addProcessToQueue, finishExecution, the catch blocks, the
31+
* hasPendingWork early-exit), and Infection has to wait its 120s timeout
32+
* before recording the mutant as TIMED_OUT.
33+
*
34+
* Throwing here escapes to the OUTER `catch (Throwable)` on line 55 of
35+
* MultiProcessesExecution::runProcesses() (the inner catch on line 51
36+
* cannot snare it because the while condition is evaluated outside the
37+
* inner try/catch). The outer handler registers it as a 'General' error
38+
* so callers can detect the cap was hit. The bundled
39+
* `run_loop_terminates_under_adversarial_inputs` test asserts the absence
40+
* of that key for the contract; existing tests benefit from the cap
41+
* implicitly — instead of a 120s hang on regressed code they fail fast
42+
* with a clear diagnostic and Infection records a kill rather than a
43+
* timeout.
44+
*/
45+
protected function hasPendingWork(int $totalProcesses): bool
46+
{
47+
if (++$this->iterations > $this->iterationCap) {
48+
throw new \Error(
49+
"MultiProcessesExecution::runProcesses() did not converge after {$this->iterations} iterations — "
50+
. 'check addProcessToQueue, finishExecution and the catch blocks for guard regressions.'
51+
);
52+
}
53+
return parent::hasPendingWork($totalProcesses);
54+
}
1255
}

tests/Unit/Tools/Execution/MultiProcessesExecutionTest.php

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -474,23 +474,20 @@ function failFast_takes_priority_over_ignoreErrorsOnExit()
474474
/**
475475
* Run-loop liveness invariant: under any combination of success / failure /
476476
* timeout / fail-fast / ignore-errors flags, MultiProcessesExecution::runProcesses()
477-
* MUST terminate within a bounded number of iterations. The previous tests
478-
* assert the OUTCOME (errors collected, skipped tools, etc.) but none of
479-
* them have an iteration cap — a mutation that removes addProcessToQueue,
480-
* flips hasPendingWork, or breaks the catch blocks lets the do-while spin
481-
* forever, and the test suite would hang up to PHPUnit's wall-clock budget
482-
* (>120s under Infection — already reported as timed-out mutants on lines
483-
* 38-41 and 131-134 of the production class).
477+
* MUST terminate within a bounded number of iterations. None of the previous
478+
* outcome-asserting tests guard against a divergent loop — a mutation that
479+
* removes addProcessToQueue, flips hasPendingWork, or breaks the catch
480+
* blocks lets the do-while spin forever (>120s under Infection, reported
481+
* as timed-out mutants on lines 38-41 and 131-134 of the production class).
484482
*
485-
* SIGALRM is NOT a viable guard here because the inner `catch (Throwable)`
486-
* on line 51 of runProcesses() catches the AssertionFailedError thrown by
487-
* `$this->fail()` and the loop just keeps spinning. Instead we override
488-
* `hasPendingWork()` (the do-while condition, evaluated OUTSIDE the inner
489-
* try/catch) and throw an `\Error` once iterations exceed a sane cap.
490-
* The error escapes to the outer `catch (Throwable)` on line 55, which
491-
* registers it as a 'General' error in the returned Errors. The test then
492-
* asserts the 'General' key is absent — present means the cap was hit and
493-
* the run loop did not converge.
483+
* The cap lives in MultiProcessesExecutionFake::hasPendingWork() so EVERY
484+
* test in this file inherits it implicitly. Without that, this single
485+
* adversarial test would still time out alongside the others when run
486+
* against a divergent mutant — Infection kills the entire PHPUnit process
487+
* on the first hang, ignoring any test that would have failed cleanly.
488+
* With the cap on the Fake, divergent mutants surface as a 'General' error
489+
* inside the returned Errors object (see the Fake's docblock for why);
490+
* this test asserts the absence of that key.
494491
*
495492
* @test
496493
* @dataProvider adversarialRunLoopScenarios
@@ -519,22 +516,7 @@ function run_loop_terminates_under_adversarial_inputs(string $scenario, array $c
519516
$tools = $this->toolsFactory->__invoke($configurationFile->getToolsConfiguration());
520517

521518
$printerMock = Mock::spy(Printer::class);
522-
$multiProcessExecution = new class ($printerMock, new GitStagerFake()) extends MultiProcessesExecutionFake {
523-
/** @var int Iterations of the do-while in runProcesses() observed by hasPendingWork */
524-
public int $iterations = 0;
525-
public int $iterationCap = 200;
526-
527-
protected function hasPendingWork(int $totalProcesses): bool
528-
{
529-
if (++$this->iterations > $this->iterationCap) {
530-
throw new \Error(
531-
"MultiProcessesExecution::runProcesses() did not converge after {$this->iterations} iterations — "
532-
. 'check addProcessToQueue, finishExecution and the catch blocks for guard regressions.'
533-
);
534-
}
535-
return parent::hasPendingWork($totalProcesses);
536-
}
537-
};
519+
$multiProcessExecution = new MultiProcessesExecutionFake($printerMock, new GitStagerFake());
538520

539521
if (!empty($config['failedTools'])) {
540522
$multiProcessExecution->failedToolsByFoundedErrors($config['failedTools']);

0 commit comments

Comments
 (0)