[Bug] client-hmr's SSE channel burns one HTTP connection per frontend, capping how many DSH frontends can coexist on one origin #4055
Replies: 1 comment
|
Confirmed against v0.1.1-rc.2 source — your table is accurate and the 30-connection causal loop evidence is the strongest part (closing one frontend immediately unblocking the stalled iframe proves the slot-pinning mechanism beyond doubt). Your WebSocket fix is the right call; I verified the alternatives so the decision has a paper trail. Source facts (packages/client/hmr/src/index.ts)
Why the alternatives to WebSocket do not hold up
Why WebSocket is the natural fit
Severity note: Family: same connection-lifecycle domain as #4059 (teardown overreach on upgraded sockets) — the sweep I suggested there ( |
Uh oh!
There was an error while loading. Please reload this page.
Summary
client-hmropens its/plugins/eventschannel withEventSource(SSE). SSE is a long-lived HTTP connection, so it consumes one slot from the browser's per-origin HTTP connection pool and never releases it. Every DSH frontend takes one such slot.Once a page runs several DSH frontends against the same origin, the pool is exhausted and any further frontend can no longer even fetch its own document — its navigation request stays
pendingforever and the UI sits at "Loading plugins…" indefinitely. It never errors out, because nothing failed: the request is queued behind a queue that never drains.Switching that one channel from SSE to WebSocket removes the ceiling entirely, because WebSocket connections are governed by a separate, far more permissive browser quota.
Environment
0.1.0-rc.7and0.1.1-rc.2(both reproduce; the relevant code is byte-identical in the two versions)Reproduction
<iframe src="/?…">, or simply several tabs on the same origin.pending, and no new TCP connection is created for them.The server is entirely healthy while this happens — in our measurements it answered in 1–3 ms and fetching all 39 client modules serially took 1826 ms with zero failures.
Root cause
Each DSH frontend holds exactly 3 long-lived connections:
dsh-client-connection→readWebSocket(MUX_EVENTS_PATH, …)dsh-client-connection→readWebSocket(HOST_EVENTS_PATH, …)dsh-client-hmr→new EventSource(EVENTS_ENDPOINT)Only #3 is the problem.
packages/client/hmr—lib/client.js:and the server half registers it as a plain HTTP route:
Because this is HTTP/1.1 over a loopback origin (no HTTP/2 available — the web server is built on
node:http), those SSE streams each pin one of the six per-origin HTTP slots for as long as the frontend lives.Evidence
Measured connection counts (each frontend consistently accounts for exactly 3 connections — opening one adds 3, closing one removes 3):
pendingwith no new connection attempted. Closing one existing frontend (freeing 3 connections) caused the stalled iframe to immediately open 3 connections and proceed — a clean causal loop.Suggested fix
Move the hmr channel to WebSocket. The infrastructure is already in the codebase:
wsis a dependency ofdsh-client-connection, andwebServeralready exposesregisterUpgrade— the two existing WebSocket channels are mounted exactly this way. The change is roughly a dozen lines on each half.Server (
packages/client/hmr, node half):Browser (
packages/client/hmr, client half):The
messagelistener andclose()teardown below it need no changes at all — the two APIs agree on both. The wire protocol is unchanged apart from dropping the SSEdata: …\n\nenvelope.Verification
We applied exactly this change locally against
0.1.1-rc.2and confirmed:Why this matters beyond our case
Anyone embedding more than one DSH frontend on a single origin hits this — split-pane workspaces, dashboards, comparison views, or simply a few tabs. The symptom (an indefinite "Loading plugins…" with a healthy server) gives no hint about the real cause, which makes it expensive to diagnose.
It also gets worse over time within a session: connections from closed pages were observed lingering for 15+ minutes, so a developer who has been opening and closing DSH tabs can hit the ceiling with fewer live frontends than expected.
All reactions