diff --git a/src/Console/Commands/WatchStats.php b/src/Console/Commands/WatchStats.php index b2690b5..452814b 100644 --- a/src/Console/Commands/WatchStats.php +++ b/src/Console/Commands/WatchStats.php @@ -114,9 +114,24 @@ class WatchStats extends Command return; } - // Verbose: one summary row per channel, then one sub-row per connection. - // The sub-rows fill the User and Duration columns; the summary fills - // Channel and Connections. Dashes mark "not applicable on this row". + // Verbose: collect every authed userId whose per-socket cache blob is + // missing, batch-load them from the auth provider's user model in one + // query, and reuse the resolved subject when formatting each row. This + // covers two real cases: connections that predate the cache writer + // being added, and Redis evictions under memory pressure. + $missingByUserId = []; + foreach ($perChannel as $sockets) { + foreach ($sockets as $socketId) { + if (! isset($authedUsers[$socketId])) { + continue; + } + if (! WebsocketService::getAuth($socketId)) { + $missingByUserId[$authedUsers[$socketId]] = true; + } + } + } + $usersById = $this->loadAuthUsers(array_keys($missingByUserId)); + $now = time(); $rows = []; foreach ($perChannel as $channel => $sockets) { @@ -131,7 +146,7 @@ class WatchStats extends Command $rows[] = [ ' ↳', "{$socketId}", - $this->formatUser($socketId, $authedUsers), + $this->formatUser($socketId, $authedUsers, $usersById), $this->formatDuration($socketId, $now), ]; } @@ -140,26 +155,55 @@ class WatchStats extends Command $this->table(['Channel', 'Connections', 'User', 'Duration'], $rows); } - private function formatUser(string $socketId, array $authedUsers): string + /** + * Load users by id from the configured auth provider's model. + * + * Returns [id => model] for found ids; absent ids simply don't appear. + * Returns [] silently on any failure (no auth provider, model missing, + * DB unreachable) — this is admin tooling, not a critical path. + */ + private function loadAuthUsers(array $ids): array + { + if (empty($ids)) { + return []; + } + + $model = config('auth.providers.users.model'); + if (! is_string($model) || ! class_exists($model)) { + return []; + } + + try { + return $model::query() + ->whereIn('id', $ids) + ->get() + ->keyBy('id') + ->all(); + } catch (\Throwable $e) { + return []; + } + } + + private function formatUser(string $socketId, array $authedUsers, array $usersById): string { if (! isset($authedUsers[$socketId])) { return 'Guest'; } - $subject = WebsocketService::getAuth($socketId); + $userId = $authedUsers[$socketId]; + $subject = WebsocketService::getAuth($socketId) ?: ($usersById[$userId] ?? null); + if (! $subject) { - // Authed-but-expired: the socket is in ws_socket_authed_users - // but the per-socket user blob has fallen out of cache. Show the - // bare id from authedUsers so the connection isn't mislabeled - // as a Guest. - return '#' . $authedUsers[$socketId] . ''; + // Auth provider couldn't resolve the user either (deleted? auth + // model not Eloquent?). Fall back to the bare id rather than + // mislabeling as Guest, so debugging stays accurate. + return '#' . $userId . ''; } - // Delegate formatting to the bound IdentityFormatter so apps with - // non-User subjects (Company, ApiClient, etc.) can render their own - // shape without forking the package. - $formatter = app(IdentityFormatter::class); - return $formatter->format($subject, $socketId); + // Delegate to the bound IdentityFormatter so apps with non-User + // subjects (Company, ApiClient, etc.) can render their own shape + // without forking the package. + return app(IdentityFormatter::class)->format($subject, $socketId); } private function formatDuration(string $socketId, int $now): string diff --git a/src/Websocket/Handler.php b/src/Websocket/Handler.php index 515c1fb..d483e80 100644 --- a/src/Websocket/Handler.php +++ b/src/Websocket/Handler.php @@ -963,7 +963,11 @@ class Handler implements MessageComponentInterface protected function cleanupChannelConnections(ConnectionInterface $connection): void { $cacheUpdates = []; - $cacheDeletes = ['ws_socket_auth_' . $connection->socketId]; + // Slug socketId so this key matches what WebsocketService::getAuth() + // reads and what setUserAuthed() writes; raw socketIds contain dots + // ("123.456") that the slug() call strips ("123-456") and previously + // produced read/write key drift that masked auth state on disconnect. + $cacheDeletes = ['ws_socket_auth_' . str()->slug($connection->socketId)]; $socketId = $connection->socketId; foreach ($this->channel_connections as $channel => $connections) { @@ -1350,9 +1354,13 @@ class Handler implements MessageComponentInterface $authed_users = cache()->get('ws_socket_authed_users') ?? []; $authed_users[$socketId] = $user->id; - // Single batched cache write - reduces 3 operations to 1 + // Single batched cache write - reduces 3 operations to 1. + // Slug the socketId so this key matches WebsocketService::getAuth() + // and setUserAuthed() — raw socketIds like "123.456" don't survive + // a round-trip with slugged ("123-456") readers, and previously this + // write was effectively unreachable from the rest of the codebase. cache()->setMultiple([ - 'ws_socket_auth_' . $socketId => $user, + 'ws_socket_auth_' . str()->slug($socketId) => $user, 'ws_socket_authed_users' => $authed_users ]);