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:
+16
-3
@@ -47,6 +47,7 @@ import { composeInstructions, createTeacherAgent } from "../src/agent/teacher-ag
|
||||
import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { webTools } from "../src/agent/tools/web.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { runningTurn } from "../src/server/turn-runs.ts";
|
||||
import {
|
||||
abortJob,
|
||||
deliverJobResult,
|
||||
@@ -201,6 +202,20 @@ async function drain(res: Response): Promise<string> {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/** CONNECTION DIET B (owner, 2026-10-10): the POST acks and closes; drain
|
||||
* the turn's wire through the reattach stream instead. */
|
||||
async function drainTurn(req: Request, factory: Parameters<typeof handleChatRequest>[1]): Promise<string> {
|
||||
const cloned = req.clone();
|
||||
const ack = await handleChatRequest(req, factory);
|
||||
if (ack.status !== 200) {
|
||||
assert.fail(`expected 200 ack, got ${ack.status}: ${await ack.text()}`);
|
||||
}
|
||||
const { chatId } = (await cloned.json()) as { chatId: string };
|
||||
const handle = runningTurn(chatId);
|
||||
if (!handle) return ""; // the turn settled inside the handler — completion already happened
|
||||
return new Response(handle.subscribe()).text();
|
||||
}
|
||||
|
||||
function post(chatId: string, input: unknown): Request {
|
||||
return new Request("http://test/api/chat", {
|
||||
method: "POST",
|
||||
@@ -486,9 +501,7 @@ describe("dispatch (async protocol §2)", () => {
|
||||
createTeacherAgent(undefined, { engine, tools, messages: opts.messages });
|
||||
|
||||
researchModelOverride.current = researcherModel();
|
||||
const body = await drain(
|
||||
await handleChatRequest(post(chat.id, { type: "text", text: "teach me graph rendering" }), agent),
|
||||
);
|
||||
const body = await drainTurn(post(chat.id, { type: "text", text: "teach me graph rendering" }), agent);
|
||||
|
||||
// The teacher's SAME turn carried on past the tool call.
|
||||
assert.ok(body.includes("Back with the brief."), "the turn continued after the dispatch");
|
||||
|
||||
Reference in New Issue
Block a user