fix(server,ui): isolate WebSocket fanout, enforce session revocation, resync UI on reconnect - #114
Merged
Merged
Conversation
Contributor
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
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
Dan's show had the embedded artist UI frozen while the phone kept driving the lasers, right after he tried to kick another user's session. Three independent faults in the state-fanout path explain that; two are proven by tests that fail on
main.1. One broken peer aborted the whole broadcast (proven). Every broadcast was a bare loop, and
broadcastState()runs every animation frame:wsthrows synchronously fromsendon a socket whose buffer/stream is broken (half-open TCP, a peer that stopped reading). Whichever client sits later in insertion order stops receiving state permanently while the server, receiver and lasers carry on — exactly the reported symptom. All broadcasts and the per-connection initial burst now go throughfanout()in the newhub.ts, which try/catches eachsend, keeps going, and hands failures todropClient()(deregister + terminate).2. Kicking a session did nothing to live sockets (proven).
revokeSession()only deleted the store row; the WS upgrade validated JWT signature/expiry but never asked whether thesidwas still live, and nothing closed already-open sockets. A kicked operator therefore stayed fully connected — the "booting somebody off wasn't working" part. Now the upgrade rejects tokens whose session is gone,ClientInfocarriessid/username, and each heartbeat sweep closes revoked sockets with4001 session revoked(onelistSessions()read per sweep, not per socket). This is the auth/session-invalidation overlap flagged in the brief: it is deliberately server-side socket enforcement only — no logout-button or auth-UI changes, which stay with the sibling session.3. Half-open sockets looked healthy on both ends (fix, not proven as the trigger). No server ping/pong, and the UI only reacted to
onclose. A dead-but-OPENsocket left the UI rendered with its last state indefinitely. Added a server heartbeat (WG_HEARTBEAT_MS, default 15s) that terminates peers that missed a pong, and a UI watchdog that closes the socket afterSOCKET_FEED_STALE_MS(8s) of silence so the existing retry loop reconnects.Reconnect used to keep the previous connection's state, and
app.tsx's settings-sync latch never reset, so a reconnected UI could keep stale sliders. UI socket handling moved into a puresocket-state.ts: eachonopenstarts a newepochviabeginConnection()(clears grid/orientation/settings/playlist), the server's initial burst repopulates it, andapp.tsxresetssettingsSyncedRefonepochchange. The sync-config refetch is triggered fromonmessage(viaisSyncConfigMessage) rather than inside asetStateupdater, keeping updaters pure.Not the cause: OSC/hardware (lasers responded), and the desktop embedded view (
laser-view.tsalready tracksdesiredUrl/loadedProjectand reloads with a fresh token URL — unchanged).Tests
packages/server/__tests__/hub.test.ts— fanout past a throwing socket, liveness sweep, revoked-socket selection.packages/server/__tests__/ws-resilience.test.ts— realwsclients againststartServer: a throwing server-side peer does not stop another client's state feed; a revoked session closes with4001while another client keeps receiving; a revoked token cannot reconnect. Against pre-change code these three fail (uncaught broken-peer throw, and two timeouts).packages/ui/__tests__/socket-state.test.ts— reconnect drops old state, the fresh burst restores it, staleness threshold.Server 75 tests / UI 66 tests, lint, tsc and root
pnpm buildpass.Not verifiable without the real installation
Which of the three faults actually fired on Dan's laptop — the broadcast abort and the revocation gap are both consistent with what he saw, and the kick attempt is a plausible trigger for a socket left in a bad state mid-loop. Confirming needs a repeat of that show (or server logs from it): the new
dropClientpath logs the peer it drops, which would identify it next time.Link to Devin session: https://app.devin.ai/sessions/5db61a0b63d64e89872f06fffb6452bd
Requested by: @pyramation