Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions src/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

final readonly class Runner implements RunnerInterface
{
private const CACHE_WARMUP_TIMEOUT = 30;

public function __construct(
private KernelFactory $kernelFactory,
) {
Expand All @@ -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');
}
}
Expand Down
109 changes: 109 additions & 0 deletions tests/Fixtures/runner_test_runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
51 changes: 51 additions & 0 deletions tests/RunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}

/**
Expand Down Expand Up @@ -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().
Expand Down
Loading