test(fork): extract fork-child connection reset into a tested method

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>
This commit is contained in:
Fabian @ Blax Software 2026-08-30 10:14:36 +02:00
parent 2a9986d209
commit ca90561c9d
2 changed files with 129 additions and 38 deletions

View File

@ -536,6 +536,55 @@ class Handler implements MessageComponentInterface
Log::channel('websocket')->debug('Hot reload: caches cleared in child process'); Log::channel('websocket')->debug('Hot reload: caches cleared in child process');
} }
/**
* Reset every service connection the child inherited from the parent after a
* fork().
*
* The child inherits the parent's Redis socket fds; using any of them
* interleaves requests on the shared socket and desyncs the predis protocol
* on BOTH the child AND the parent (`unserialize(): Error at offset 0 of N
* bytes` / "Error while reading line from the server"). Once session + cache
* live on Redis, the parent's authenticateConnection() opens Redis fds before
* it forks, so the child must drop EVERY inherited connection (#1445).
*
* Extracted so it can be regression-tested: a future refactor dropping a
* connection from the loop, or breaking the client/options skip, is exactly
* what re-opens the desync flood.
*/
protected function resetInheritedConnectionsInChild(): void
{
// 1) Actively disconnect each configured connection — this closes ONLY the
// child's fd copy (predis disconnect just fcloses, it sends no command,
// so the parent's socket is untouched), turning any accidental later
// reuse into a fresh reconnect instead of a desync.
try {
$redisManager = app('redis');
foreach (config('database.redis', []) as $redisName => $redisConf) {
// Skip non-connection entries (client, options, clusters, …).
if (! is_array($redisConf) || ! isset($redisConf['host'])) {
continue;
}
try {
$redisManager->connection($redisName)->disconnect();
} catch (\Throwable $e) {
// per-connection best-effort
}
}
} catch (\Throwable $e) {
// best-effort; the forgetInstance() calls below still force fresh managers
}
// 2) Forget the singletons so the next cache()/session()/Redis() call
// lazily rebuilds with a fresh socket. session/session.store were the
// gap: once the session store is Redis-backed they hold their OWN
// inherited Redis connection, which the cache/redis purge never reset.
app()->forgetInstance('cache');
app()->forgetInstance('cache.store');
app()->forgetInstance('redis');
app()->forgetInstance('session');
app()->forgetInstance('session.store');
}
/** /**
* Fork with event-driven socket pair IPC (no polling!) * Fork with event-driven socket pair IPC (no polling!)
* Parent is notified INSTANTLY when child sends data * Parent is notified INSTANTLY when child sends data
@ -590,44 +639,10 @@ class Handler implements MessageComponentInterface
// This saves ~5-15ms for methods that don't use the database // This saves ~5-15ms for methods that don't use the database
DB::disconnect(); DB::disconnect();
// Purge inherited Redis/cache/session connections from the parent. // Drop every Redis/cache/session connection the child inherited from
// After fork(), the child inherits the parent's Redis socket fds; using // the parent — a shared predis socket desyncs both sides. Full
// any of them interleaves requests on the shared socket and desyncs the // rationale lives on the extracted, regression-tested method (#1445).
// predis protocol on BOTH the child AND the parent (unserialize "offset 0 $this->resetInheritedConnectionsInChild();
// of N bytes" / "Error while reading line"). Once session + cache live on
// Redis, the parent's authenticateConnection() opens Redis fds before it
// forks, so the child must drop EVERY inherited connection.
//
// 1) Actively disconnect each configured connection — this closes ONLY the
// child's fd copy (predis disconnect just fcloses, it sends no command,
// so the parent's socket is untouched), turning any accidental later
// reuse into a fresh reconnect instead of a desync.
try {
$redisManager = app('redis');
foreach (config('database.redis', []) as $redisName => $redisConf) {
// Skip non-connection entries (client, options, clusters, …).
if (! is_array($redisConf) || ! isset($redisConf['host'])) {
continue;
}
try {
$redisManager->connection($redisName)->disconnect();
} catch (\Throwable $e) {
// per-connection best-effort
}
}
} catch (\Throwable $e) {
// best-effort; the forgetInstance() calls below still force fresh managers
}
// 2) Forget the singletons so the next cache()/session()/Redis() call
// lazily rebuilds with a fresh socket. session/session.store were the
// gap: once the session store is Redis-backed they hold their OWN
// inherited Redis connection, which the cache/redis purge never reset.
app()->forgetInstance('cache');
app()->forgetInstance('cache.store');
app()->forgetInstance('redis');
app()->forgetInstance('session');
app()->forgetInstance('session.store');
// Configure DB reconnect-on-lost-connection for this child. // Configure DB reconnect-on-lost-connection for this child.
// If MySQL returns "Too many connections" or "server has gone away", // If MySQL returns "Too many connections" or "server has gone away",

View File

@ -0,0 +1,76 @@
<?php
namespace BlaxSoftware\LaravelWebSockets\Test;
/**
* Regression guard for the fork-child Redis isolation fix (#1445).
*
* The per-message fork child must disconnect EVERY configured Redis connection
* it inherited from the parent — a single missed connection re-opens the predis
* desync flood ("unserialize(): Error at offset 0 of N bytes"). This pins that
* resetInheritedConnectionsInChild() disconnects each real connection and skips
* the non-connection config entries (client / options / anything host-less).
*/
class ForkChildConnectionResetTest extends TestCase
{
public function test_it_disconnects_every_configured_redis_connection_and_skips_pseudo_entries()
{
// A realistic multi-connection layout: two pseudo-entries that are NOT
// connections (client, options) plus three real ones on their own DBs.
config(['database.redis' => [
'client' => 'predis',
'options' => ['prefix' => 'test_'],
'default' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 0],
'cache' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 1],
'session' => ['host' => '127.0.0.1', 'port' => 6379, 'database' => 2],
]]);
// Record which connections get disconnect()ed, without touching real Redis.
$recorder = new \ArrayObject();
$fakeManager = new class($recorder) {
public function __construct(private \ArrayObject $recorder) {}
public function connection($name = null)
{
return new class($name, $this->recorder) {
public function __construct(private ?string $name, private \ArrayObject $recorder) {}
public function disconnect(): void
{
$this->recorder->append($this->name);
}
};
}
};
$this->app->instance('redis', $fakeManager);
$reset = new \ReflectionMethod($this->wsHandler, 'resetInheritedConnectionsInChild');
$reset->setAccessible(true);
$reset->invoke($this->wsHandler);
$disconnected = $recorder->getArrayCopy();
sort($disconnected);
// Every host-bearing connection was reset; client/options were skipped.
$this->assertSame(['cache', 'default', 'session'], $disconnected);
}
public function test_it_does_not_throw_when_redis_is_unresolvable()
{
// A broken redis manager must not bubble out of the child reset — the
// forgetInstance() fallback still forces fresh managers.
$this->app->instance('redis', new class {
public function connection($name = null)
{
throw new \RuntimeException('redis down');
}
});
$reset = new \ReflectionMethod($this->wsHandler, 'resetInheritedConnectionsInChild');
$reset->setAccessible(true);
$reset->invoke($this->wsHandler);
$this->addToAssertionCount(1); // reached here = no throw
}
}