refactor: State Socket — mux session-state + host-metrics SSE into one WebSocket#375
Conversation
Replace the per-server EventSource pool and host-metrics EventSource with a single /ws/state connection (hello/subscribe/ack protocol), eliminating steady-state h1 pool usage that was blocking WebSocket handshakes on Firefox/WebKit once 6 SSE connections saturated the pool. Retires GET /api/sessions/stream; SSE event names/payloads move verbatim into the new envelope, and attachServer/subscribe* consumer seams are unchanged.
There was a problem hiding this comment.
Pull request overview
Refactors the “state” transport to eliminate per-server SSE fan-out by multiplexing session-state and host-metrics over a single /ws/state WebSocket, updating both backend hub routing and frontend consumers, and migrating tests/docs accordingly.
Changes:
- Backend: add
/ws/stateWebSocket endpoint + adapt the existing hub to route events via per-connection subscription sets; retireGET /api/sessions/stream. - Frontend: introduce a per-tab
StateSocketclient and swapsession-contextwiring from EventSource to WebSocket; add Vite dev proxy for/ws. - Tests/docs: migrate e2e/unit tests from SSE to state-socket mocks, and add an e2e “connection budget” guard.
Reviewed changes
Copilot reviewed 49 out of 49 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| fab/changes/260716-qf3j-state-socket/plan.md | Change plan and acceptance criteria for the state-socket refactor. |
| fab/changes/260716-qf3j-state-socket/.status.yaml | Updates change tracking/status metadata for the rollout. |
| fab/changes/260716-qf3j-state-socket/.history.jsonl | Records stage-transition history for the change. |
| docs/memory/run-kit/tmux-sessions.md | Updates tmux/SSE lifecycle documentation to reference state-socket + gone. |
| docs/memory/run-kit/index.md | Updates memory index/architecture references to the new state socket. |
| app/frontend/vite.config.ts | Adds /ws WebSocket proxy support for dev/test-e2e. |
| app/frontend/tests/e2e/top-bar-refresh.spec.ts | Migrates mocked terminal-route spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/top-bar-refresh.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/top-bar-persistence.spec.ts | Migrates mocked persistence spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/top-bar-persistence.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/status-dot-tip.spec.ts | Migrates status-dot-tip spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/status-dot-tip.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/spawn-agent.spec.ts | Migrates spawn-agent spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/spawn-agent.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/server-reorder.spec.ts | Updates live-backend reorder spec to observe state-socket global events. |
| app/frontend/tests/e2e/server-reorder.spec.md | Updates companion doc for state-socket-based broadcast verification. |
| app/frontend/tests/e2e/row-minimalism.spec.ts | Migrates row-minimalism spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/row-minimalism.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/pr-status-sidebar.spec.ts | Migrates PR-status sidebar spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/pr-status-sidebar.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/pane-register-panel.spec.ts | Migrates pane-register-panel spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/pane-register-panel.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/connection-budget.spec.ts | Adds guard spec ensuring exactly one /ws/state WS and zero SSE responses. |
| app/frontend/tests/e2e/connection-budget.spec.md | Companion doc describing the connection-budget guard’s purpose and steps. |
| app/frontend/tests/e2e/chat-view.spec.ts | Migrates sessions stream mocking to state-socket (chat SSE remains separate). |
| app/frontend/tests/e2e/chat-view.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/board-reorder.spec.ts | Updates board reorder spec to observe per-server board-changed via WS. |
| app/frontend/tests/e2e/board-reorder.spec.md | Updates companion doc for state-socket-based echo verification. |
| app/frontend/tests/e2e/board-list-reorder.spec.ts | Updates board list reorder spec to observe board-order via WS. |
| app/frontend/tests/e2e/board-list-reorder.spec.md | Updates companion doc for state-socket-based echo verification. |
| app/frontend/tests/e2e/agent-next-waiting.spec.ts | Migrates agent-next-waiting spec from SSE to state-socket mock. |
| app/frontend/tests/e2e/agent-next-waiting.spec.md | Updates companion doc to describe state-socket mocking. |
| app/frontend/tests/e2e/_state-socket-mock.ts | Adds shared Playwright WS mock helper for /ws/state. |
| app/frontend/src/lib/state-socket.ts | Introduces the StateSocket client (single WS per tab, ref-counted subs, reconnect). |
| app/frontend/src/hooks/use-boards.test.tsx | Updates hook tests to use a stub WebSocket transport instead of EventSource. |
| app/backend/api/status_refresh_test.go | Adjusts tests to consume hub events rather than SSE bytes. |
| app/backend/api/state_ws.go | Adds the /ws/state handler and envelope rendering types. |
| app/backend/api/state_ws_test.go | Adds hub- and handler-level tests for hello/subscribe/ack/events/gone/errors. |
| app/backend/api/sse.go | Refactors hub/client model to support state-socket subscriptions; removes SSE edge. |
| app/backend/api/sse_test.go | Updates hub tests for new hubEvent-based channels and global fan-out semantics. |
| app/backend/api/sse_subscriber_test.go | Updates subscriber-driven poll tests to consume hubEvent frames. |
| app/backend/api/sessions_test.go | Updates sessions tests to consume hubEvent frames. |
| app/backend/api/router.go | Registers /ws/state and removes /api/sessions/stream. |
| app/backend/api/preview_test.go | Updates preview tests to consume hubEvent frames. |
| app/backend/api/boards_test.go | Updates board tests to consume hubEvent frames / test client wiring. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export type StateSocketControl = { | ||
| /** Push a host-global event (e.g. board-order, status-refresh, server-order). */ | ||
| emitGlobal: (type: string, data: unknown) => void; | ||
| /** Push a per-server event (e.g. sessions, session-order, board-changed, preview). */ | ||
| emitServer: (key: string, type: string, data: unknown) => void; | ||
| /** Push a `gone` frame for a server. */ | ||
| emitGone: (key: string) => void; | ||
| }; |
There was a problem hiding this comment.
Skipped — StateSocketControl is the documented return type of the shared helper's live-frame-control entry point (routeStateSocketWithControl), which specs are directed to by the module doc comments. Keeping it exported is intentional test-helper surface for specs that need live frame control, so de-scoping it now would break the helper's stated contract.
| export async function routeStateSocketWithControl( | ||
| page: Page, | ||
| opts: StateSocketMockOptions = {}, | ||
| ): Promise<StateSocketControl> { |
There was a problem hiding this comment.
Skipped — routeStateSocketWithControl is the module's public live-frame-control entry point (mockStateSocket delegates to it, and the doc comments direct specs here for post-load frame control). It's an intentional shared-helper API, so keeping it exported is correct even though no spec drives live frames yet.
| ws.onclose = () => { | ||
| this.handlers.onConnectionChange?.(false); | ||
| this.scheduleReconnect(); | ||
| }; |
There was a problem hiding this comment.
Fixed — reqToKey is now cleared in ws.onclose, so pending subscribe reqs from a dead connection can't accumulate across repeated drop-while-pending reconnect cycles (reconnect resubscribes with fresh req numbers anyway). (07caa3d)
| // String renders a hubEvent as an SSE-style frame. Used ONLY by tests that | ||
| // assert on the legacy frame shape; production rendering is renderEnvelope. For | ||
| // a gone marker it mirrors the retired `event: server-gone\ndata: {}` frame. | ||
| func (e hubEvent) String() string { |
There was a problem hiding this comment.
Fixed — moved the test-only String() method (and its fmt import) into state_ws_test.go (same package), so it no longer ships in the production binary; the hubEvent doc comment now points there. Backend tests still green. (07caa3d)
Meta
qf3jexcludes
fab/,docs/· generated by fab-kit v2.15.5Pipeline: intake ✓ → apply ✓ → review ✓ → hydrate ✓ → ship → review-pr
Summary
Every attached tmux server currently costs one session-state EventSource plus one host-metrics EventSource, and SSE holds an HTTP/1.1 pool slot in every browser engine (cap 6 per origin) — on plaintext origins a 5-server host saturates the pool from SSE alone, and on Firefox/WebKit a saturated pool blocks new WebSocket handshakes entirely, so no terminal relay can ever connect. This change muxes session-state + host-metrics onto a single
/ws/stateWebSocket (established WS holds no pool slot in any engine), with in-band subscribe/ack ordering that also structurally fixes the long-standingattachServer→SSE race (multi-server-sidebar:70now passes deterministically). Change 1 of 3 in the socket-unification plan (fab/plans/sahil/socket-unification.md); terminal relays and chat SSE are untouched (Changes 2 and 3).Changes
/ws/stateendpoint (gorilla/websocket): JSON-envelope protocol —hello/subscribe/unsubscribe/preview-scopeops,ack+snapshot /event/gone/errorframes; server-key validation (ValidateServerName) on every op; cached global slots replayed once afterhello.sseHubrefactor (api/sse.go):sseClientgeneralized to a per-connection subscription set with per-subscription routing; producers unchanged; SSE event names/payloads move verbatim into the envelope (byte-equality unit-tested); dead-server reap now emits an in-bandgoneevent;GET /api/sessions/stream+handleSSEretired;POST /api/preview-scopestays.session-context.tsxtransport swap: oneStateSocketper tab (src/lib/state-socket.ts— hello, ref-counted subscribe, 1s→15s backoff, resubscribe-on-reconnect);attachServerand allsubscribe*seams keep exact signatures; connection dots = socket-connected AND subscription-acked; StrictMode-safe across remounts;/wsadded to the Vite dev proxy.connection-budgete2e guard (zero EventSources, exactly one live/ws/stateWS on host/server/terminal/board routes); 12 e2e specs migrated to a shared WS mock with.spec.mdcompanions updated.