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');
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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