laravel-websockets/tests/Commands/ShutdownStopsChildrenTest.php

139 lines
4.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
namespace BlaxSoftware\LaravelWebSockets\Test\Commands;
use BlaxSoftware\LaravelWebSockets\Console\Commands\StartServer;
use BlaxSoftware\LaravelWebSockets\Contracts\ChannelManager;
use BlaxSoftware\LaravelWebSockets\Helpers;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
use Illuminate\Console\OutputStyle;
use React\EventLoop\Factory as LoopFactory;
use React\Promise\PromiseInterface;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* Both shutdown paths of websockets:serve stop the message children that are
* still running (#2465). A child that outlived the server used to keep the
* listening port bound, and the restarted server failed with EADDRINUSE.
*/
class ShutdownStopsChildrenTest extends TestCase
{
/** @var int[] */
private array $spawned = [];
public function setUp(): void
{
parent::setUp();
if (! function_exists('pcntl_fork') || ! function_exists('posix_kill')) {
$this->markTestSkipped('pcntl and posix are required');
}
ChildProcesses::flush();
}
public function tearDown(): void
{
foreach ($this->spawned as $pid) {
@posix_kill($pid, SIGKILL);
@pcntl_waitpid($pid, $status);
}
ChildProcesses::flush();
parent::tearDown();
}
public function test_hard_shutdown_stops_running_children()
{
$pid = $this->spawnTrackedChild();
$command = $this->command(LoopFactory::create());
(new \ReflectionMethod(StartServer::class, 'triggerHardShutdown'))->invoke($command);
$this->assertFalse(@posix_kill($pid, 0), 'The child must not outlive the server.');
$this->assertSame(0, ChildProcesses::count());
}
public function test_soft_shutdown_stops_running_children()
{
$pid = $this->spawnTrackedChild();
$loop = LoopFactory::create();
$this->app->instance(ChannelManager::class, new class {
public function declineNewConnections(): void {}
public function getLocalConnections(): PromiseInterface
{
return Helpers::createFulfilledPromise([]);
}
});
$command = $this->command($loop);
$loop->addTimer(8, fn () => $loop->stop());
$loop->futureTick(fn () => (new \ReflectionMethod(StartServer::class, 'triggerSoftShutdown'))->invoke($command));
$loop->run();
$this->assertFalse(@posix_kill($pid, 0), 'The child must not outlive the server.');
$this->assertSame(0, ChildProcesses::count());
}
public function test_the_server_registers_its_listening_socket_for_children_to_close()
{
// No broadcast socket file left behind in /tmp by this test.
config(['websockets.broadcast_socket_enabled' => false]);
$registered = null;
$this->loop->futureTick(function () use (&$registered) {
$registered = ChildProcesses::listenerCount();
$this->loop->stop();
});
$this->artisan('websockets:serve', ['--loop' => $this->loop, '--host' => '127.0.0.1', '--port' => 0]);
$this->assertSame(1, $registered);
}
public function test_children_are_left_alone_when_disabled()
{
config(['websockets.shutdown.terminate_children' => false]);
$pid = $this->spawnTrackedChild();
$command = $this->command(LoopFactory::create());
(new \ReflectionMethod(StartServer::class, 'triggerHardShutdown'))->invoke($command);
$this->assertTrue(posix_kill($pid, 0), 'Opted out: the child keeps running.');
}
private function command($loop): StartServer
{
$command = $this->app->make(StartServer::class);
$command->setLaravel($this->app);
$command->setOutput(new OutputStyle(new ArrayInput([]), new BufferedOutput));
(new \ReflectionProperty(StartServer::class, 'loop'))->setValue($command, $loop);
return $command;
}
private function spawnTrackedChild(): int
{
$pid = pcntl_fork();
$this->assertNotSame(-1, $pid);
if ($pid === 0) {
// A busy message child: default SIGTERM disposition, as after
// ChildProcesses::prepareChild(). Never returns into PHPUnit.
ChildProcesses::restoreDefaultSignals();
while (true) {
usleep(50_000);
}
}
$this->spawned[] = $pid;
ChildProcesses::track($pid, 'child-crash.hold');
return $pid;
}
}