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:
parent
2a9986d209
commit
ca90561c9d
|
|
@ -536,6 +536,55 @@ class Handler implements MessageComponentInterface
|
|||
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!)
|
||||
* 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
|
||||
DB::disconnect();
|
||||
|
||||
// Purge inherited Redis/cache/session connections from the parent.
|
||||
// After 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 "offset 0
|
||||
// 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');
|
||||
// Drop every Redis/cache/session connection the child inherited from
|
||||
// the parent — a shared predis socket desyncs both sides. Full
|
||||
// rationale lives on the extracted, regression-tested method (#1445).
|
||||
$this->resetInheritedConnectionsInChild();
|
||||
|
||||
// Configure DB reconnect-on-lost-connection for this child.
|
||||
// If MySQL returns "Too many connections" or "server has gone away",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue