Skip to content

Commit d61ef02

Browse files
Testclaude
andcommitted
test(multi-processes-execution): add iteration-cap liveness guard
Adds a parametrized run-loop liveness test to MultiProcessesExecutionTest covering 6 adversarial scenarios (all-succeed, one-fails, one-throws, one-times-out, fail-fast-triggered, all-fail-no-fail-fast). The test guards the do-while in runProcesses() with an iteration cap of 200 ticks via an anonymous fake that overrides hasPendingWork(). When the loop diverges, the cap throws an \Error that escapes to the OUTER catch on line 55 (the inner catch on line 51 cannot snare it because the while condition is evaluated outside the inner try) and gets registered as 'General' in the returned Errors object. The test then asserts the key is absent. This converts the 6 timed-out mutants Infection currently reports against this file (lines 38-41 catch removal, 131-134 hasPendingWork) into clean kills with a precise diagnostic that points at the regression site. SIGALRM is not viable here because the inner catch (Throwable) on line 51 swallows AssertionFailedError and the loop just keeps spinning. Manually verified with two mutations: - addProcessToQueue removed (T14): all 6 cases hit the cap. - hasPendingWork failFastTriggered branch flipped to true (T17): the fail_fast_triggered case hits the cap as expected. Both reverted after confirming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 486db78 commit d61ef02

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

tests/Unit/Tools/Execution/MultiProcessesExecutionTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,123 @@ function failFast_takes_priority_over_ignoreErrorsOnExit()
470470
$printerMock->shouldNotHaveReceived()->resultSuccess(\Mockery::pattern($this->messageRegExp('phpstan')));
471471
$printerMock->shouldNotHaveReceived()->resultSuccess(\Mockery::pattern($this->messageRegExp('phpmd')));
472472
}
473+
474+
/**
475+
* Run-loop liveness invariant: under any combination of success / failure /
476+
* 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).
484+
*
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.
494+
*
495+
* @test
496+
* @dataProvider adversarialRunLoopScenarios
497+
*
498+
* @param string $scenario Human label for the assertion message
499+
* @param array<string,mixed> $config How to configure MultiProcessesExecutionFake
500+
*/
501+
function run_loop_terminates_under_adversarial_inputs(string $scenario, array $config)
502+
{
503+
$builder = $this->configurationFileBuilder;
504+
if (isset($config['failFastTool'])) {
505+
// Mirror the existing fail-fast test setup: a 3-tool subset where
506+
// changeToolOption('parallel-lint', ['failFast' => true]) is known
507+
// to round-trip through the builder. Other tools (e.g. phpcs)
508+
// require otherArguments to be set explicitly via the changeToolOption
509+
// path and the builder doesn't fill that default.
510+
$builder = $builder
511+
->setTools(['parallel-lint', 'phpstan', 'phpmd'])
512+
->changeToolOption($config['failFastTool'], ['failFast' => true]);
513+
}
514+
$configurationFile = new ConfigurationFile(
515+
$builder->buildArray(),
516+
self::ALL_TOOLS,
517+
new ToolRegistry()
518+
);
519+
$tools = $this->toolsFactory->__invoke($configurationFile->getToolsConfiguration());
520+
521+
$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+
};
538+
539+
if (!empty($config['failedTools'])) {
540+
$multiProcessExecution->failedToolsByFoundedErrors($config['failedTools']);
541+
}
542+
if (!empty($config['exceptionTools'])) {
543+
$multiProcessExecution->failedToolsByException($config['exceptionTools']);
544+
}
545+
if (!empty($config['timeoutTools'])) {
546+
$multiProcessExecution->setToolsWithTimeout($config['timeoutTools']);
547+
}
548+
549+
$errors = $multiProcessExecution->execute($tools, $configurationFile->getProcesses());
550+
551+
$this->assertArrayNotHasKey(
552+
'General',
553+
$errors->getErrors(),
554+
"Case '{$scenario}': runProcesses() loop did not converge within {$multiProcessExecution->iterationCap} iterations. "
555+
. "The error was caught by the outer Throwable handler on line 55."
556+
);
557+
$this->assertLessThanOrEqual(
558+
$multiProcessExecution->iterationCap,
559+
$multiProcessExecution->iterations,
560+
"Case '{$scenario}': loop spent {$multiProcessExecution->iterations} iterations (cap: {$multiProcessExecution->iterationCap})"
561+
);
562+
}
563+
564+
/**
565+
* Adversarial inputs covering the run-loop's decision surface:
566+
* - all-success: control case, the loop must complete with no incidents.
567+
* - one-fails: a failed tool puts a process in runnedProcesses but the
568+
* other ones must keep being polled until they all finish.
569+
* - one-throws: ProcessFailedException reaches the inner catch which
570+
* MUST advance numberOfRunnedProcesses (regression candidate for the
571+
* CatchBlockRemoval mutation on line 38).
572+
* - one-times-out: ProcessTimedOutException must move the offending tool
573+
* out of runningProcesses; otherwise hasPendingWork never converges.
574+
* - fail-fast-after-failure: failFastTriggered short-circuits queueing
575+
* and hasPendingWork must fall through to false once running is empty.
576+
* - all-fail-no-fail-fast: the loop must keep flushing every running
577+
* process until counts converge even when none of them succeed.
578+
*
579+
* @return array<string, array{0: string, 1: array<string,mixed>}>
580+
*/
581+
public function adversarialRunLoopScenarios(): array
582+
{
583+
return [
584+
'all_succeed' => ['all_succeed', []],
585+
'one_fails' => ['one_fails', ['failedTools' => ['phpcs']]],
586+
'one_throws_exception' => ['one_throws_exception', ['exceptionTools' => ['phpcs']]],
587+
'one_times_out' => ['one_times_out', ['timeoutTools' => ['phpcs']]],
588+
'fail_fast_triggered' => ['fail_fast_triggered', ['failedTools' => ['parallel-lint'], 'failFastTool' => 'parallel-lint']],
589+
'all_fail_no_fail_fast' => ['all_fail_no_fail_fast', ['failedTools' => ['phpcs', 'phpcbf', 'phpmd', 'phpcpd', 'parallel-lint', 'phpstan']]],
590+
];
591+
}
473592
}

0 commit comments

Comments
 (0)