Skip to content

fix(server,ui): isolate WebSocket fanout, enforce session revocation, resync UI on reconnect - #114

Merged
pyramation merged 3 commits into
mainfrom
fix/ws-broadcast-fanout-resync
Aug 19, 2026
Merged

fix(server,ui): isolate WebSocket fanout, enforce session revocation, resync UI on reconnect#114
pyramation merged 3 commits into
mainfrom
fix/ws-broadcast-fanout-resync

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

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:

wss.clients.forEach(c => { if (c.readyState === OPEN) c.send(payload); });  // throw here => later clients never get this frame, forever

ws throws synchronously from send on 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 through fanout() in the new hub.ts, which try/catches each send, keeps going, and hands failures to dropClient() (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 the sid was 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, ClientInfo carries sid/username, and each heartbeat sweep closes revoked sockets with 4001 session revoked (one listSessions() 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-OPEN socket 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 after SOCKET_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 pure socket-state.ts: each onopen starts a new epoch via beginConnection() (clears grid/orientation/settings/playlist), the server's initial burst repopulates it, and app.tsx resets settingsSyncedRef on epoch change. The sync-config refetch is triggered from onmessage (via isSyncConfigMessage) rather than inside a setState updater, keeping updaters pure.

Not the cause: OSC/hardware (lasers responded), and the desktop embedded view (laser-view.ts already tracks desiredUrl/loadedProject and 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 — real ws clients against startServer: a throwing server-side peer does not stop another client's state feed; a revoked session closes with 4001 while 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 build pass.

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 dropClient path logs the peer it drops, which would identify it next time.

Link to Devin session: https://app.devin.ai/sessions/5db61a0b63d64e89872f06fffb6452bd
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 19, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant