diff --git a/src/Websocket/Handler.php b/src/Websocket/Handler.php index e9b45fb..abd4ea7 100644 --- a/src/Websocket/Handler.php +++ b/src/Websocket/Handler.php @@ -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", diff --git a/tests/ForkChildConnectionResetTest.php b/tests/ForkChildConnectionResetTest.php new file mode 100644 index 0000000..fdd67e2 --- /dev/null +++ b/tests/ForkChildConnectionResetTest.php @@ -0,0 +1,76 @@ + [ + '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 + } +}