fix(sessions): stream session events over a WebSocket to dodge HTTP connection exhaustion - #4342
fix(sessions): stream session events over a WebSocket to dodge HTTP connection exhaustion#4342TomeHirata wants to merge 3 commits into
Conversation
…onnection exhaustion
Each open tab held its session event stream on a fetch-based SSE GET
(`/v1/sessions/{id}/stream`), which counts against the browser's ~6
HTTP/1.1 connections-per-origin cap shared across all tabs. With 5-6
tabs open, the SSE streams filled every slot and unrelated navigation /
API requests queued behind them, so the UI appeared hung.
Add a dedicated per-conversation event WebSocket
(`WS /v1/sessions/{id}/stream/ws`) that carries the same
`ServerStreamEvent` payloads, moving the stream onto the browser's
separate, effectively unbounded WebSocket pool. The web client defaults
to it; the SSE route stays as a fallback (`VITE_EVENT_STREAM_TRANSPORT=sse`).
Server: factor the SSE generator into a transport-agnostic
`_iter_session_events` core (presence + subscribe + validation) so the
SSE wire output is byte-identical and the WS route shares the same
snapshot-on-connect and presence semantics.
Client: split the pump into `pumpParsedEvents` (transport-agnostic) plus
`pumpStreamEvents` (SSE wrapper); add the `sessionEventSocket` transport;
`startStreamPump` selects transport via `useEventStreamWebSocket()`.
Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
Rebase onto main brought the routes_events explicit-import refactor, so `_iter_session_events` needed adding to that import list and to the sessions package re-exports. CI fixes: - Rename `useEventStreamWebSocket` to `eventStreamUsesWebSocket`: the `use` prefix made oxlint's react-hooks rule treat it as a React Hook called from a non-component function. - Annotate `_iter_session_events` as `AsyncGenerator` so it satisfies `contextlib.aclosing`'s `_SupportsAclose` bound. - Build the e2e SPA with `VITE_EVENT_STREAM_TRANSPORT=sse`. Much of that suite drives the SSE transport directly (intercepting `GET /stream` to inject 404s, monkeypatching `window.fetch` for a controllable ReadableStream), and Playwright's `extra_http_headers` don't apply to a WebSocket handshake, so header-auth'd multi-viewer presence tests can't identify themselves over WS. Add a localStorage override (`omnigent.eventStream.transport`) so a transport can be flipped per-tab without a rebuild, and use it for new coverage of the default transport: an e2e turn over the event WebSocket that also asserts the SSE endpoint is never opened, plus two unit tests for transport selection. Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
The conftest's own build sets VITE_EVENT_STREAM_TRANSPORT=sse, but every UI workflow builds the SPA in a dedicated step and then runs pytest with --ui-skip-build, so that fixture never ran and the suite kept exercising the WebSocket default — failing the presence tests, which rely on Playwright's extra_http_headers (not applied to a WS handshake). Set the env var on the build step in each workflow that runs the e2e_ui suite: e2e-ui, flake-stress-ui, ui-snapshot, and ui-snapshot-update. The WebSocket default keeps its own coverage: the localStorage override survives an SSE-pinned build, so test_event_stream_websocket.py still opts back in and asserts the SSE endpoint is never opened. Signed-off-by: Tomu Hirata <tomu.hirata@gmail.com>
| finally: | ||
| recv_task.cancel() | ||
| with contextlib.suppress(asyncio.CancelledError, Exception): | ||
| await recv_task |
| ) | ||
| with contextlib.suppress(RuntimeError): | ||
| await websocket.close(code=status.WS_1001_GOING_AWAY) | ||
| except WebSocketDisconnect: |
|
Related issue
N/A
Summary
Follow-up to #3699, which added a banner warning that open tabs are exhausting the browser's connection pool. That explains the stall; this PR removes it.
Each open conversation holds one long-lived
GET /v1/sessions/{id}/streamSSE request. Held SSE GETs count against the browser's ~6 HTTP/1.1 connections-per-origin cap, shared across all tabs, so with 5–6 conversations open every slot is taken and unrelated requests queue behind them.WS /v1/sessions/{id}/stream/ws) carrying the sameServerStreamEventpayloads. WebSockets use a separate, effectively unbounded pool, so the HTTP cap no longer applies.VITE_EVENT_STREAM_TRANSPORT=sseat build time, or theomnigent.eventStream.transportlocalStorage override per tab. No hard cutover._iter_session_eventscore (presence +session_stream.subscribe+ validation). The SSE wire output stays byte-identical; the WS route reuses the same snapshot-on-connect (_build_resource_snapshot) and presence semantics.pumpParsedEvents(transport-agnostic reduce/tap/flush) pluspumpStreamEvents(unchanged SSE wrapper); add thesessionEventSockettransport;startStreamPumpselects viaeventStreamUsesWebSocket().ELI5
The browser only lets ~6 long-lived HTTP connections talk to one site at once, and every tab shares that budget. Each open chat held one forever just to receive live updates — six chats, no slots left, whole app freezes. WebSockets don't count against that budget, so live updates now ride one.
Test Plan
python -m pytest tests/server/routes/test_session_stream_ws.py tests/server/test_stream_events.py -q→ 27 passed.test_session_stream_ws.pydrives the real WS route: subscription-ack heartbeat, live event fan-out, resource snapshot-on-connect, and unauthenticated + unauthorized handshake rejection.cd web && pnpm vitest run src/store/chatStore.test.ts src/lib/sessionEventSocket.test.ts→ 313 passed.sessionEventSocket.test.tscovers parsed-event yield, clean-close vs drop, and abort;pumpParsedEventstests lock the WS clean-close/drop mapping.tests/e2e_ui/chat/test_event_stream_websocket.pydrives a full turn over the event WebSocket and asserts the SSE endpoint is never opened — verified as a real guard by inverting it to SSE, where it fails.VITE_EVENT_STREAM_TRANSPORT=sse, because much of it drives the SSE transport directly (interceptingGET /streamto inject 404s, monkeypatchingwindow.fetchfor a controllableReadableStream) and Playwright'sextra_http_headersdo not apply to a WebSocket handshake, so header-auth'd multi-viewer presence tests cannot identify themselves over WS.mainand re-verified all of the above green.Demo
N/A — no visible UI change; this is a transport swap. Verify via DevTools → Network: the event stream connects as
…/stream/wsrather than an EventStream.Type of change
Test coverage
Coverage notes
Manual verification: confirmed via DevTools → Network that the event stream connects as a WebSocket and that many open tabs no longer stall unrelated HTTP requests; confirmed the SSE fallback still streams under
VITE_EVENT_STREAM_TRANSPORT=sse.Two things worth a reviewer's eye:
MAX_WS_EMPTY_OPENS = 8) rather than an immediate status code. It terminates correctly, just slower to give up than the SSE path. Happy to add a distinct server close code mapped to immediate client failure if preferred.Changelog
Session live updates now stream over a WebSocket, so opening many conversation tabs no longer stalls the app.