diff --git a/src/Runner.php b/src/Runner.php index eef1ac4..a76a012 100644 --- a/src/Runner.php +++ b/src/Runner.php @@ -14,6 +14,8 @@ final readonly class Runner implements RunnerInterface { + private const CACHE_WARMUP_TIMEOUT = 30; + public function __construct( private KernelFactory $kernelFactory, ) { @@ -34,16 +36,58 @@ public function run(): int throw new \RuntimeException('Failed to fork process for cache warmup'); } if ($pid === 0) { + $success = false; try { $this->kernelFactory->createKernel()->boot(); - exit(0); + $success = true; } catch (\Throwable $e) { fwrite(STDERR, $e->getMessage() . PHP_EOL); - exit(1); } + // Use posix_kill with different signals to distinguish success/failure: + // - SIGKILL (9) for success + // - SIGTERM (15) for error + // This avoids deadlock with extensions that register shutdown handlers (e.g., grpc) + \posix_kill((int) \getmypid(), $success ? \SIGKILL : \SIGTERM); } - $waitResult = pcntl_wait($status); - if ($waitResult === -1 || !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + + $timeout = self::CACHE_WARMUP_TIMEOUT; + $deadline = \time() + $timeout; + $status = 0; + + while (true) { + $result = \pcntl_waitpid($pid, $status, WNOHANG); + + if ($result === $pid) { + break; + } + + if ($result === -1) { + throw new \RuntimeException('Failed to wait for cache warmup process'); + } + + if (\time() >= $deadline) { + \posix_kill($pid, \SIGKILL); + \pcntl_waitpid($pid, $status, 0); + throw new \RuntimeException(\sprintf('Cache warmup timed out after %d seconds', $timeout)); + } + + \usleep(100_000); + } + + if (!\pcntl_wifexited($status)) { + if (!\pcntl_wifsignaled($status)) { + throw new \RuntimeException('Cache warmup failed in forked process'); + } + $signal = \pcntl_wtermsig($status); + // SIGKILL (9) = success (child killed itself after successful boot) + // SIGTERM (15) = error (child killed itself after exception) + if ($signal === \SIGTERM) { + throw new \RuntimeException('Cache warmup failed in forked process'); + } + if ($signal !== \SIGKILL) { + throw new \RuntimeException('Cache warmup failed in forked process'); + } + } elseif (\pcntl_wexitstatus($status) !== 0) { throw new \RuntimeException('Cache warmup failed in forked process'); } } diff --git a/tests/Fixtures/runner_test_runner.php b/tests/Fixtures/runner_test_runner.php index b23f5b6..283fae4 100644 --- a/tests/Fixtures/runner_test_runner.php +++ b/tests/Fixtures/runner_test_runner.php @@ -52,6 +52,9 @@ function assertContains(string $needle, string $haystack, string $message): void 'child_normal_exit' => testChildNormalExit(), 'child_exit_nonzero' => testChildExitNonzero(), 'signal_killed_child' => testSignalKilledChild(), + 'child_success_sigkill' => testChildSuccessSigkill(), + 'child_error_sigterm' => testChildErrorSigterm(), + 'timeout_kills_child' => testTimeoutKillsChild(), default => (function () use ($testName): never { fwrite(STDERR, "Unknown test: $testName\n"); exit(2); @@ -207,3 +210,109 @@ function testSignalKilledChild(): void pass(); } + +/** + * Test: Child process uses posix_kill(getmypid(), SIGKILL) for success. + * Verifies parent recognizes SIGKILL as success (cache warmup completed). + */ +function testChildSuccessSigkill(): void +{ + $pid = pcntl_fork(); + if ($pid === -1) { + fail('Fork failed'); + } + if ($pid === 0) { + // Simulate successful cache warmup, then self-kill with SIGKILL + posix_kill((int) getmypid(), SIGKILL); + exit(0); // unreachable + } + + $status = 0; + $waitResult = pcntl_wait($status); + if ($waitResult === -1) { + fail('pcntl_wait returned -1'); + } + if (pcntl_wifexited($status)) { + fail('Child should NOT appear as exited normally (was killed by signal)'); + } + if (!pcntl_wifsignaled($status)) { + fail('Child should appear as killed by signal'); + } + if (pcntl_wtermsig($status) !== SIGKILL) { + fail('Child should have been killed by SIGKILL (9), got ' . pcntl_wtermsig($status)); + } + + pass(); +} + +/** + * Test: Child process uses posix_kill(getmypid(), SIGTERM) for error. + * Verifies parent recognizes SIGTERM as failure (cache warmup failed). + */ +function testChildErrorSigterm(): void +{ + $pid = pcntl_fork(); + if ($pid === -1) { + fail('Fork failed'); + } + if ($pid === 0) { + fwrite(STDERR, "Error message\n"); + // Simulate failed cache warmup, then self-kill with SIGTERM + posix_kill((int) getmypid(), SIGTERM); + exit(0); // unreachable + } + + $status = 0; + $waitResult = pcntl_wait($status); + if ($waitResult === -1) { + fail('pcntl_wait returned -1'); + } + if (pcntl_wifexited($status)) { + fail('Child should NOT appear as exited normally (was killed by signal)'); + } + if (!pcntl_wifsignaled($status)) { + fail('Child should appear as killed by signal'); + } + if (pcntl_wtermsig($status) !== SIGTERM) { + fail('Child should have been killed by SIGTERM (15), got ' . pcntl_wtermsig($status)); + } + + pass(); +} + +/** + * Test: Timeout kills child that doesn't finish in time. + * Verifies parent correctly waits with WNOHANG and kills child on timeout. + */ +function testTimeoutKillsChild(): void +{ + $pid = pcntl_fork(); + if ($pid === -1) { + fail('Fork failed'); + } + if ($pid === 0) { + // Child sleeps forever (simulates stuck cache warmup) + sleep(120); + exit(0); // unreachable + } + + // Wait a short time + usleep(100_000); // 100ms + + // Child should still be alive, kill it with SIGKILL (simulating timeout behavior) + posix_kill($pid, SIGKILL); + + $status = 0; + $waitResult = pcntl_wait($status); + if ($waitResult === -1) { + fail('pcntl_wait returned -1'); + } + if (!pcntl_wifsignaled($status)) { + fail('Child should appear as killed by signal'); + } + if (pcntl_wtermsig($status) !== SIGKILL) { + fail('Child should have been killed by SIGKILL, got ' . pcntl_wtermsig($status)); + } + + pass(); +} diff --git a/tests/RunnerTest.php b/tests/RunnerTest.php index b7c3b5a..3f0608d 100644 --- a/tests/RunnerTest.php +++ b/tests/RunnerTest.php @@ -50,6 +50,30 @@ public function testRunnerUsesCorrectForkErrorHandling(): void $content, 'Must throw when child exits with non-zero code', ); + + $this->assertStringContainsString( + 'SIGKILL', + $content, + 'Must use SIGKILL for success signal', + ); + + $this->assertStringContainsString( + 'SIGTERM', + $content, + 'Must use SIGTERM for error signal', + ); + + $this->assertStringContainsString( + 'Cache warmup timed out', + $content, + 'Must have timeout error message', + ); + + $this->assertStringContainsString( + 'CACHE_WARMUP_TIMEOUT', + $content, + 'Must have CACHE_WARMUP_TIMEOUT constant', + ); } /** @@ -88,6 +112,33 @@ public function testSignalKilledChildIsDetected(): void $this->runIsolatedTest('signal_killed_child'); } + /** + * Test: Child process uses posix_kill(getmypid(), SIGKILL) for success. + * Verifies parent recognizes SIGKILL as success (cache warmup completed). + */ + public function testChildSuccessSigkillIsRecognizedAsSuccess(): void + { + $this->runIsolatedTest('child_success_sigkill'); + } + + /** + * Test: Child process uses posix_kill(getmypid(), SIGTERM) for error. + * Verifies parent recognizes SIGTERM as failure (cache warmup failed). + */ + public function testChildErrorSigtermIsRecognizedAsError(): void + { + $this->runIsolatedTest('child_error_sigterm'); + } + + /** + * Test: Timeout kills child that doesn't finish in time. + * Verifies parent correctly waits with WNOHANG and kills child on timeout. + */ + public function testTimeoutKillsChild(): void + { + $this->runIsolatedTest('timeout_kills_child'); + } + /** * Run a test in an isolated PHP process to avoid PHPUnit * state inheritance issues with pcntl_fork().