fix(fork): fully isolate inherited Redis connections in the message child

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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-14 11:48:26 +02:00
parent b678a25a3c
commit 2a9986d209
1 changed files with 35 additions and 4 deletions

View File

@ -590,13 +590,44 @@ 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 connections from parent process. // Purge inherited Redis/cache/session connections from the parent.
// After fork(), child inherits parent's Redis socket fd — using it // After fork(), the child inherits the parent's Redis socket fds; using
// would corrupt parent's protocol state. Purging forces fresh // any of them interleaves requests on the shared socket and desyncs the
// connections on next cache() call (predis connects lazily). // 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');
app()->forgetInstance('cache.store'); app()->forgetInstance('cache.store');
app()->forgetInstance('redis'); 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",