laravel-websockets/tests/Server/ChildFatalErrorGuardTest.php

82 lines
2.9 KiB
PHP
Raw Permalink Normal View History

fix(fork): children never outlive the server or hold its port (#2465) A fork-per-message child inherits the server's listening socket. A child that outlived the server (a long-running handler such as an engine loop) kept the port bound, so the restarted server failed with EADDRINUSE until that child died. - Track every forked child (Server\ChildProcesses). On hard and soft shutdown, send SIGTERM to the ones still running, SIGKILL them after websockets.shutdown.child_grace_seconds (default 2) and reap them. A PID is signalled only while waitpid() confirms it is still an unreaped child, with SIGCHLD blocked, so a recycled PID is never hit. Opt out with websockets.shutdown.terminate_children = false. - Right after fork the child closes its copy of the listening socket, so even a stray child cannot hold the port. Only the fd is closed: the React server is not closed, because removing it from an ext-ev/ext-event loop would edit the epoll set the child shares with the parent. - The child restores default SIGTERM/SIGINT/SIGCHLD handling. Under ext-ev the inherited libev handlers swallowed SIGTERM in the child. - A child that dies of a fatal error (out of memory, E_ERROR) now sends <event>:error from a shutdown function (Server\ChildFatalErrorGuard, #1462). A child that dies without any reply (killed by a signal, or the shutdown function never ran) gets <event>:error from the parent, decided once both its stream and its exit status are in (Websocket\ChildMessageOutcome). - Tests: real forks, signals and sockets for the shutdown, the port release and the fatal reply; end to end through Handler::forkWithSocketPair(). The test TestCase now stops a leftover broadcast socket, which made ws_available() true for later tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:38:11 +00:00
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Server;
use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
use PHPUnit\Framework\TestCase;
/**
* Regression guard for #1462: a message child that died of a fatal error
* (out of memory, E_ERROR, ...) sent no `:error` frame and the client hung.
*
* Each case runs Fixtures/fatal_child.php in its own PHP process, which forks
* a child that really dies of the fatal error.
*/
class ChildFatalErrorGuardTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (! function_exists('pcntl_fork') || ! SocketPairIpc::isSupported()) {
$this->markTestSkipped('pcntl and sockets are required');
}
}
public function test_a_child_that_runs_out_of_memory_still_sends_the_error_frame()
{
$result = $this->runChild('oom');
$this->assertSame(255, $result['exit_code'], 'PHP exits with 255 after a fatal error.');
$this->assertCount(1, $result['frames']);
$frame = json_decode($result['frames'][0], true);
$this->assertSame('demo.crash:error', $frame['event']);
$this->assertSame('Internal server error', $frame['data']['message']);
$this->assertTrue($frame['data']['meta']['fatal']);
$this->assertTrue($result['replied'], 'The parent recognises the frame as the final reply.');
$this->assertStringContainsString('app handler saw: Allowed memory size', $result['stderr'], 'The earlier app handler still ran first.');
}
public function test_a_disarmed_guard_stays_silent()
{
$result = $this->runChild('oom-disarmed');
$this->assertSame(255, $result['exit_code']);
$this->assertSame([], $result['frames']);
$this->assertFalse($result['replied']);
}
public function test_a_normal_exit_sends_nothing()
{
$this->assertSame([], $this->runChild('clean-exit')['frames']);
$this->assertSame([], $this->runChild('warning-then-exit')['frames'], 'A non-fatal last error is not a crash.');
}
/**
* @return array{frames: string[], exit_code: ?int, replied: bool, stderr: string}
*/
private function runChild(string $mode): array
{
$process = proc_open(
[PHP_BINARY, '-d', 'display_errors=stderr', '-d', 'memory_limit=128M', __DIR__ . '/Fixtures/fatal_child.php', $mode],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes
);
$this->assertIsResource($process);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$result = json_decode((string) $stdout, true);
$this->assertIsArray($result, "Driver output was not JSON.\nstdout: {$stdout}\nstderr: {$stderr}");
return $result + ['stderr' => $stderr];
}
}