fix(ui): stop rebuilding the socket on every token refresh - #9540
Merged
lstein merged 1 commit intoAug 25, 2026
Conversation
The socket.io connection was keyed on the auth token's bytes. The sliding-window middleware mints a replacement token on every mutating request and the client commits one once a minute (TOKEN_REFRESH_THROTTLE_MS), so during any activity the connect effect re-ran on a 60s cadence: listeners disposed, progress store cleared, the socket disconnected and rebuilt — a visible preview glitch mid-generation, and a "client disconnect"/"connected" pair in the server log every minute. Key the socket on the session the token belongs to — its user id and revocation epoch — instead. A routine refresh keeps the key stable; a logout, an expiry, another account taking over the tab, or a password change that supersedes the epoch all still change it and still rebuild the socket. The epoch belongs in the key because the server force-disconnects sockets whose epoch is superseded and expects the client to come back with its replacement token, and a server-initiated disconnect is terminal for socket.io: the client sets skipReconnect and never retries. For the same reason, an `io server disconnect` now drives a bounded, backed-off reconnect of the same socket — the deactivation branch of _handle_user_access_changed changes nothing the client keys on, so without it a reverted deactivation would leave the tab with no events and Invoke disabled until a page reload. `auth` is passed as a callback so each connection attempt presents the token that is live then, rather than the one the socket was built with. The Authorization extra header is dropped with it: a header is fixed for the life of the manager, so as the server's fallback it could only ever offer a credential the client might already have discarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YbMt7bvKerUbuR5r1zQQtt
lstein
requested review from
JPPhoto,
Pfannkuchensack,
blessedcoolant and
dunkeroni
as code owners
August 25, 2026 21:10
JPPhoto
approved these changes
Aug 25, 2026
JPPhoto
left a comment
Collaborator
There was a problem hiding this comment.
No merge blockers. You can decide if this is worth addressing or not:
invokeai/frontend/web/src/services/events/useSocketIO.ts:129:socket.connect()leaves Socket.IO Manager reconnection enabled; a transport failure during custom retry bypasses five-attempt bound and retries indefinitely. Effect: persistent retry traffic while unavailable. Likelihood: plausible server/network failure after server disconnect. Recovery: reload/teardown stops it. Test: real socket.io-client 4.8.3 produced 4 reconnect attempts in 180 ms against a closed port; bound Manager retries explicitly.
Suggestions:
- Instead of relying on Manager defaults, coordinate or disable Manager reconnection during custom retries so the five-attempt limit is enforceable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The socket.io connection was keyed on the bytes of the auth token. The sliding-window
middleware mints a replacement token on every mutating request, and the client commits one
roughly once a minute (
TOKEN_REFRESH_THROTTLE_MS = 60_000), so during any activityuseSocketIO's connect effect re-ran on a fixed 60s cadence:disposeEventListeners()tore down every app-wide socket listener,$lastProgressEvent/clearAllProgressEvents()wiped in-flight progress,Visible symptom: a preview glitch mid-generation once a minute, and a matching
Socket … disconnected (… reason: client disconnect)/connectedpair in the server log:Nothing about a refreshed token changes who is connected or which rooms they belong to, so the
rebuild bought nothing.
Change
authSlice:getTokenSessionKey()/selectAuthSessionKey— the session a token belongs to:its
user_idclaim plus thetoken_epochrevocation claim, falling back to the token's ownbytes when it carries no user id.
useSocketIO: the options memo — and therefore the connect effect — keys on that session keyinstead of the token. A logout, a session expiry, another account taking over the tab, or a
password change that supersedes the epoch all still change the key and still rebuild the socket.
authis now passed as a callback. socket.io invokes it inSocket.onopenon everyconnection attempt (
socket.io-client/build/esm/socket.js:185-194, 405-412), so a socket thatnow outlives the token it was built with still presents the live one on any reconnect.
Authorizationextra header is dropped. It was the server's fallback for a handshakecarrying no auth payload (
sockets.py:239-246), but a header is fixed for the life of themanager, so it could only ever hold the token that was live at socket-build time — i.e. offer a
credential the client may already have discarded. The payload is read live and cannot go stale.
io server disconnectnow drives a bounded, backed-off reconnect of the same socket(5 attempts, 1s doubling; cancelled on teardown).
Why the epoch, and why the reconnect
Both come from the same server behaviour.
_handle_user_access_changedforce-disconnects socketswhen a user is deactivated or when their token epoch is superseded (
sockets.py:761-767), and aserver-initiated disconnect is terminal for socket.io —
Socket.ondisconnect→destroy()→Manager._close()setsskipReconnect(socket.io-clientsocket.js:613-616,manager.js:280-283). Under the old byte-keyed effect the client happened to recover because thereplacement token changed the memo; keying on identity alone would have removed that.
"the session that performed the change reconnects with its replacement token"
(
sockets.py:691-696). Replacement and sliding-window tokens both carrytoken_epochstraightfrom the user record (
auth.py:52-58,api_app.py:177-183), so a routine refresh keeps the keystable while a revocation changes it.
A deactivation that is reverted moments later would otherwise leave that tab with no events and
the Invoke button disabled (via
$isConnected→readiness.ts) until a page reload. If theclient is genuinely no longer welcome, the reconnect is rejected with a connect error, which
socket.io does not retry — so the retry cannot become a loop.
Room re-subscription is unaffected:
subscribe_queue/subscribe_bulk_downloadare emittedfrom the
connecthandler (setEventListeners.tsx:106-111), which fires again on everyreconnect.
Tests
useSocketIO.test.tsx— mounted-DOM coverage (happy-dom +act, the pattern from #9439):token;
setCredentialsto a different user rebuilds it;externalTokenAdoptedfrom another account tears it down and waits for/meto rehydrate;io server disconnectreconnects the same socket,transport closeis left to socket.io, theretries are bounded, and a pending retry never revives a socket the session has discarded.
Plus session-key units in
refreshedTokenConsumers.test.ts.Every behaviour was mutation-checked one at a time — each mutant fails exactly its own test and
nothing else.
Verification
Ran the fixed build against a live server for ~4 minutes with
pnpm dev, sampling the server'sTCP connections at 0.5s: the connection carrying the fixed client's socket stayed established in
285/285 samples, across three of the minute boundaries that previously dropped it.
Full frontend suite green (166 files / 2199 tests); all five
pnpm lint:*clean.Two independent fresh-context adversarial reviews were run against the change (state lifecycle /
existing-caller assumptions, and auth + multi-user isolation); the epoch keying, the header
removal and the bounded reconnect are all responses to their findings.