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>
This commit is contained in:
Fabian @ Blax Software 2026-10-14 10:38:11 +02:00
parent c169e418ca
commit 35ed320202
16 changed files with 1927 additions and 14 deletions

View File

@ -30,6 +30,17 @@ Consequences — internalize these, they change how you write handlers:
cache. Each child gets a *fresh* DB connection and purged Redis/cache singletons.
- **Concurrency is capped** by `config('websockets.max_concurrent_children')` (protects
MySQL `max_connections`); excess messages queue in memory and run as children free up.
- **A child never outlives the server.** Right after fork the child closes its copy of the
listening socket and gets default signal handling back; on shutdown the server SIGTERMs
running children, SIGKILLs them after `websockets.shutdown.child_grace_seconds` (default 2)
and reaps them (`src/Server/ChildProcesses.php`). A handler that blocks for minutes (an
engine loop) is killed on every restart, so keep its state in Redis/DB and let a later
message take over. Opt out with `websockets.shutdown.terminate_children = false`.
- **A child that dies still answers.** A fatal error (out of memory, E_ERROR) sends
`<event>:error` with `meta.fatal = true` from a shutdown function
(`src/Server/ChildFatalErrorGuard.php`); a child that dies with no reply at all (killed by a
signal, or the shutdown function never ran) gets `<event>:error` from the parent
(`src/Websocket/ChildMessageOutcome.php`).
---
@ -324,7 +335,8 @@ migrated to real queued **Jobs** (which also get retry semantics + show up in to
| Out-of-band broadcast/whisper + tracking | `src/Services/WebsocketService.php`, `src/Broadcast/BroadcastClient.php` |
| Per-connection store | `src/Websocket/ConnectionSession.php` |
| Canonical handler example | `src/Websocket/Controllers/ExampleController.php` |
| Config (introspection, hot_reload, max_concurrent_children, broadcast_socket) | `config/websockets.php` |
| Config (introspection, hot_reload, max_concurrent_children, shutdown, broadcast_socket) | `config/websockets.php` |
| Child lifecycle: shutdown kill, listener close, fatal-error reply | `src/Server/ChildProcesses.php`, `src/Server/ChildFatalErrorGuard.php`, `src/Websocket/ChildMessageOutcome.php` |
**Frontend counterpart:** the client side of this protocol (how `:progress`/`:response`/`:error`
are consumed) is documented in `@blax-software/networking`'s `AGENTS.md`.

View File

@ -99,6 +99,26 @@ return [
*/
'max_concurrent_children' => (int) env('WEBSOCKET_MAX_CHILDREN', 30),
/*
|--------------------------------------------------------------------------
| Shutdown: running fork children
|--------------------------------------------------------------------------
|
| When the server stops (SIGTERM, websockets:restart, websocket:steer
| restart), message children that are still running get SIGTERM, then
| SIGKILL once the grace period is over, and are reaped. A child that
| outlived the server used to keep running as an orphan (and, before
| children closed their copy of the listening socket, kept the port bound).
|
| Long-running handlers that must clean up can install their own SIGTERM
| handler inside the child; everything else simply dies on SIGTERM.
|
*/
'shutdown' => [
'terminate_children' => env('WEBSOCKET_SHUTDOWN_TERMINATE_CHILDREN', true),
'child_grace_seconds' => (float) env('WEBSOCKET_SHUTDOWN_CHILD_GRACE', 2.0),
],
/*
|--------------------------------------------------------------------------
| Broadcast Socket Settings

View File

@ -11,6 +11,7 @@ use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
use BlaxSoftware\LaravelWebSockets\Server\Loggers\ConnectionLogger;
use BlaxSoftware\LaravelWebSockets\Server\Loggers\HttpLogger;
use BlaxSoftware\LaravelWebSockets\Server\Loggers\WebSocketsLogger;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
use BlaxSoftware\LaravelWebSockets\ServerFactory;
use BlaxSoftware\LaravelWebSockets\Services\WebsocketService;
use Illuminate\Console\Command;
@ -520,12 +521,40 @@ class StartServer extends Command
return 0;
}
$reaped = 0;
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
$reaped++;
// Also drops each reaped child from the list terminateChildren() stops.
return ChildProcesses::reapExited();
}
return $reaped;
/**
* Stop the fork-per-message children that are still running.
*
* A child outlives the server otherwise: the ATC engine in learn-atc blocks
* its child for up to two hours, kept the listening port bound and made the
* restarted server fail with EADDRINUSE (#2465). Running children get
* SIGTERM, then SIGKILL after the grace period, and are reaped. Blocks the
* loop for at most the grace period plus one second; only called while
* shutting down.
*/
protected function terminateChildren(): void
{
if (! config('websockets.shutdown.terminate_children', true)) {
return;
}
try {
$grace = max(0.0, (float) config('websockets.shutdown.child_grace_seconds', 2.0));
$running = ChildProcesses::count();
$stats = ChildProcesses::terminateAll($grace);
if ($stats['terminated'] > 0 || $stats['killed'] > 0 || $stats['remaining'] > 0) {
\Log::channel('websocket')->info('Stopped fork children on shutdown', $stats + [
'tracked' => $running,
'grace_seconds' => $grace,
]);
}
} catch (\Throwable $e) {
\Log::channel('websocket')->warning('Stopping fork children on shutdown failed: ' . $e->getMessage());
}
}
/**
@ -907,6 +936,12 @@ class StartServer extends Command
->setConsoleOutput($this->output)
->createServer();
// Forked message children close their inherited copy of this
// socket, so none of them can keep the port bound (#2465).
if (! ChildProcesses::registerListener($this->server->socket)) {
\Log::channel('websocket')->warning('Could not find the listening socket; forked children will keep it open.');
}
break;
} catch (\RuntimeException $e) {
$isAddrInUse = str_contains($e->getMessage(), 'EADDRINUSE')
@ -992,10 +1027,10 @@ class StartServer extends Command
// Stop the broadcast socket server
$this->stopBroadcastSocket();
// Reap any already-exited fork children before we exit so they are not
// handed to PID 1 as orphans (still-running children are unavoidably
// reparented and reaped by the container init/supervisord).
// Reap the children that already exited, then stop and reap the ones
// still running, so none is handed to PID 1 as an orphan.
$this->reapChildren();
$this->terminateChildren();
$this->loop->stop();
}
@ -1069,9 +1104,10 @@ class StartServer extends Command
// Stop the broadcast socket server
$this->stopBroadcastSocket();
// Reap any already-exited fork children before we exit (see
// Reap and stop the fork children before we exit (see
// triggerHardShutdown).
$this->reapChildren();
$this->terminateChildren();
$this->loop->stop();
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Server;
/**
* Lets a forked message child answer even when it dies of a fatal error.
*
* A fatal error (out of memory, E_ERROR, E_PARSE, E_CORE_ERROR,
* E_COMPILE_ERROR) skips every catch block, so the child exited without
* sending the `:error` frame and the client waited forever (#1462). The guard
* registers a shutdown function in the child; while it is armed and the last
* error is fatal, it calls the callback, which sends a pre-encoded frame.
*
* Shutdown functions run in registration order, so the application's own
* fatal handler (Laravel's HandleExceptions, inherited from the server
* process) reports the error first. If that handler dies of a second fatal
* error or calls exit(), this one never runs; the parent then notices the
* abnormal exit status instead (see ChildMessageOutcome).
*/
final class ChildFatalErrorGuard
{
public const FATAL_ERROR_TYPES = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
/**
* Extra memory_limit headroom granted to the callback after an
* out-of-memory fatal error, so writing the frame cannot fail the same way.
*/
private const OOM_HEADROOM_BYTES = 16 * 1024 * 1024;
private bool $armed = true;
/** @var \Closure(array): void */
private \Closure $onFatal;
/**
* Memory released before the callback runs.
*/
private ?string $reserve;
private function __construct(callable $onFatal, int $reserveBytes)
{
$this->onFatal = \Closure::fromCallable($onFatal);
$this->reserve = $reserveBytes > 0 ? str_repeat("\0", $reserveBytes) : null;
}
/**
* Register the guard in the current (child) process. It starts armed.
*
* @param callable(array{type: int, message: string, file: string, line: int}): void $onFatal
*/
public static function register(callable $onFatal, int $reserveBytes = 32768): self
{
$guard = new self($onFatal, $reserveBytes);
register_shutdown_function([$guard, 'handleShutdown']);
return $guard;
}
/**
* Stop reacting to fatal errors, for example once the handler has replied
* or a catch block has already sent its own error frame.
*/
public function disarm(): void
{
$this->armed = false;
$this->reserve = null;
}
public function isArmed(): bool
{
return $this->armed;
}
/**
* @internal called by PHP at shutdown
*/
public function handleShutdown(): void
{
if (! $this->armed) {
return;
}
$error = error_get_last();
if ($error === null || ! in_array($error['type'], self::FATAL_ERROR_TYPES, true)) {
return;
}
$this->armed = false;
$this->reserve = null;
if (str_starts_with((string) $error['message'], 'Allowed memory size')) {
$this->raiseMemoryLimit();
}
try {
($this->onFatal)($error);
} catch (\Throwable) {
// The child is dying; there is nobody left to tell.
}
}
private function raiseMemoryLimit(): void
{
$limit = (string) ini_get('memory_limit');
if ($limit === '' || $limit === '-1') {
return;
}
@ini_set('memory_limit', (string) (memory_get_usage(true) + self::OOM_HEADROOM_BYTES));
}
}

View File

@ -0,0 +1,466 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Server;
use React\EventLoop\LoopInterface;
use React\EventLoop\StreamSelectLoop;
/**
* Bookkeeping for the fork-per-message children of `websockets:serve`.
*
* Every forked child inherits the server's listening socket. A child that
* outlives the server (the ATC engine in learn-atc blocks its child for up to
* two hours) kept the port bound, so the restarted server failed with
* EADDRINUSE until that child died (#2465). Two independent guards:
*
* - the parent tracks every child PID and, on shutdown, SIGTERMs the ones
* still running, waits a bounded grace period, SIGKILLs the survivors and
* reaps them ({@see terminateAll()});
* - the child closes its copy of the listening socket right after fork, so
* even a child that escapes the shutdown cannot hold the port
* ({@see prepareChild()}).
*
* State is static on purpose: the Handler forks, the StartServer command shuts
* down, and both live in the same single-threaded server process.
*/
final class ChildProcesses
{
/**
* Running children of this process.
*
* @var array<int, array{event: ?string, started_at: float, on_exit: ?callable}>
*/
private static array $children = [];
/**
* Listening sockets a forked child must close.
*
* @var resource[]
*/
private static array $listeners = [];
/**
* Set once terminateAll() starts, so exit callbacks can tell a child
* stopped by the shutdown from one that crashed.
*/
private static bool $terminating = false;
/**
* Signals the server process installs handlers for. A forked child gets
* the default dispositions back (see restoreDefaultSignals()).
*/
private const SERVER_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGCHLD'];
/**
* Record a freshly forked child.
*
* $onExit is called once with the raw wait status when the child is
* reaped, or with null when it was reaped somewhere this registry could
* not see (its status is then unknown).
*/
public static function track(int $pid, ?string $event = null, ?callable $onExit = null): void
{
if ($pid <= 0) {
return;
}
self::$children[$pid] = [
'event' => $event,
'started_at' => microtime(true),
'on_exit' => $onExit,
];
}
/**
* PIDs of the tracked children.
*
* @return int[]
*/
public static function pids(): array
{
return array_keys(self::$children);
}
public static function count(): int
{
return count(self::$children);
}
/**
* Reap every child that has already exited, without blocking.
*
* Safe from a SIGCHLD handler, a timer or the shutdown path: WNOHANG only
* collects children that are gone. Untracked children are reaped too.
*
* @return int number of children reaped
*/
public static function reapExited(): int
{
if (! function_exists('pcntl_waitpid')) {
return 0;
}
$reaped = 0;
while (($pid = pcntl_waitpid(-1, $status, WNOHANG)) > 0) {
$reaped++;
self::finish($pid, $status);
}
return $reaped;
}
/**
* Stop every tracked child that is still running.
*
* SIGTERM first, then poll for up to $graceSeconds, then SIGKILL whatever
* is left and wait up to $killWaitSeconds for the kernel to finish it off.
* Every child that exits is reaped. Blocks for at most
* $graceSeconds + $killWaitSeconds, so call it only on shutdown.
*
* A PID is signalled only while waitpid() confirms it is still an unreaped
* child of this process, and SIGCHLD is blocked meanwhile so no handler can
* reap it between that check and the kill. A recycled PID that belongs to
* another process is therefore never signalled.
*
* @return array{terminated: int, killed: int, remaining: int} children sent
* SIGTERM, children sent SIGKILL, children still alive at the end
*/
public static function terminateAll(float $graceSeconds = 2.0, float $killWaitSeconds = 1.0): array
{
$stats = ['terminated' => 0, 'killed' => 0, 'remaining' => 0];
if (! function_exists('pcntl_waitpid')) {
return $stats;
}
self::$terminating = true;
$blocked = self::blockSigchld();
try {
self::reapExited();
$running = self::stillRunning(self::pids());
if ($running === []) {
return $stats;
}
foreach ($running as $pid) {
if (self::signal($pid, SIGTERM)) {
$stats['terminated']++;
}
}
$running = self::waitForExit($running, $graceSeconds);
if ($running !== []) {
foreach ($running as $pid) {
if (self::signal($pid, SIGKILL)) {
$stats['killed']++;
}
}
$running = self::waitForExit($running, $killWaitSeconds);
}
$stats['remaining'] = count($running);
return $stats;
} finally {
self::restoreSigmask($blocked);
}
}
/**
* Register a listening server whose socket forked children must close.
*
* Accepts a stream resource or a ReactPHP server object (Server,
* SocketServer, SecureServer, TcpServer, UnixServer); for objects the
* underlying stream is found through their private properties.
*
* @param mixed $server
* @return bool whether a socket was found and registered
*/
public static function registerListener($server): bool
{
$stream = self::findStream($server);
if ($stream === null) {
return false;
}
self::$listeners[(int) $stream] = $stream;
return true;
}
public static function listenerCount(): int
{
return count(self::$listeners);
}
/**
* Whether terminateAll() has started in this process.
*/
public static function isTerminating(): bool
{
return self::$terminating;
}
/**
* Run in a freshly forked child, before any handler code.
*
* Closes the child's copy of every registered listening socket, forgets
* the parent's child bookkeeping (siblings are not this child's children)
* and gives the server's signals their default dispositions back.
*
* @return int number of listening sockets closed
*/
public static function prepareChild(?LoopInterface $loop = null): int
{
$closed = self::closeInheritedListeners($loop);
self::$children = [];
self::$terminating = false;
self::restoreDefaultSignals();
return $closed;
}
/**
* Close this process's copy of every registered listening socket.
*
* After fork() the child holds its own descriptor for the same kernel
* socket. fclose() releases only that descriptor: the parent keeps
* listening, and once the parent exits the port is free even if this
* child is still running.
*
* The ReactPHP server object is deliberately NOT closed: close() pauses it,
* which removes the stream from the event loop. The ext-ev / ext-event /
* ext-uv loops keep their watch list in a kernel epoll set that a forked
* child shares with the parent, so removing the socket there would stop
* the PARENT from accepting connections. StreamSelectLoop keeps its watch
* list in plain PHP arrays, so the stream is dropped from it first; a
* closed stream left in it would break stream_select() should the child
* ever run that loop.
*
* @return int number of sockets closed
*/
public static function closeInheritedListeners(?LoopInterface $loop = null): int
{
$closed = 0;
foreach (self::$listeners as $stream) {
if (! is_resource($stream)) {
continue;
}
if ($loop instanceof StreamSelectLoop) {
$loop->removeReadStream($stream);
}
if (@fclose($stream)) {
$closed++;
}
}
self::$listeners = [];
return $closed;
}
/**
* Give the server's signals their default dispositions back.
*
* The child inherits the server's handlers. Under ext-ev they are libev C
* handlers that only flag the signal for a loop the child never runs, so a
* SIGTERM sent to the child did nothing. Under StreamSelectLoop with async
* signals they ran the parent's shutdown closure inside the child.
*/
public static function restoreDefaultSignals(): void
{
if (! function_exists('pcntl_signal')) {
return;
}
$signals = [];
foreach (self::SERVER_SIGNALS as $name) {
if (defined($name)) {
$signals[] = constant($name);
}
}
foreach ($signals as $signal) {
@pcntl_signal($signal, SIG_DFL);
}
if (function_exists('pcntl_sigprocmask') && $signals !== []) {
@pcntl_sigprocmask(SIG_UNBLOCK, $signals);
}
}
/**
* Whether a raw wait status describes an abnormal exit: killed by a
* signal, or exited with a non-zero code (PHP exits with 255 after a
* fatal error).
*/
public static function exitedAbnormally(int $status): bool
{
if (pcntl_wifsignaled($status)) {
return true;
}
return pcntl_wifexited($status) && pcntl_wexitstatus($status) !== 0;
}
/**
* Forget all state. For tests.
*/
public static function flush(): void
{
self::$children = [];
self::$listeners = [];
self::$terminating = false;
}
/**
* @param int[] $pids
* @return int[] the PIDs that are still running
*/
private static function stillRunning(array $pids): array
{
$running = [];
foreach ($pids as $pid) {
$result = pcntl_waitpid($pid, $status, WNOHANG);
if ($result === 0) {
$running[] = $pid;
} elseif ($result === $pid) {
self::finish($pid, $status);
} else {
// -1: not (any longer) a child of this process, for example
// reaped by code that bypassed this registry.
self::finish($pid, null);
}
}
return $running;
}
/**
* @param int[] $pids
* @return int[] the PIDs still running when the time ran out
*/
private static function waitForExit(array $pids, float $seconds): array
{
$deadline = microtime(true) + max(0.0, $seconds);
while (true) {
$pids = self::stillRunning($pids);
if ($pids === [] || microtime(true) >= $deadline) {
return $pids;
}
usleep(20_000);
}
}
private static function finish(int $pid, ?int $status): void
{
$entry = self::$children[$pid] ?? null;
unset(self::$children[$pid]);
if ($entry === null || $entry['on_exit'] === null) {
return;
}
try {
($entry['on_exit'])($status);
} catch (\Throwable) {
// An exit callback must never break reaping or the shutdown.
}
}
private static function signal(int $pid, int $signal): bool
{
if (function_exists('posix_kill')) {
return @posix_kill($pid, $signal);
}
return false;
}
/**
* @return array<int>|null the previous mask, or null when nothing was blocked
*/
private static function blockSigchld(): ?array
{
if (! function_exists('pcntl_sigprocmask') || ! defined('SIGCHLD')) {
return null;
}
$previous = [];
return @pcntl_sigprocmask(SIG_BLOCK, [SIGCHLD], $previous) ? $previous : null;
}
private static function restoreSigmask(?array $previous): void
{
if ($previous !== null) {
@pcntl_sigprocmask(SIG_SETMASK, $previous);
}
}
/**
* Find the stream resource behind a ReactPHP server object.
*
* @param mixed $server
* @return resource|null
*/
private static function findStream($server, int $depth = 0)
{
if (is_resource($server)) {
return get_resource_type($server) === 'stream' ? $server : null;
}
if (! is_object($server) || $depth > 4) {
return null;
}
// TcpServer/UnixServer: $master. Server/SocketServer: $server.
// SecureServer: $tcp. Ratchet's IoServer: $socket.
foreach (['master', 'server', 'tcp', 'socket'] as $name) {
$class = new \ReflectionObject($server);
while ($class !== false && ! $class->hasProperty($name)) {
$class = $class->getParentClass();
}
if ($class === false) {
continue;
}
$property = $class->getProperty($name);
if (PHP_VERSION_ID < 80100) {
$property->setAccessible(true);
}
if (! $property->isInitialized($server)) {
continue;
}
$found = self::findStream($property->getValue($server), $depth + 1);
if ($found !== null) {
return $found;
}
}
return null;
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Websocket;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
/**
* Parent-side view of one forked message child: did it answer, and how did it
* end?
*
* When a child exits abnormally (killed by a signal, or a non-zero exit after
* a fatal error) without having sent a final `<event>:response` or
* `<event>:error` frame, the client would wait forever. The crash callback
* then sends the error frame in the child's place.
*
* The verdict waits for BOTH the end of the IPC stream and the reaped exit
* status. A SIGCHLD reaper can collect the child before the loop has read the
* child's last frames, so deciding at reap time alone would send an `:error`
* ahead of a `:response` that is still in the socket buffer.
*/
final class ChildMessageOutcome
{
/** @var string[] */
private array $finalFramePrefixes;
private bool $replied = false;
private bool $streamClosed = false;
private bool $exited = false;
private ?int $status = null;
private bool $settled = false;
/** @var \Closure(int): void */
private \Closure $onCrash;
/**
* @param callable(int): void $onCrash receives the raw wait status
*/
public function __construct(string $event, callable $onCrash)
{
$this->finalFramePrefixes = [
'{"event":' . json_encode($event . ':response'),
'{"event":' . json_encode($event . ':error'),
];
$this->onCrash = \Closure::fromCallable($onCrash);
}
/**
* Inspect a frame the child sent. Final frames for this message are
* encoded by the controller with "event" as the first key.
*/
public function frameReceived(mixed $data): void
{
if ($this->replied || ! is_string($data)) {
return;
}
foreach ($this->finalFramePrefixes as $prefix) {
if (str_starts_with($data, $prefix)) {
$this->replied = true;
return;
}
}
}
public function streamClosed(): void
{
$this->streamClosed = true;
$this->settle();
}
/**
* @param int|null $status raw wait status, or null when unknown
*/
public function exited(?int $status): void
{
$this->exited = true;
$this->status = $status;
$this->settle();
}
public function replied(): bool
{
return $this->replied;
}
private function settle(): void
{
if ($this->settled || ! $this->streamClosed || ! $this->exited) {
return;
}
$this->settled = true;
if ($this->replied || $this->status === null || ! ChildProcesses::exitedAbnormally($this->status)) {
return;
}
($this->onCrash)($this->status);
}
}

View File

@ -12,6 +12,8 @@ use BlaxSoftware\LaravelWebSockets\Contracts\ChannelManager;
use BlaxSoftware\LaravelWebSockets\Events\ConnectionClosed;
use BlaxSoftware\LaravelWebSockets\Events\NewConnection;
use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
use BlaxSoftware\LaravelWebSockets\Server\ChildFatalErrorGuard;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
use BlaxSoftware\LaravelWebSockets\Websocket\MockConnectionSocketPair;
use BlaxSoftware\LaravelWebSockets\Server\Exceptions\ConnectionsOverCapacity;
use BlaxSoftware\LaravelWebSockets\Server\Exceptions\OriginNotAllowed;
@ -581,6 +583,91 @@ class Handler implements MessageComponentInterface
app()->forgetInstance('session.store');
}
/**
* Arm a shutdown-time reply in this message child for a fatal error (#1462).
*
* Fatal errors (out of memory, E_ERROR, ...) skip the catch block in
* forkWithSocketPair(), so the child used to exit without any frame and the
* client waited forever. The frame is encoded now, while memory is still
* available; at shutdown the guard only writes it. Same shape as the
* controller's catch-all error frame (Controller::send_error()).
*/
protected function guardChildAgainstFatalErrors(SocketPairIpc $ipc, Channel $channel, array $message): ChildFatalErrorGuard
{
$frame = static::fatalErrorFrame($message, $channel->getName());
return ChildFatalErrorGuard::register(static function () use ($ipc, $frame): void {
$ipc->sendToParent($frame);
});
}
/**
* The `:error` frame a message child sends when it dies of a fatal error.
*/
protected static function fatalErrorFrame(array $message, ?string $channelName): string
{
return json_encode([
'event' => ($message['event'] ?? 'unknown') . ':error',
'data' => [
'message' => 'Internal server error',
'meta' => [
// The application's own fatal handler (Laravel's
// HandleExceptions) runs before the guard and reports it.
'reported' => true,
'fatal' => true,
],
],
'channel' => $message['channel'] ?? $channelName,
]);
}
/**
* Answer the client for a message child that died without replying: killed
* by a signal (kernel OOM killer, server shutdown) or a fatal error its own
* guard could not report (#1462). Runs in the event loop: must not throw.
*/
protected function replyForCrashedChild(
ConnectionInterface $connection,
Channel $channel,
array $message,
int $pid,
int $status
): void {
$event = $message['event'] ?? 'unknown';
$stoppedByShutdown = ChildProcesses::isTerminating();
try {
$exit = pcntl_wifsignaled($status)
? 'signal ' . pcntl_wtermsig($status)
: 'exit code ' . pcntl_wexitstatus($status);
$context = ['event' => $event, 'pid' => $pid, 'exit' => $exit];
if ($stoppedByShutdown) {
Log::channel('websocket')->info('Message child stopped by the server shutdown before replying', $context);
} else {
Log::channel('websocket')->error('Message child died without replying', $context);
}
} catch (\Throwable) {
// Logging must never break the event loop.
}
try {
$connection->send(json_encode([
'event' => $event . ':error',
'data' => [
'message' => $stoppedByShutdown ? 'Server is restarting' : 'Internal server error',
'meta' => [
'reported' => false,
],
],
'channel' => $message['channel'] ?? $channel->getName(),
]));
} catch (\Throwable) {
// The connection may already be gone.
}
}
/**
* Fork with event-driven socket pair IPC (no polling!)
* Parent is notified INSTANTLY when child sends data
@ -627,6 +714,16 @@ class Handler implements MessageComponentInterface
// === CHILD PROCESS ===
$ipc->setupChild();
// Close this child's copy of the server's listening socket (a child
// that outlived the server kept the port bound, #2465), forget the
// parent's child bookkeeping and restore default signal handling,
// so the parent's shutdown can SIGTERM this child.
ChildProcesses::prepareChild($this->channelManager->loop);
// A fatal error skips every catch block below; answer the client
// from a shutdown function instead (#1462).
$fatalGuard = $this->guardChildAgainstFatalErrors($ipc, $channel, $message);
// Hot reload: clear all caches for fresh code loading (only in dev mode)
$this->hotReloadChild();
@ -691,6 +788,11 @@ class Handler implements MessageComponentInterface
$this->channelManager
);
// The controller has answered (or chose not to). A fatal error
// in the deferred callbacks or the session save below must not
// send a second, contradicting frame.
$fatalGuard->disarm();
\Illuminate\Container\Container::getInstance()
->make(\Illuminate\Support\Defer\DeferredCallbackCollection::class)
->invokeWhen(fn($callback) => true);
@ -698,6 +800,9 @@ class Handler implements MessageComponentInterface
// Persist session changes to Redis before exit
$session->save();
} catch (\Throwable $e) {
// This catch sends its own error frame.
$fatalGuard->disarm();
// Send error via socket pair
$ipc->sendToParent(json_encode([
'event' => $message['event'] . ':error',
@ -755,6 +860,16 @@ class Handler implements MessageComponentInterface
}
// === PARENT PROCESS ===
// Track the child so the shutdown can stop it (#2465), and answer the
// client in its place should it die without replying (#1462).
$outcome = new ChildMessageOutcome(
(string) ($message['event'] ?? ''),
function (int $status) use ($connection, $channel, $message, $pid) {
$this->replyForCrashedChild($connection, $channel, $message, $pid, $status);
}
);
ChildProcesses::track($pid, $message['event'] ?? null, [$outcome, 'exited']);
// Setup event-driven reading - NO POLLING!
$startTime = microtime(true);
@ -764,8 +879,9 @@ class Handler implements MessageComponentInterface
// uncaught throwable here would propagate up through ExtEvLoop and
// crash the entire WebSocket server (supervisor would then restart
// it, dropping every connected client). We must catch and log.
function ($data) use ($connection, $message, $startTime) {
function ($data) use ($connection, $message, $startTime, $outcome) {
try {
$outcome->frameReceived($data);
$this->handleChildData($connection, $message, $data);
} catch (\Throwable $e) {
Log::channel('websocket')->error('handleChildData failed: ' . $e->getMessage(), [
@ -802,17 +918,19 @@ class Handler implements MessageComponentInterface
},
// onClose callback - child process ended.
// Same isolation rules apply: must not throw out of the loop.
function () {
function () use ($outcome) {
try {
// Every frame the child sent has been handled by now.
$outcome->streamClosed();
// Reap ALL exited fork children, not just one. onClose fires on
// socket EOF, which can precede the child's actual process exit —
// so this call often reaps the *previous* message's child (or
// nothing yet) rather than this one. Draining in a loop keeps
// zombies from accumulating between messages; StartServer's
// SIGCHLD + periodic reaper is the idle/straggler backstop.
while (pcntl_waitpid(-1, $status, WNOHANG) > 0) {
// reaped one child
}
// Each reap also clears the child from the shutdown list.
ChildProcesses::reapExited();
// Free up a child slot and process any queued messages.
// NOTE: this decrement is per-child-SOCKET (one onClose per

View File

@ -0,0 +1,138 @@
<?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;
}
}

View File

@ -0,0 +1,81 @@
<?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];
}
}

View File

@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Server;
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
use PHPUnit\Framework\TestCase;
use React\EventLoop\StreamSelectLoop;
use React\Socket\Server as LegacyServer;
use React\Socket\SocketServer;
use React\Socket\TcpServer;
/**
* Regression guard for #2465: a fork-per-message child that outlived the
* server kept :6001 bound, so the restarted server failed with EADDRINUSE.
*
* Uses real forks, real signals and real sockets. Every child is killed and
* reaped in tearDown even when an assertion fails.
*/
class ChildProcessesTest extends TestCase
{
/** @var int[] */
private array $spawned = [];
protected function setUp(): void
{
parent::setUp();
foreach (['pcntl_fork', 'pcntl_waitpid', 'posix_kill', 'pcntl_signal'] as $function) {
if (! function_exists($function)) {
$this->markTestSkipped("{$function}() is required");
}
}
ChildProcesses::flush();
}
protected function tearDown(): void
{
foreach ($this->spawned as $pid) {
@posix_kill($pid, SIGKILL);
@pcntl_waitpid($pid, $status);
}
ChildProcesses::flush();
parent::tearDown();
}
public function test_shutdown_sigterms_a_running_child_and_reaps_it()
{
$statuses = [];
// The child inherits a SIGTERM handler, as message children inherit the
// server's. prepareChild() must restore the default, otherwise SIGTERM
// is swallowed and only the SIGKILL fallback ends the child.
$pid = $this->forkChild(function () {
pcntl_signal(SIGTERM, function () {
// inherited handler that would swallow the signal
});
ChildProcesses::prepareChild();
});
ChildProcesses::track($pid, 'atc-traffic.subscribe', function (?int $status) use (&$statuses) {
$statuses[] = $status;
});
$stats = ChildProcesses::terminateAll(2.0);
$this->assertSame(['terminated' => 1, 'killed' => 0, 'remaining' => 0], $stats);
$this->assertSame(0, ChildProcesses::count());
$this->assertCount(1, $statuses, 'The exit callback runs once, with the wait status.');
$this->assertTrue(pcntl_wifsignaled($statuses[0]));
$this->assertSame(SIGTERM, pcntl_wtermsig($statuses[0]));
$this->assertTrue(ChildProcesses::exitedAbnormally($statuses[0]));
$this->assertFalse(@posix_kill($pid, 0), 'The child is gone and reaped.');
}
public function test_shutdown_sigkills_a_child_that_ignores_sigterm_after_the_grace_period()
{
$statuses = [];
$pid = $this->forkChild(function () {
pcntl_signal(SIGTERM, SIG_IGN);
});
ChildProcesses::track($pid, null, function (?int $status) use (&$statuses) {
$statuses[] = $status;
});
$started = microtime(true);
$stats = ChildProcesses::terminateAll(0.3, 1.0);
$elapsed = microtime(true) - $started;
$this->assertSame(['terminated' => 1, 'killed' => 1, 'remaining' => 0], $stats);
$this->assertGreaterThanOrEqual(0.3, $elapsed, 'SIGKILL waits for the grace period.');
$this->assertLessThan(1.5, $elapsed, 'The shutdown wait is bounded.');
$this->assertSame(SIGKILL, pcntl_wtermsig($statuses[0]));
$this->assertFalse(@posix_kill($pid, 0));
}
public function test_shutdown_never_signals_a_process_that_is_not_its_child()
{
// A grandchild is re-parented away from this process, exactly like a
// PID that was reaped elsewhere and recycled by an unrelated process.
$grandchild = $this->forkGrandchild();
$this->spawned[] = $grandchild;
$exitCalls = [];
ChildProcesses::track($grandchild, null, function (?int $status) use (&$exitCalls) {
$exitCalls[] = $status;
});
$stats = ChildProcesses::terminateAll(0.2, 0.2);
$this->assertSame(['terminated' => 0, 'killed' => 0, 'remaining' => 0], $stats);
$this->assertTrue(posix_kill($grandchild, 0), 'A process that is not our child must survive.');
$this->assertSame([null], $exitCalls, 'It is forgotten with an unknown status.');
$this->assertSame(0, ChildProcesses::count());
}
public function test_reap_exited_reports_the_exit_status_to_the_callback()
{
$statuses = [];
foreach ([0, 3] as $code) {
$pid = pcntl_fork();
if ($pid === 0) {
$this->exitChild($code);
}
$this->spawned[] = $pid;
ChildProcesses::track($pid, null, function (?int $status) use (&$statuses, $code) {
$statuses[$code] = $status;
});
}
$deadline = microtime(true) + 5;
while (count($statuses) < 2 && microtime(true) < $deadline) {
ChildProcesses::reapExited();
usleep(10_000);
}
$this->assertCount(2, $statuses);
$this->assertSame(0, pcntl_wexitstatus($statuses[0]));
$this->assertFalse(ChildProcesses::exitedAbnormally($statuses[0]));
$this->assertSame(3, pcntl_wexitstatus($statuses[3]));
$this->assertTrue(ChildProcesses::exitedAbnormally($statuses[3]));
$this->assertSame(0, ChildProcesses::count());
}
public function test_a_child_that_ran_prepare_child_does_not_keep_the_port_bound()
{
$server = $this->listen($port);
$this->assertTrue(ChildProcesses::registerListener($server));
$pid = $this->forkChild(function () {
ChildProcesses::prepareChild();
});
// The parent still accepts on its own copy of the socket.
$client = stream_socket_client("tcp://127.0.0.1:{$port}", $errno, $errstr, 2);
$this->assertIsResource($client, $errstr);
$accepted = @stream_socket_accept($server, 2);
$this->assertIsResource($accepted, 'The parent must keep accepting after the child closed its copy.');
fclose($accepted);
fclose($client);
// The server goes away while the child is still running: the port
// must be free for its successor at once.
fclose($server);
$this->assertTrue(posix_kill($pid, 0), 'The child is still alive.');
$successor = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
$this->assertIsResource($successor, "Rebinding failed while the child lives: {$errstr}");
fclose($successor);
}
public function test_a_child_that_did_not_close_the_socket_keeps_the_port_bound()
{
// The failure mode the fix removes: proves the test above is meaningful.
$server = $this->listen($port);
$this->forkChild(function () {
// inherits the listening socket and keeps it
});
fclose($server);
$successor = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
$this->assertFalse($successor, 'The port should still be held by the child.');
$this->assertStringContainsStringIgnoringCase('in use', $errstr);
}
public function test_it_finds_the_listening_socket_inside_react_servers_and_keeps_the_loop_usable()
{
$loop = new StreamSelectLoop();
$tcp = new TcpServer('127.0.0.1:0', $loop);
$socketServer = new SocketServer('127.0.0.1:0', [], $loop);
// What ServerFactory builds today (the deprecated wrapper).
$legacyServer = new LegacyServer('127.0.0.1:0', $loop);
$this->assertTrue(ChildProcesses::registerListener($tcp));
$this->assertTrue(ChildProcesses::registerListener($socketServer));
$this->assertTrue(ChildProcesses::registerListener($legacyServer));
$this->assertFalse(ChildProcesses::registerListener(new \stdClass()));
$this->assertSame(3, ChildProcesses::listenerCount());
$ports = [
(int) parse_url((string) $tcp->getAddress(), PHP_URL_PORT),
(int) parse_url((string) $socketServer->getAddress(), PHP_URL_PORT),
(int) parse_url((string) $legacyServer->getAddress(), PHP_URL_PORT),
];
$this->assertSame(3, ChildProcesses::closeInheritedListeners($loop));
$this->assertSame(0, ChildProcesses::listenerCount());
// Both ports are free again.
foreach ($ports as $port) {
$rebound = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
$this->assertIsResource($rebound, $errstr);
fclose($rebound);
}
// StreamSelectLoop no longer selects on the closed streams; a closed
// stream left in its list makes stream_select() throw.
$ticked = false;
$loop->futureTick(function () use ($loop, &$ticked) {
$ticked = true;
$loop->stop();
});
$loop->addTimer(1, fn () => $loop->stop());
$loop->run();
$this->assertTrue($ticked);
}
/**
* Fork a child that runs $setup, tells the parent it is ready, then waits
* to be killed.
*/
private function forkChild(callable $setup): int
{
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$pid = pcntl_fork();
$this->assertNotSame(-1, $pid, 'fork failed');
if ($pid === 0) {
// Never return into PHPUnit from a forked child: it would go on
// running the rest of the suite.
try {
fclose($pair[0]);
$setup();
fwrite($pair[1], "ready\n");
} catch (\Throwable) {
$this->exitChild(1);
}
while (true) {
usleep(50_000);
}
}
$this->spawned[] = $pid;
fclose($pair[1]);
stream_set_timeout($pair[0], 5);
$this->assertSame("ready\n", fgets($pair[0]), 'The child never became ready.');
fclose($pair[0]);
return $pid;
}
/**
* Fork a child that forks a long-sleeping grandchild, reports its PID
* and exits, so the grandchild is no longer a child of this process.
*/
private function forkGrandchild(): int
{
$pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$pid = pcntl_fork();
$this->assertNotSame(-1, $pid, 'fork failed');
if ($pid === 0) {
fclose($pair[0]);
$grandchild = pcntl_fork();
if ($grandchild === 0) {
fclose($pair[1]);
ChildProcesses::restoreDefaultSignals();
sleep(30);
$this->exitChild(0);
}
fwrite($pair[1], $grandchild . "\n");
$this->exitChild(0);
}
fclose($pair[1]);
stream_set_timeout($pair[0], 5);
$grandchild = (int) trim((string) fgets($pair[0]));
fclose($pair[0]);
pcntl_waitpid($pid, $status);
$this->assertGreaterThan(0, $grandchild);
return $grandchild;
}
/**
* @return resource
*/
private function listen(?int &$port)
{
$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);
return $server;
}
/**
* Leave a forked child without flushing PHPUnit's buffered output twice.
*/
private function exitChild(int $code): void
{
while (ob_get_level() > 0) {
ob_end_clean();
}
exit($code);
}
}

View File

@ -0,0 +1,97 @@
<?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(),
]);

