laravel-websockets/tests/Websocket/HandlerChildLifecycleTest.php

205 lines
7.7 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\Websocket;
use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
use BlaxSoftware\LaravelWebSockets\Test\Mocks;
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
use BlaxSoftware\LaravelWebSockets\Websocket\ChildMessageOutcome;
use BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver;
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
use BlaxSoftware\LaravelWebSockets\Websocket\Handler;
/**
* End to end through Handler::forkWithSocketPair(), with real message
* children (#2465, #1462):
* - the child closes its copy of the server's listening socket,
* - the shutdown stops a child that is still running,
* - a child that dies without replying is answered by the parent,
* - a child that replied gets no extra frame.
*/
class HandlerChildLifecycleTest extends TestCase
{
private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Websocket\\Fixtures\\ChildCrash';
public function setUp(): void
{
parent::setUp();
if (! SocketPairIpc::isSupported() || ! function_exists('pcntl_fork') || ! function_exists('posix_kill')) {
$this->markTestSkipped('pcntl, posix and sockets are required');
}
if (! class_exists(\Illuminate\Support\Defer\DeferredCallbackCollection::class)) {
require_once __DIR__ . '/Fixtures/DeferredCallbackCollectionStub.php';
}
ChildProcesses::flush();
ControllerResolver::clearCache();
EventRegistry::clear();
EventRegistry::setSearchPaths([
__DIR__ . '/Fixtures/ChildCrash' => self::FIXTURES_NS,
]);
}
public function tearDown(): void
{
// Never leave a forked child behind, even after a failed assertion.
ChildProcesses::terminateAll(0.0, 1.0);
ChildProcesses::flush();
EventRegistry::clear();
(new \ReflectionProperty(EventRegistry::class, 'searchPaths'))->setValue(null, null);
parent::tearDown();
}
public function test_a_child_killed_before_replying_is_answered_by_the_parent()
{
$connection = $this->send('child-crash.killed');
$this->runLoopUntil(fn () => $this->sentEvent($connection, 'child-crash.killed:error') !== null);
$error = $this->sentEvent($connection, 'child-crash.killed:error');
$this->assertNotNull($error, 'The client must not wait forever for a child that died.');
$this->assertSame('Internal server error', $error['data']['message']);
$this->assertFalse($error['data']['meta']['reported']);
$this->assertSame(0, ChildProcesses::count(), 'The dead child was reaped and forgotten.');
}
public function test_a_child_that_replied_gets_no_extra_error_frame()
{
$connection = $this->send('child-crash.ok');
$this->runLoopUntil(
fn () => ChildProcesses::count() === 0 && $this->sentEvent($connection, 'child-crash.ok:response') !== null,
5.0,
0.2
);
$response = $this->sentEvent($connection, 'child-crash.ok:response');
$this->assertNotNull($response);
$this->assertSame(['ok' => true], $response['data']);
$connection->assertNotSentEvent('child-crash.ok:error');
}
public function test_a_running_child_neither_holds_the_port_nor_survives_the_shutdown()
{
$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
$this->assertIsResource($server, $errstr);
$port = (int) parse_url('tcp://' . stream_socket_get_name($server, false), PHP_URL_PORT);
$this->assertTrue(ChildProcesses::registerListener($server));
$connection = $this->send('child-crash.hold');
$this->assertSame(1, ChildProcesses::count());
[$pid] = ChildProcesses::pids();
// The server closes its socket while the child is still busy: the port
// frees as soon as the child has dropped its inherited copy.
fclose($server);
$successor = false;
$deadline = microtime(true) + 5;
while ($successor === false && microtime(true) < $deadline) {
$successor = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
if ($successor === false) {
usleep(20_000);
}
}
$this->assertIsResource($successor, "The busy child kept port {$port} bound: {$errstr}");
fclose($successor);
$this->assertTrue(posix_kill($pid, 0), 'The child is still running its handler.');
// Server shutdown: SIGTERM does end the child (default disposition
// restored after fork), no SIGKILL needed.
$stats = ChildProcesses::terminateAll(2.0);
$this->assertSame(['terminated' => 1, 'killed' => 0, 'remaining' => 0], $stats);
$this->assertFalse(@posix_kill($pid, 0));
// Its client is told instead of waiting for a reply that never comes.
$this->runLoopUntil(fn () => $this->sentEvent($connection, 'child-crash.hold:error') !== null);
$error = $this->sentEvent($connection, 'child-crash.hold:error');
$this->assertNotNull($error);
$this->assertSame('Server is restarting', $error['data']['message']);
}
public function test_the_fatal_error_frame_is_recognised_as_the_reply()
{
$method = new \ReflectionMethod(Handler::class, 'fatalErrorFrame');
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
$message = ['event' => 'child-crash.ok[u1]', 'data' => [], 'channel' => 'crash-channel'];
$frame = $method->invoke(null, $message, 'crash-channel');
// Same shape as the controller's catch-all error frame.
$this->assertSame([
'event' => 'child-crash.ok[u1]:error',
'data' => ['message' => 'Internal server error', 'meta' => ['reported' => true, 'fatal' => true]],
'channel' => 'crash-channel',
], json_decode($frame, true));
// The parent must count it as the reply, or it would add its own
// error frame on top of it.
$outcome = new ChildMessageOutcome($message['event'], fn () => null);
$outcome->frameReceived($frame);
$this->assertTrue($outcome->replied());
}
private function send(string $event): Mocks\Connection
{
$connection = $this->newActiveConnection(['crash-channel']);
$connection->resetEvents();
$this->wsHandler->onMessage($connection, new Mocks\Message([
'event' => $event,
'data' => [],
'channel' => 'crash-channel',
]));
return $connection;
}
/**
* Run the loop with StartServer's reaper until $done or the timeout, then
* keep it running for $settle seconds.
*/
private function runLoopUntil(callable $done, float $timeout = 5.0, float $settle = 0.0): void
{
$finishedAt = null;
$tick = $this->loop->addPeriodicTimer(0.02, function () use ($done, $settle, &$finishedAt) {
ChildProcesses::reapExited();
if ($finishedAt === null && $done()) {
$finishedAt = microtime(true);
}
if ($finishedAt !== null && microtime(true) - $finishedAt >= $settle) {
$this->loop->stop();
}
});
$timeoutTimer = $this->loop->addTimer($timeout, fn () => $this->loop->stop());
$this->loop->run();
$this->loop->cancelTimer($tick);
$this->loop->cancelTimer($timeoutTimer);
}
private function sentEvent(Mocks\Connection $connection, string $event): ?array
{
foreach ($connection->sentData as $frame) {
if (($frame['event'] ?? null) === $event) {
return $frame;
}
}
return null;
}
}