fix(auth): re-establish auth guard in fork child; suppress EPIPE on peer-gone write

The deferred-message fork path (processDeferredMessages -> forkWithSocketPair)
never re-runs authenticateConnection, so the child inherited $connection->user
(passing the need_auth gate) but a cleared Auth guard — auth()->user() /
User::auth() returned null, crashing handlers that read the guard (learn-atc
GlitchTip #529/#532/#535/#531/#530). Sync the guard from the mock's user per
fork with Auth::setUser() (setUser, not login, to avoid re-firing the Login
event on every message).

Also guard SocketPairIpc::sendToParent with @socket_write: when the WS client
disconnects mid-response the parent tears down its read end, so the child's next
write races a closed pipe (EPIPE / "Broken pipe"). That benign warning was
promoted to a reported ErrorException and flooded GlitchTip (#461). Mirrors the
existing @fwrite in Broadcast/BroadcastClient.php.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-08-04 10:21:44 +02:00
parent 1bd747835f
commit ed15ef8c23
2 changed files with 30 additions and 1 deletions

View File

@ -151,7 +151,13 @@ class SocketPairIpc
// Newline-delimited framing // Newline-delimited framing
$message = $data . "\n"; $message = $data . "\n";
$written = socket_write($this->sockets[1], $message, strlen($message)); // Suppress the write warning: when the WS client disconnects mid-response
// the parent tears down its read end, so the child's next write races a
// closed pipe (EPIPE / "Broken pipe"). That warning is otherwise promoted
// to a reported ErrorException and floods GlitchTip for a benign, expected
// disconnect. The false-return is still handled by the caller. Mirrors the
// existing @fwrite in Broadcast/BroadcastClient.php.
$written = @socket_write($this->sockets[1], $message, strlen($message));
return $written === strlen($message); return $written === strlen($message);
} }

View File

@ -619,6 +619,29 @@ class Handler implements MessageComponentInterface
// Create mock that sends via socket pair // Create mock that sends via socket pair
$mock = new MockConnectionSocketPair($connection, $ipc); $mock = new MockConnectionSocketPair($connection, $ipc);
// Re-establish this fork's Laravel auth guard from the
// connection's user. authenticateConnection() logs the guard in,
// but it runs in the PARENT before forking — deferred messages
// fork later via processDeferredMessages() WITHOUT re-authenticating,
// and the parent's guard may since have been cleared (scheduleLogout)
// or reassigned to another connection. The mock still proxies the
// correct $connection->user (so the need_auth gate passes), yet
// auth()->user() / User::auth() would be null in this child —
// crashing any handler that reads the guard instead of ->user.
// Sync the guard to the connection here so every handler, deferred
// or not, sees a consistent authenticated user. setUser() (not
// login()) avoids re-firing the Login event on every message.
try {
if ($mock->user) {
Auth::setUser($mock->user);
} elseif (Auth::hasUser()) {
Auth::logout();
}
} catch (\Throwable $authSyncError) {
// Best-effort: the need_auth gate in controll_message() is
// still the hard authorization check.
}
$this->executeControllerWithDbResilience( $this->executeControllerWithDbResilience(
$mock, $mock,
$channel, $channel,