From 2a9986d2093d2791203b06d2e1bd250101bda0cd Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Fri, 14 Aug 2026 11:48:26 +0200 Subject: [PATCH] fix(fork): fully isolate inherited Redis connections in the message child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-message fork child purged cache/redis manager singletons but never reset the session bindings, and never actively disconnected the fds it inherited. Once an app puts session + cache on Redis, the parent's authenticateConnection() opens Redis sockets before forking; a child touching any un-reset connection interleaves on the shared fd and desyncs the predis protocol on BOTH sides — flooding "unserialize(): Error at offset 0 of N bytes" and "Predis ConnectionException: Error while reading line from the server". In the child, before purging: disconnect() every configured redis connection (closes only the child's fd copy — predis disconnect fcloses without sending a command, so the parent socket is untouched; any later reuse reconnects fresh instead of desyncing), then also forgetInstance('session') + 'session.store' so a Redis-backed session store rebuilds lazily too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Websocket/Handler.php | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Websocket/Handler.php b/src/Websocket/Handler.php index f6bb7aa..e9b45fb 100644 --- a/src/Websocket/Handler.php +++ b/src/Websocket/Handler.php @@ -590,13 +590,44 @@ class Handler implements MessageComponentInterface // This saves ~5-15ms for methods that don't use the database DB::disconnect(); - // Purge inherited Redis/cache connections from parent process. - // After fork(), child inherits parent's Redis socket fd — using it - // would corrupt parent's protocol state. Purging forces fresh - // connections on next cache() call (predis connects lazily). + // 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'); // Configure DB reconnect-on-lost-connection for this child. // If MySQL returns "Too many connections" or "server has gone away",