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>
The soft shutdown chained ->then() on Handler::onClose(), which returns
void. The error became a silently dropped promise rejection, loop->stop()
never ran, and the server kept accepting and closing every new socket
without connection_established until a hard restart. Clients queued their
sends forever in that window.
- Close connections directly (Ratchet runs onClose once per stream), stop
the loop after a 1s grace, on a listing error, or at a 5s deadline.
- Websocket\ChannelManager::unsubscribeFromAllChannels returns early for a
connection declined before it got an app, like LocalChannelManager. That
was the "Attempt to read property id on null" flood in the log.
- Tests: SoftShutdownTest drives the shutdown on a fresh loop, StartServerTest
fails on any error logged during a soft shutdown, ClosedBeforeOpenTest.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dev hot reload forgot the `events` instance, so the container rebuilt an
empty dispatcher and every listener the service providers registered was
gone for that message. Events fired from WS controllers reached nobody (a
shop's "order paid" mails and invoice silently never happened in dev).
Model observers kept working, which hid it: Eloquent holds its own
reference to the old dispatcher.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The per-message fork child's Redis/cache/session isolation (#1445) was inline
in forkWithSocketPair() with no test — verified only by watching prod. Extract
it into Handler::resetInheritedConnectionsInChild() (behavior-preserving) and
add ForkChildConnectionResetTest: asserts it disconnects every host-bearing
connection, skips the client/options pseudo-entries, and never throws when the
redis manager is unresolvable. A future refactor that drops a connection now
fails CI instead of re-opening the predis desync flood.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Introduced `helpers-and-testing.md` to document global helpers and WebsocketService class usage.
- Created `HandlerLifecycleTest.php` to test the full WebSocket handler lifecycle, including connection management, channel subscriptions, and message routing.
- Added `WebsocketServiceTest.php` to validate state tracking methods in WebsocketService, covering user authentication, channel tracking, and broadcast functionality.