streaming: connection diet — the parallel-session freeze, fixed

Root cause (postmortem, verified live): Chromium caps 6 concurrent
HTTP/1.1 connections per origin. Every running turn pinned slots for
its whole life — the POST /api/chat response stream plus a reattach
GET stream per visit that never closed on navigate-away — so two
concurrent thinking sessions starved navigation RSC fetches in the
browser queue (147s measured) until the turns settled. Server was
exonerated (3ms curls during the freeze); main thread idle throughout.

- COMPACTION LAW + WIRE DIET (ui-stream): store the states, not the
  chunks — replace-in-place snapshot retention keyed by updateSlots,
  ~100ms coalesced latest-wins fan-out, slim wire drops pi's duplicate
  assistantMessageEvent payload.
- DIET B: POST /api/chat acks and closes ({ok:true}) — the turn wire
  is GET /stream for everyone, submitter included; send() reattaches
  via refetch-first resumeStream(true).
- DIET A: unmount cleanup calls model.stop() — navigating away aborts
  the view connection (turns are server-side background objects).
- Answer-flicker fix: resumeStream's eager tail clear moved off the
  refetch path (the refetch swaps base+tail atomically; clean-slate
  guard restored on refetch failure) — the optimistic answer card
  survives refetch latency (1.7s stalls measured on dev).
- Tests converted to the ack contract (drainTurn/drainRunning helpers,
  dietFetch mock) + a pin that the POST body reads while the turn runs.

Gates: 1489/1489, typecheck, lint. Live: dual streaming sessions,
navigations 335-648ms, in-page settings 19ms (was 147s).
This commit is contained in:
amosblomqvist
2026-10-10 09:42:47 +03:00
parent 5c1eb47740
commit 643e7596f0
10 changed files with 441 additions and 152 deletions
+13
View File
@@ -54,8 +54,21 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
// latency before the instant replay, so a fresh mount attaches
// directly. This hook's effect is the SINGLE mount-reattach owner
// (chat.tsx's duplicate was deleted with the same round).
//
// CONNECTION DIET A (owner, 2026-10-10 — parallel-session freeze
// postmortem): unmount RELEASES the stream connection. The turn is a
// server-side process — it runs on with zero views; this page's open
// GET stream (or, pre-diet-B, its POST stream) used to survive
// navigation, silently pinning one of the browser's 6-per-origin
// connection slots until the turn settled, streaming into a dead
// component. Enough leaked views starved every NEW request (navigation
// RSC fetches included) — the multi-session navigation freeze. Aborting
// costs nothing: navigating back replays the compacted log + live tail.
useEffect(() => {
if (initiallyInFlight) void model.resumeStream(false);
return () => {
model.stop();
};
}, [initiallyInFlight, model]);
// The view + state, derived per model change from the model.