BF cache-key slug drift on ws_socket_auth_*; resilient User column in watch -v
Handler::cacheAuthenticatedUser() and ::cleanupChannelConnections() were
writing/forgetting ws_socket_auth_<rawSocketId> while
WebsocketService::getAuth() and ::setUserAuthed() have always slugged
("123.456" → "123-456"). Result: the cache write was reachable from the
package's own writer path but not from the service-layer reader, so the
admin tooling (websockets:watch -v) saw cache misses and rendered #<id>
instead of the configured IdentityFormatter output.
Also: WatchStats now batch-loads missing users via the configured auth
provider model in one query per render, so the User column still renders
the full formatter shape even when the per-socket cache blob predates
the writer or got evicted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a46243a706
commit
dd6be893a6
|
|
@ -114,9 +114,24 @@ class WatchStats extends Command
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbose: one summary row per channel, then one sub-row per connection.
|
// Verbose: collect every authed userId whose per-socket cache blob is
|
||||||
// The sub-rows fill the User and Duration columns; the summary fills
|
// missing, batch-load them from the auth provider's user model in one
|
||||||
// Channel and Connections. Dashes mark "not applicable on this row".
|
// 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();
|
$now = time();
|
||||||
$rows = [];
|
$rows = [];
|
||||||
foreach ($perChannel as $channel => $sockets) {
|
foreach ($perChannel as $channel => $sockets) {
|
||||||
|
|
@ -131,7 +146,7 @@ class WatchStats extends Command
|
||||||
$rows[] = [
|
$rows[] = [
|
||||||
' <fg=gray>↳</>',
|
' <fg=gray>↳</>',
|
||||||
"<fg=gray>{$socketId}</>",
|
"<fg=gray>{$socketId}</>",
|
||||||
$this->formatUser($socketId, $authedUsers),
|
$this->formatUser($socketId, $authedUsers, $usersById),
|
||||||
$this->formatDuration($socketId, $now),
|
$this->formatDuration($socketId, $now),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -140,26 +155,55 @@ class WatchStats extends Command
|
||||||
$this->table(['Channel', 'Connections', 'User', 'Duration'], $rows);
|
$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])) {
|
if (! isset($authedUsers[$socketId])) {
|
||||||
return '<fg=gray>Guest</>';
|
return '<fg=gray>Guest</>';
|
||||||
}
|
}
|
||||||
|
|
||||||
$subject = WebsocketService::getAuth($socketId);
|
$userId = $authedUsers[$socketId];
|
||||||
|
$subject = WebsocketService::getAuth($socketId) ?: ($usersById[$userId] ?? null);
|
||||||
|
|
||||||
if (! $subject) {
|
if (! $subject) {
|
||||||
// Authed-but-expired: the socket is in ws_socket_authed_users
|
// Auth provider couldn't resolve the user either (deleted? auth
|
||||||
// but the per-socket user blob has fallen out of cache. Show the
|
// model not Eloquent?). Fall back to the bare id rather than
|
||||||
// bare id from authedUsers so the connection isn't mislabeled
|
// mislabeling as Guest, so debugging stays accurate.
|
||||||
// as a Guest.
|
return '<fg=yellow>#' . $userId . '</>';
|
||||||
return '<fg=yellow>#' . $authedUsers[$socketId] . '</>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate formatting to the bound IdentityFormatter so apps with
|
// Delegate to the bound IdentityFormatter so apps with non-User
|
||||||
// non-User subjects (Company, ApiClient, etc.) can render their own
|
// subjects (Company, ApiClient, etc.) can render their own shape
|
||||||
// shape without forking the package.
|
// without forking the package.
|
||||||
$formatter = app(IdentityFormatter::class);
|
return app(IdentityFormatter::class)->format($subject, $socketId);
|
||||||
return $formatter->format($subject, $socketId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatDuration(string $socketId, int $now): string
|
private function formatDuration(string $socketId, int $now): string
|
||||||
|
|
|
||||||
|
|
@ -963,7 +963,11 @@ class Handler implements MessageComponentInterface
|
||||||
protected function cleanupChannelConnections(ConnectionInterface $connection): void
|
protected function cleanupChannelConnections(ConnectionInterface $connection): void
|
||||||
{
|
{
|
||||||
$cacheUpdates = [];
|
$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;
|
$socketId = $connection->socketId;
|
||||||
|
|
||||||
foreach ($this->channel_connections as $channel => $connections) {
|
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 = cache()->get('ws_socket_authed_users') ?? [];
|
||||||
$authed_users[$socketId] = $user->id;
|
$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([
|
cache()->setMultiple([
|
||||||
'ws_socket_auth_' . $socketId => $user,
|
'ws_socket_auth_' . str()->slug($socketId) => $user,
|
||||||
'ws_socket_authed_users' => $authed_users
|
'ws_socket_authed_users' => $authed_users
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue