laravel-websockets/tests/Websocket/ChildMessageOutcomeTest.php

116 lines
4.0 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\Websocket\ChildMessageOutcome;
use PHPUnit\Framework\TestCase;
/**
* The parent answers for a message child that died without replying (#1462),
* and only then: never after a real reply, never for a clean exit, and never
* before the child's last frames have been read.
*/
class ChildMessageOutcomeTest extends TestCase
{
private const EXIT_OK = 0; // exit(0)
private const EXIT_FATAL = 255 << 8; // exit(255), what PHP does after a fatal error
private const KILLED = 9; // terminated by SIGKILL
private array $crashes = [];
private function outcome(string $event = 'atc-traffic.subscribe[x1]'): ChildMessageOutcome
{
$this->crashes = [];
return new ChildMessageOutcome($event, function (int $status) {
$this->crashes[] = $status;
});
}
public function test_a_killed_child_without_reply_is_answered_once_both_signals_are_in()
{
foreach ([[true, false], [false, true]] as [$streamFirst]) {
$outcome = $this->outcome();
$outcome->frameReceived('{"event":"atc-traffic.subscribe[x1]:progress","data":[]}');
if ($streamFirst) {
$outcome->streamClosed();
$this->assertSame([], $this->crashes, 'No verdict before the exit status is known.');
$outcome->exited(self::KILLED);
} else {
$outcome->exited(self::KILLED);
$this->assertSame([], $this->crashes, 'No verdict while frames may still be unread.');
$outcome->streamClosed();
}
$this->assertSame([self::KILLED], $this->crashes);
}
}
public function test_a_fatal_exit_without_reply_is_answered()
{
$outcome = $this->outcome();
$outcome->streamClosed();
$outcome->exited(self::EXIT_FATAL);
$this->assertSame([self::EXIT_FATAL], $this->crashes);
}
public function test_a_child_that_replied_is_left_alone_even_if_it_crashed_afterwards()
{
foreach (['response', 'error'] as $suffix) {
$outcome = $this->outcome();
$outcome->frameReceived('{"event":"atc-traffic.subscribe[x1]:' . $suffix . '","data":{"ok":true},"channel":"websocket"}');
$outcome->streamClosed();
$outcome->exited(self::EXIT_FATAL);
$this->assertTrue($outcome->replied());
$this->assertSame([], $this->crashes, "A {$suffix} frame counts as the reply.");
}
}
public function test_frames_for_other_events_or_broadcasts_are_not_a_reply()
{
$outcome = $this->outcome('dashboard.index');
$outcome->frameReceived('B:{"event":"dashboard.index:response"}');
$outcome->frameReceived('{"event":"dashboard.indexer:response"}');
$outcome->frameReceived('{"data":1,"event":"dashboard.index:response"}');
$outcome->frameReceived(null);
$this->assertFalse($outcome->replied());
}
public function test_event_names_are_matched_in_their_json_encoded_form()
{
$event = 'admin/user.show[a"b]';
$outcome = $this->outcome($event);
$outcome->frameReceived(json_encode(['event' => $event . ':response', 'data' => null]));
$this->assertTrue($outcome->replied());
}
public function test_a_clean_exit_or_an_unknown_status_sends_nothing()
{
foreach ([self::EXIT_OK, null] as $status) {
$outcome = $this->outcome();
$outcome->streamClosed();
$outcome->exited($status);
$this->assertSame([], $this->crashes);
}
}
public function test_the_verdict_is_given_only_once()
{
$outcome = $this->outcome();
$outcome->streamClosed();
$outcome->exited(self::KILLED);
$outcome->streamClosed();
$outcome->exited(self::KILLED);
$this->assertCount(1, $this->crashes);
}
}