laravel-websockets/tests/Server/Fixtures/fatal_child.php

98 lines
2.5 KiB
PHP
Raw 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
/**
* Standalone driver for ChildFatalErrorGuardTest.
*
* Runs outside PHPUnit and Laravel on purpose: a real fatal error in a forked
* PHPUnit process would print the framework's console error rendering into
* the test output. Forks one child over a SocketPairIpc, lets it die in the
* requested way, and prints what the parent received as JSON.
*
* Usage: php fatal_child.php <oom|oom-disarmed|warning-then-exit|clean-exit>
*/
declare(strict_types=1);
use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
use BlaxSoftware\LaravelWebSockets\Server\ChildFatalErrorGuard;
use BlaxSoftware\LaravelWebSockets\Websocket\ChildMessageOutcome;
use React\EventLoop\StreamSelectLoop;
require __DIR__ . '/../../../vendor/autoload.php';
$mode = $argv[1] ?? 'oom';
$loop = new StreamSelectLoop();
$ipc = SocketPairIpc::create($loop);
$pid = pcntl_fork();
if ($pid === 0) {
$ipc->setupChild();
// Stand-in for Laravel's HandleExceptions: registered earlier (in the
// server process), so it runs before the guard and sees the error first.
register_shutdown_function(function () {
$error = error_get_last();
if ($error !== null) {
fwrite(STDERR, 'app handler saw: ' . $error['message'] . "\n");
}
});
$frame = json_encode([
'event' => 'demo.crash:error',
'data' => ['message' => 'Internal server error', 'meta' => ['reported' => true, 'fatal' => true]],
'channel' => 'test',
]);
$guard = ChildFatalErrorGuard::register(function () use ($ipc, $frame) {
$ipc->sendToParent($frame);
});
if ($mode === 'clean-exit') {
exit(0);
}
if ($mode === 'warning-then-exit') {
@trigger_error('just a warning', E_USER_WARNING);
exit(0);
}
if ($mode === 'oom-disarmed') {
$guard->disarm();
}
ini_set('memory_limit', '32M');
$hog = [];
while (true) {
$hog[] = str_repeat('x', 1024 * 1024);
}
}
$frames = [];
$outcome = new ChildMessageOutcome('demo.crash', function () {
});
$ipc->setupParent(
function ($data) use (&$frames, $outcome) {
$frames[] = $data;
$outcome->frameReceived($data);
},
function () use ($loop) {
$loop->stop();
}
);
$loop->addTimer(10, function () use ($loop) {
$loop->stop();
});
$loop->run();
pcntl_waitpid($pid, $status);
echo json_encode([
'frames' => $frames,
'exit_code' => pcntl_wifexited($status) ? pcntl_wexitstatus($status) : null,
'replied' => $outcome->replied(),
]);