diff --git a/src/Analyser/Parallel/AnalysisNodeWorker.php b/src/Analyser/Parallel/AnalysisNodeWorker.php index 42cb1dc7..a70c1f66 100644 --- a/src/Analyser/Parallel/AnalysisNodeWorker.php +++ b/src/Analyser/Parallel/AnalysisNodeWorker.php @@ -9,12 +9,10 @@ use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Throwable; -use function file_get_contents; use function file_put_contents; use function is_array; use function serialize; use function sprintf; -use function unserialize; use const STDOUT; @@ -24,11 +22,9 @@ final readonly class AnalysisNodeWorker { /** @param resource|null $outputStream */ - public static function run(string $inputFile, string $outputFile, mixed $outputStream = null): int + public static function run(mixed $payload, string $outputFile, mixed $outputStream = null): int { try { - $payload = unserialize((string) file_get_contents($inputFile)); - if (! is_array($payload)) { throw new WorkerFailedException('Invalid worker payload.'); } diff --git a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index 430ac85d..f986a13e 100644 --- a/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -16,6 +16,7 @@ use RuntimeException; use function array_fill; +use function array_filter; use function array_key_exists; use function array_map; use function array_pop; @@ -38,6 +39,10 @@ use function is_string; use function min; use function mkdir; +use function pcntl_wexitstatus; +use function pcntl_wifsignaled; +use function pcntl_wtermsig; +use function posix_getpid; use function proc_close; use function rmdir; use function serialize; @@ -47,15 +52,35 @@ use function unserialize; use function usleep; +use const PCNTL_EINTR; use const PHP_BINARY; use const PHP_OS_FAMILY; use const PHP_VERSION_ID; +use const SIGKILL; +use const STREAM_IPPROTO_IP; +use const STREAM_PF_UNIX; +use const STREAM_SOCK_STREAM; /** * @internal */ final readonly class ParallelAnalysisNodeExtractor { + /** + * Every function the fork path calls: disable_functions can remove any of them on its own. + */ + private const FORK_REQUIRED_FUNCTIONS = [ + 'pcntl_fork', + 'pcntl_get_last_error', + 'pcntl_waitpid', + 'pcntl_wifsignaled', + 'pcntl_wtermsig', + 'pcntl_wexitstatus', + 'posix_getpid', + 'posix_kill', + 'stream_socket_pair', + ]; + /** * @param array> $layers * @param array> $layerPatterns @@ -103,14 +128,18 @@ public function extract( mkdir($cacheDirectory, 0777, true); } - foreach ($this->buildWorkerBuckets($files, $workerCount) as $chunk) { - [ - 'inputFile' => $inputFile, - 'outputFile' => $outputFile, - 'stderrFile' => $stderrFile, - ] = $this->createWorkerFiles($cacheDirectory); + // A forked child inherits the loaded code, config and cache, so it skips the PHP + // boot, autoload and payload (un)serialisation an exec'd worker pays for. + $canFork = array_filter( + self::FORK_REQUIRED_FUNCTIONS, + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName + // to avoid error in test that mock it + static fn (string $function): bool => ! function_exists($function), + // phpcs:enable + ) === []; - file_put_contents($inputFile, serialize([ + foreach ($this->buildWorkerBuckets($files, $workerCount) as $chunk) { + $payload = [ 'basePath' => $this->basePath, 'layers' => $this->layers, 'layerPatterns' => $this->layerPatterns, @@ -119,9 +148,25 @@ public function extract( 'emitProgress' => $emitProgress, 'withFileAnalysis' => $withFileAnalysis, 'withSignatures' => $withSignatures, - 'cache' => $this->analysisResultCache?->forFiles($chunk), + 'cache' => $canFork + ? $this->analysisResultCache + : $this->analysisResultCache?->forFiles($chunk), 'cacheNamespace' => $this->analysisNodeCacheNamespace, - ])); + ]; + + if ($canFork) { + $pending[] = $this->forkWorker($cacheDirectory, $chunk, $payload); + + continue; + } + + [ + 'inputFile' => $inputFile, + 'outputFile' => $outputFile, + 'stderrFile' => $stderrFile, + ] = $this->createWorkerFiles($cacheDirectory); + + file_put_contents($inputFile, serialize($payload)); // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName // to avoid error in test that mock it @@ -147,6 +192,7 @@ public function extract( $pending[] = [ 'process' => $process, + 'pid' => null, 'files' => $chunk, 'buffer' => '', 'inputFile' => $inputFile, @@ -238,9 +284,13 @@ public function extract( // returns the real exit code (no double-waitpid race with proc_get_status). fclose($stdoutPipe); - /** @phpstan-var resource $procResource */ - $procResource = $worker['process']; - $exitCode = proc_close($procResource); + if ($worker['pid'] !== null) { + $exitCode = $this->reapForkedWorker($worker['pid']); + } else { + /** @phpstan-var resource $procResource */ + $procResource = $worker['process']; + $exitCode = proc_close($procResource); + } try { // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName @@ -382,11 +432,113 @@ private function buildWorkerBuckets(array $files, int $workerCount): array return $buckets; } + /** + * @param list $chunk + * @param array $payload + * @return array{ + * process: null, + * pid: int, + * files: list, + * buffer: string, + * inputFile: string, + * outputFile: string, + * stderrFile: string, + * stdoutPipe: resource + * } + */ + private function forkWorker(string $cacheDirectory, array $chunk, array $payload): array + { + $outputFile = $this->temporaryFile($cacheDirectory); + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName + // to avoid error in test that mock it + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + // phpcs:enable + + if ($sockets === false) { + $this->cleanup([$outputFile]); + + throw new RuntimeException('Unable to start parallel analysis worker.'); + } + + [$parentSocket, $childSocket] = $sockets; + + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName + // to avoid error in test that mock it + $pid = pcntl_fork(); + // phpcs:enable + + if ($pid === -1) { + fclose($parentSocket); + fclose($childSocket); + $this->cleanup([$outputFile]); + + throw new RuntimeException('Unable to start parallel analysis worker.'); + } + + if ($pid === 0) { + fclose($parentSocket); + AnalysisNodeWorker::run($payload, $outputFile, $childSocket); + fclose($childSocket); + + // The child shares the coordinator's state: exiting normally would run its + // shutdown functions, destructors and output buffers a second time. + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName + // to avoid error in test that mock it + posix_kill(posix_getpid(), SIGKILL); + // phpcs:enable + } + + fclose($childSocket); + + return [ + 'process' => null, + 'pid' => $pid, + 'files' => $chunk, + 'buffer' => '', + 'inputFile' => '', + 'outputFile' => $outputFile, + 'stderrFile' => '', + 'stdoutPipe' => $parentSocket, + ]; + } + + /** + * A forked worker always ends by SIGKILL-ing itself after writing its payload, so that + * signal counts as a normal exit; the payload then tells success from failure. + */ + private function reapForkedWorker(int $pid): int + { + // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName + // to avoid error in test that mock it + do { + $reapedPid = pcntl_waitpid($pid, $waitStatus); + } while ($reapedPid === -1 && pcntl_get_last_error() === PCNTL_EINTR); + + // phpcs:enable + + // Any other failure (ECHILD: the status was collected elsewhere, e.g. a host SIGCHLD handler): + // the worker has written its payload before the socket EOF, so that payload decides. + if ($reapedPid === -1) { + return 0; + } + + /** @var int $status */ + $status = $waitStatus; + + if (pcntl_wifsignaled($status)) { + $signal = pcntl_wtermsig($status); + + return $signal === SIGKILL ? 0 : 128 + $signal; + } + + return (int) pcntl_wexitstatus($status); + } + private function workerFailure(string $error, string $stderrFile): RuntimeException { // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName // to avoid error in test that mock it - $stderr = (string) file_get_contents($stderrFile); + $stderr = $stderrFile !== '' ? (string) file_get_contents($stderrFile) : ''; // phpcs:enable return new RuntimeException(sprintf( diff --git a/src/Cli/StructArmedApplication.php b/src/Cli/StructArmedApplication.php index 5f7244e8..597d467e 100644 --- a/src/Cli/StructArmedApplication.php +++ b/src/Cli/StructArmedApplication.php @@ -9,9 +9,11 @@ use function array_slice; use function array_values; +use function file_get_contents; use function getcwd; use function in_array; use function sprintf; +use function unserialize; final readonly class StructArmedApplication { @@ -27,7 +29,7 @@ public function run(array $argv, ?string $basePath = null): int $command = $arguments[0] ?? null; if ($command === '--internal-worker') { - return AnalysisNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); + return AnalysisNodeWorker::run(unserialize((string) file_get_contents($argv[2] ?? '')), $argv[3] ?? ''); } // Options may precede the command: `structarmed --basepath=../../ --clear-cache`. diff --git a/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php index 38ae1cb1..9a89bc6b 100644 --- a/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php +++ b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php @@ -18,7 +18,6 @@ use function file_get_contents; use function file_put_contents; use function fopen; -use function serialize; use function unserialize; #[CoversClass(AnalysisNodeWorker::class)] @@ -43,17 +42,14 @@ final class Foo } PHP); - $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); - file_put_contents($inputFile, serialize([ + $exitCode = AnalysisNodeWorker::run([ 'basePath' => $dir, 'layers' => ['Domain' => 'App\\Domain'], 'layerPatterns' => [], 'files' => [$srcFile], - ])); - - $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + ], $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); @@ -82,19 +78,16 @@ final class Foo } PHP); - $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); - file_put_contents($inputFile, serialize([ + $this->assertSame(0, AnalysisNodeWorker::run([ 'basePath' => $dir, 'layers' => ['Domain' => 'App\\Domain'], 'layerPatterns' => [], 'files' => [$srcFile], 'cache' => new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir), 'cacheNamespace' => 'namespace', - ])); - - $this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream())); + ], $outputFile, $this->silentStream())); $cached = (new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir)) ->loadAnalysisNodes($srcFile, 'namespace', true); @@ -120,14 +113,11 @@ public function run(Bar $bar): void } PHP); - $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); $payload = ['basePath' => $dir, 'layers' => [], 'layerPatterns' => [], 'files' => [$srcFile]]; foreach ([[[], 0], [['withSignatures' => true], 1]] as [$option, $signatureCount]) { - file_put_contents($inputFile, serialize($payload + $option)); - - $this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream())); + $this->assertSame(0, AnalysisNodeWorker::run($payload + $option, $outputFile, $this->silentStream())); $result = unserialize((string) file_get_contents($outputFile)); @@ -140,12 +130,9 @@ public function run(Bar $bar): void public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void { - $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); - file_put_contents($inputFile, serialize('not-an-array')); - - $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run('not-an-array', $outputFile, $this->silentStream()); $this->assertSame(1, $exitCode); @@ -179,17 +166,14 @@ final class FooService } PHP); - $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); - file_put_contents($inputFile, serialize([ + $exitCode = AnalysisNodeWorker::run([ 'basePath' => $dir, 'layers' => ['Domain' => 'App\\Domain'], 'layerPatterns' => ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]], 'files' => [$srcFile], - ])); - - $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + ], $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); diff --git a/tests/Analyser/Parallel/MockFunctions.php b/tests/Analyser/Parallel/MockFunctions.php index 18626e38..fb11ef00 100644 --- a/tests/Analyser/Parallel/MockFunctions.php +++ b/tests/Analyser/Parallel/MockFunctions.php @@ -4,10 +4,19 @@ namespace Boundwize\StructArmed\Analyser\Parallel; +use RuntimeException; + +use function array_shift; +use function array_slice; use function in_array; +use function is_int; +use function pcntl_exec; use function serialize; +use function sprintf; use function str_replace; +use const PCNTL_EINTR; + // phpcs:disable $GLOBALS['mock_proc_open'] = false; $GLOBALS['mock_proc_open_command'] = null; @@ -15,6 +24,12 @@ $GLOBALS['mock_stream_select'] = false; $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; +$GLOBALS['mock_stream_socket_pair'] = false; +$GLOBALS['mock_pcntl_fork'] = null; +$GLOBALS['mock_posix_kill'] = false; +$GLOBALS['mock_pcntl_waitpid_errors'] = []; +$GLOBALS['mock_pcntl_last_error'] = null; +$GLOBALS['mock_missing_functions'] = []; // phpcs:enable /** @@ -36,6 +51,25 @@ function proc_open(array|string $command, array $descriptorspec, array|null &$pi return \proc_open($command, $descriptorspec, $pipes); } +/** + * A mocked proc_open() is only reached on the exec'd worker path, so its tests run without pcntl_fork(). + */ +function function_exists(string $function): bool +{ + if ( + $function === 'pcntl_fork' + && ($GLOBALS['mock_proc_open'] === true || $GLOBALS['mock_proc_open_command'] !== null) + ) { + return false; + } + + if (in_array($function, (array) $GLOBALS['mock_missing_functions'], true)) { + return false; + } + + return \function_exists($function); +} + function tempnam(string $directory, string $prefix): string|false { if ($GLOBALS['mock_tempnam'] === true) { @@ -81,3 +115,85 @@ function file_get_contents(string $filename): string|false return \file_get_contents($filename); } + +/** + * @return array|false + */ +function stream_socket_pair(int $domain, int $type, int $protocol): array|false +{ + if ($GLOBALS['mock_stream_socket_pair'] === true) { + return false; + } + + return \stream_socket_pair($domain, $type, $protocol); +} + +/** + * An int is returned as the fork result (-1 failed, 0 pretends to be the child); a command + * forks for real and replaces the child with it, so the child never runs PHPUnit's shutdown. + */ +function pcntl_fork(): int +{ + /** @var int|list|null $mock */ + $mock = $GLOBALS['mock_pcntl_fork']; + + if ($mock === null) { + return \pcntl_fork(); + } + + if (is_int($mock)) { + return $mock; + } + + $pid = \pcntl_fork(); + + if ($pid === 0) { + pcntl_exec($mock[0], array_slice($mock, 1)); + } + + return $pid; +} + +/** + * Only reached by a child pretended in-process; stops it before it kills the test runner. + */ +function posix_kill(int $processId, int $signal): bool +{ + if ($GLOBALS['mock_posix_kill'] === true) { + throw new RuntimeException(sprintf('posix_kill(%d, %d)', $processId, $signal)); + } + + return \posix_kill($processId, $signal); +} + +/** + * Each queued error fails one call with -1: EINTR leaves the child for the retry, + * any other error reaps it for real first, as if its status was collected elsewhere. + */ +function pcntl_waitpid(int $processId, mixed &$status): int +{ + /** @var list $errors */ + $errors = $GLOBALS['mock_pcntl_waitpid_errors']; + $error = array_shift($errors); + + $GLOBALS['mock_pcntl_waitpid_errors'] = $errors; + $GLOBALS['mock_pcntl_last_error'] = $error; + + if ($error === null) { + return \pcntl_waitpid($processId, $status); + } + + if ($error !== PCNTL_EINTR) { + \pcntl_waitpid($processId, $status); + } + + return -1; +} + +function pcntl_get_last_error(): int +{ + /** @var int|null $error */ + $error = $GLOBALS['mock_pcntl_last_error']; + + return $error ?? \pcntl_get_last_error(); +} diff --git a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php index 65667c9e..d69182c8 100644 --- a/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php +++ b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php @@ -11,22 +11,33 @@ use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\RequiresFunction; use PHPUnit\Framework\TestCase; use RuntimeException; use function array_map; use function bin2hex; +use function count; +use function end; +use function file_get_contents; use function file_put_contents; +use function getmypid; use function glob; use function is_dir; +use function pcntl_waitpid; use function random_bytes; use function rmdir; use function sort; use function sprintf; use function sys_get_temp_dir; use function unlink; +use function unserialize; +use const PCNTL_ECHILD; +use const PCNTL_EINTR; use const PHP_BINARY; +use const SIGKILL; +use const WNOHANG; #[CoversClass(ParallelAnalysisNodeExtractor::class)] final class ParallelAnalysisNodeExtractorTest extends TestCase @@ -642,4 +653,160 @@ public function testExtractThrowsWhenErrorPayloadIsInvalid(): void $GLOBALS['mock_tracked_tempnam_files'] = []; } } + + #[RequiresFunction('pcntl_fork')] + #[RequiresFunction('posix_kill')] + public function testExtractThrowsWhenForkedWorkerCannotStart(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, ' true, 'mock_pcntl_fork' => -1] as $mock => $value) { + $GLOBALS[$mock] = $value; + + try { + (new ParallelAnalysisNodeExtractor($dir, [], [], 2))->extract([$file]); + $this->fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException $runtimeException) { + $this->assertSame('Unable to start parallel analysis worker.', $runtimeException->getMessage()); + } finally { + $GLOBALS['mock_stream_socket_pair'] = false; + $GLOBALS['mock_pcntl_fork'] = null; + } + } + } + + #[RequiresFunction('pcntl_fork')] + #[RequiresFunction('posix_kill')] + public function testForkedWorkerWritesPayloadThenKillsItself(): void + { + // pcntl_fork() returning 0 runs the child branch in this process; the mocked + // posix_kill() throws instead of killing the test runner. + $GLOBALS['mock_pcntl_fork'] = 0; + $GLOBALS['mock_posix_kill'] = true; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'extract([$file]); + $this->fail('Expected the forked worker to kill itself.'); + } catch (RuntimeException $runtimeException) { + $this->assertSame(sprintf('posix_kill(%d, %d)', getmypid(), SIGKILL), $runtimeException->getMessage()); + + /** @var list $outputFiles */ + $outputFiles = $GLOBALS['mock_tracked_tempnam_files']; + $outputFile = (string) end($outputFiles); + $result = unserialize((string) file_get_contents($outputFile)); + + unlink($outputFile); + + $this->assertIsArray($result); + $this->assertNull($result['error']); + $this->assertIsArray($result['nodes']); + $this->assertInstanceOf(ClassNode::class, $result['nodes'][0]); + $this->assertSame('ForkedWorkerFixture', $result['nodes'][0]->className); + } finally { + $GLOBALS['mock_pcntl_fork'] = null; + $GLOBALS['mock_posix_kill'] = false; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + #[RequiresFunction('pcntl_fork')] + #[RequiresFunction('posix_kill')] + public function testExtractReportsForkedWorkerThatDidNotKillItself(): void + { + // The forked child is replaced by a process that exits or is signalled without writing a payload. + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, ' 3, 'posix_kill(getmypid(), SIGTERM);' => 143] as $code => $exitCode) { + $GLOBALS['mock_pcntl_fork'] = [PHP_BINARY, '-r', $code]; + + try { + (new ParallelAnalysisNodeExtractor($dir, [], [], 2))->extract([$file]); + $this->fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException $runtimeException) { + $this->assertSame( + sprintf('[worker #1] Parallel analysis worker failed: worker exited with code %d', $exitCode), + $runtimeException->getMessage(), + ); + } finally { + $GLOBALS['mock_pcntl_fork'] = null; + } + } + } + + #[RequiresFunction('pcntl_fork')] + #[RequiresFunction('posix_kill')] + public function testExtractReportsErrorPayloadOfForkedWorkerExitingNonZero(): void + { + $GLOBALS['mock_pcntl_fork'] = [PHP_BINARY, '-r', 'exit(1);']; + $GLOBALS['mock_file_get_contents_payload'] = ['nodes' => [], 'error' => 'simulated payload error']; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'expectException(RuntimeException::class); + $this->expectExceptionMessage('[worker #1] Parallel analysis worker failed: simulated payload error'); + + try { + (new ParallelAnalysisNodeExtractor($dir, [], [], 2))->extract([$file]); + } finally { + $GLOBALS['mock_pcntl_fork'] = null; + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + public function testExtractUsesProcOpenWorkerWhenAnyForkFunctionIsDisabled(): void + { + $GLOBALS['mock_missing_functions'] = ['pcntl_waitpid']; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'assertCount(1, (new ParallelAnalysisNodeExtractor($dir, [], [], 2))->extract([$file])->classNodes); + + // input, output and stderr files: only an exec'd worker needs all three + $this->assertCount($trackedFiles + 3, (array) $GLOBALS['mock_tracked_tempnam_files']); + } finally { + $GLOBALS['mock_missing_functions'] = []; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + #[RequiresFunction('pcntl_fork')] + #[RequiresFunction('posix_kill')] + public function testForkedWorkerIsReapedAcrossWaitFailures(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'extract([$file]); + } finally { + $GLOBALS['mock_pcntl_waitpid_errors'] = []; + $GLOBALS['mock_pcntl_last_error'] = null; + } + + $this->assertCount(1, $extractionResult->classNodes); + $this->assertSame(-1, pcntl_waitpid(-1, $status, WNOHANG), 'no child is left unreaped'); + } + } }