View File

@ -132,6 +132,13 @@ abstract class TestCase extends Orchestra
protected function tearDown(): void
{
// A test that ran websockets:serve and stopped its loop directly never
// reached the shutdown, so the broadcast socket file stayed in /tmp and
// made ws_available() true for every later test.
if ($this->app && $this->app->bound(\BlaxSoftware\LaravelWebSockets\Broadcast\BroadcastSocketServer::class)) {
$this->app->make(\BlaxSoftware\LaravelWebSockets\Broadcast\BroadcastSocketServer::class)->stop();
}
parent::tearDown();
if ($this->server) {

View File

@ -0,0 +1,115 @@
<?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);
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Test\Websocket\Fixtures\ChildCrash;
use BlaxSoftware\LaravelWebSockets\Attributes\Websocket;
/**
* Handlers for HandlerChildLifecycleTest. They run in real forked message
* children. Events: child-crash.ok, child-crash.killed, child-crash.hold.
*/
class ChildCrashController
{
#[Websocket]
public function ok(): array
{
return ['ok' => true];
}
/**
* Dies without replying, like a child taken by the kernel OOM killer.
*/
#[Websocket]
public function killed(): array
{
posix_kill(getmypid(), SIGKILL);
sleep(5);
return ['unreachable' => true];
}
/**
* Blocks like a long-running engine until the server stops it.
*/
#[Websocket]
public function hold(): array
{
sleep(10);
return ['held' => true];
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Illuminate\Support\Defer;
/**
* Laravel 11 class the message child flushes after the controller. The
* package's dev dependencies run an older Laravel without defer(), where the
* missing class turns every handled message into an extra error frame. Loaded
* only when the real class is absent.
*/
class DeferredCallbackCollection
{
public function invokeWhen(?callable $callback = null): void
{
}
}

View File

@ -0,0 +1,204 @@
<?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;
}
}