Studio: stop the chat UI falling behind a fast stream - #8845
Conversation
|
I went through this against Three things I think need addressing before this merges. 1. Stop now truncates the saved replyThis is the one I would prioritise. The PR describes it as a limitation, but it costs more than the description implies.
// Every streamed yield carries the repaired text, not just the terminal ones:
// assistant-ui drops whatever is yielded after an abort, so on Stop the last
// STREAMED yield is what gets saved.Same thing again at Two details that make it worse than "one frame": The window is up to 500 ms, not ~16 ms. The gate reopens on The lost text is unrecoverable, not just unpainted. Autosave exports published runtime state at Worth noting that both coalescers already in this codebase are trailing-edge and do not have this problem:
2. The integration test passes with the gate removed
Mutation A, comment out the entire gate. The PR is effectively reverted, and the test does not notice, because Mutation B, move for await (const chunk of stream) {
const canPublish = createFrameGate();Mutation B is the one the test believes it covers: its own assertion message says "the run creates one gate per request", but Source-grepping is fine and well established here (68 of 294 files in For what it is worth, the other 7 unit tests are good. The quiet-tail case at 3. Only one of four publish sites in the loop is gatedAll four of these yield
"Tool-call updates are unchanged" is accurate, but it means the fix does not reach a tool-calling turn. Gating in Smaller things
Things I checked that are fine
|
|
Thanks for the detailed review. Points 1 and 2 are real and I took both; point 3 in part. It is all in e555607. 1. Stop truncating the saved replyConfirmed, and the mechanism is stricter than the comments you quote imply. I checked it at the library level rather than reading it off them. for await (const r of promiseOrGenerator) {
if (abortSignal.aborted) {
updateMessage({ status: { type: "incomplete", reason: "cancelled" } });
break;
}
updateMessage(r);
}The consumer drops the value. So the That rules out both of the ways out you sketched, which is why I did not take the restructure.
So the loss can be bounded, not removed. I bounded it in characters rather than in time, because characters are what is at risk and the bound then does not move with the stream rate: Measured with Chromium against a local Studio running
Two things to read carefully there. On the merge-base the click never landed during the reply at all: the timer carrying it is starved with everything else, so it fired only after the stream had already closed, which is why that row's last column is just the whitespace-normalisation floor and not a Stop measurement. And on the PR head the loss is real and much larger than one frame, 589 characters in the worst run, which is your point. Two runs per build were left to finish normally instead of being stopped. Those dropped 0 to 7 characters out of 5.9k to 10.5k, so the unconditional final update still carries everything on a normal end. 2. The integration testReproduced both mutations in a scratch tree before touching anything: 8/8 green with the gate commented out, 8/8 green with Rewritten the way you suggest, in the
The 3. Publish sitesI took I left the other two ungated on purpose.
So the rule is to gate the preview rebuilds and never the publishes that carry state, with the reason at the call site. Smaller thingsAll four taken.
One consequence for the description: its Limitation paragraph is out of date, since what a stop can omit is now bounded at under 256 characters rather than a whole gate interval. |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Review found the gate covered the text path and the tool_args preview but
not the OpenAI delta.tool_calls path, which streams function arguments in
many small fragments and rebuilt the whole message for each one. A tool
turn therefore stayed in the old regime.
Gate it the same way, with one exception: a fragment that introduces a
tool call always publishes. Only fragments extending an existing call's
arguments are coalesced, so an aborted turn still cannot lose a call the
merge base would have kept, which is why this path was left ungated.
Both branches feed streamedChars, so the cap covers a turn that only
streams arguments.
Two smaller gaps:
- A chunk the external ${...} strip empties has nothing to show, and the
yield below is skipped for it anyway, but it was still consuming the
gate's publish and holding the next real chunk. Check for that before
consulting the gate.
- The reasoning-group adoption ran only on the success path. The
non-abort failure path yields its own partial with its own metadata(),
so an interrupted reply could render a reasoning group with no duration
entry behind it. Both terminal publishes now share one helper.
Also initialise the frame and timer handles before reopen closes over
them; a scheduler that calls back synchronously otherwise threw out of
the stream loop, and reword UNPAINTED_REOPEN_MS, which is a scheduled
fallback rather than a deadline: a hidden page throttles that timer to
>=1s and pauses frames, so there the arrival cap is what paces publishes.
Tests: the suite's adapter assertions were passing against mutations they
were meant to catch. regionOf now bounds the slice it takes, so editing
an anchor's line cannot silently slide the region hundreds of lines down
and leave the ordering assertions passing against the wrong code. Added
behavioural coverage for the cap resetting its baseline on a cap-forced
publish, a synchronous scheduler, and the new tool-call gating. A
23-mutation matrix over the gate and its call sites now kills all 23; two
of those survived before, including one that silently restores unbounded
stop loss on a plain-text reply.
npm test 2411 passed, typecheck, build, eslint and biome clean on the
changed files. Gate behaviour verified in Chromium, Firefox and WebKit,
which covers Tauri's WebView2, WKWebView and WebKitGTK.
Codex is right, and it reproduces. The gate can only parse a reasoning group out of the text on a publishing chunk, so a pass shorter than MAX_HELD_CHARS in a window that produces no frames is first seen at the terminal adoption, which started and finished it in the same instant and persisted 0s. Measured against the merge base with a controlled clock: a 200 character pass spanning 30s recorded 10s before this PR and 0s after it. Once a pass exceeds the cap the forced publish starts it on time, which is why only short passes were affected. Providers that send reasoning in its own delta field were never affected: that path calls startGroup before the gate. This is the parse-driven fallback for providers that inline <think> in delta.content. Record when the held text arrived and hand that to startGroup, which now takes an optional firstSeenAt and measures from it. Both terminal adoptions pass whatever is still held. All five cases now match the merge base exactly, including the 30s one. npm test 2413 passed, typecheck, build and eslint clean.
Five follow-ons from the arrival-time fix, four of which it caused. The worst is mine: backdating the whole discovery handed every skipped index the arrival time as well, so three groups revealed by one publish after a 30s hold each measured 30s and overlapped, against the invariant that a group revealed and closed inside one discovery measures zero. Measured [30,30,30], now [0,0,30]. Only the group left open takes the arrival time. The stamp was also taken on any held chunk, so a prose delta arriving before the reasoning backdated the group to before it began. It is now taken only when the held chunk carries reasoning. finishGroup reads nothing the gate withholds, so it ran on the wrong side of it: a pause after the reasoning ended was counted as reasoning until the next publish. Closing a group now happens on every arrival and only the start, which needs the parsed content, stays gated. The forced tool-call publish could be the first to expose a group the gate was holding, yielding content whose durations knew nothing about it; it reconciles before yielding now. Last one is the rule this PR already states, applied where it was missing: a Gemini thought signature and a Codex reasoning ledger reach the message only through a yield, so holding one behind the gate loses it on Stop and the next turn replays without it. That is state, not a preview, so it forces a publish like a new tool call does. Three regression tests, and the adapter greps are regexes now so an extra condition on the gate line does not read as the gate having gone. npm test 2416 passed, typecheck and build clean.
Both follow from the previous commit and both are real.
The forced publish for replay state could never fire for the case it was
added for. A chunk whose only payload is extra_content has no delta and
no reasoning, so `if (!delta && !reasoning) continue` returned before the
gate was ever consulted. That shape is not hypothetical: the Gemini
translator has a test for it, test_empty_text_part_with_thought_signature
_emits_extra_content, whose docstring says Gemini 3 ships a content-free
fragment whose only payload is thoughtSignature, and the Codex client
builds a delta of {} plus extra_content on response.completed, so its
reasoning ledger arrives on a text-free terminal delta. Handled before
the empty-content skip now, and the tool-call publish forces on replay
state too.
The forced tool-call publish also only started an adopted group. Left
open it yields with no duration entry behind it and keeps timing across
the tool execution, so a Stop during the tool run persists exactly the
inconsistent metadata the adoption was meant to prevent. Start, resume
and finish now live in one reconcileReasoning used by both publish
paths, which is also why the normal path shrank.
Two regression tests. The adapter greps stay regexes so an added forcing
condition does not read as the gate having gone.
npm test 2418 passed, typecheck and build clean. The earlier
reproductions still hold: the 30s gated pass measures 10s, and three
groups revealed by one publish stay [0,0,30].
A later reasoning pass can open with a bare "<think>" in its own chunk. It
parses to no parts, but an EARLIER pass leaves the group count above zero on
that publish, so the count alone could not tell a pending pass from a
finished one and the stamp was cleared. The body then arrives gated, nothing
restores the stamp, and the pass starts at whatever publish first sees it:
pass one 1s -> 1s = 0s, pass two 3s -> 30s = 27s
clearing whenever a group exists : [0,0]
clearing only once one is active : [0,27]
An active group can only be one the adopt or resume just took the stamp for,
so that is the condition. It sits after the resume that can start the timing
and before the finish that ends it.
|
I think we should simplify this before adding more reasoning-specific reconciliation. The current placement makes the gate skip semantic processing as well as UI publication: That is why coalescing UI updates has spread into reasoning timing, split The simpler insertion point is: This still coalesces the expensive downstream work that causes the visible backlog. Assistant-ui, React, markdown rendering, and browser painting receive fewer cumulative snapshots, while reasoning and replay bookkeeping continue to observe every arrival normally. The available measurements suggest that retaining the content build on every chunk costs very little compared with the work after the yield. In the synthetic parser benchmark, 14,000 cumulative parses of a reply ending at 64,000 characters took about 55 to 65 ms total on this machine. By comparison, the actual browser backlog measured 11.4 to 11.8 seconds at 8x CPU throttling and 26.9 to 28.7 seconds at 12x. Coalescing publications reduced those backlogs to 1.38 to 1.44 seconds and 2.30 to 2.57 seconds respectively. A real Qwen3.5-0.8B-MTP run also dropped from 5.68 to 5.70 seconds of post-stream lag to 0.96 to 0.98 seconds at 12x throttling. So we would give up a small extra parser CPU saving, but should retain nearly all of the user-visible improvement with a drastically smaller and easier-to-reason-about change. The final publish should remain unconditional, and state-bearing tool or replay events can still publish immediately. The held-text cap can also remain for Stop behavior. I recommend moving the gate to immediately before the ordinary assistant-ui yield, removing the reasoning-specific gate timestamps and reconciliation paths, and rerunning the existing browser benchmarks to confirm that the practical improvement is unchanged. |
The previous commit rewrote a block of the gate test file and took the per-call thought signature and unchanged-strip tests with it. Mutation testing caught it: removing the per-call replay latch left the suite green.
|
Codex Review: Didn't find any major issues. You're on a roll. Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
Agreed, and this is the right call. I raised something close to this at the point where the reasoning findings started repeating, then talked myself out of it after a few of the fixes landed cleanly. Your framing is better than mine was, because it identifies the actual mistake: the gate is skipping semantic processing as well as publication, and only the publication needed skipping. I re-measured the parse cost independently before agreeing, since that is the load-bearing claim. Cumulative So about 34 to 41 ms warm, slightly cheaper than your 55 to 65, with the first run showing JIT warmup rather than real cost. Against 11.4 to 28.7 seconds of backlog that is not a tradeoff worth defending. What the move deletes, on my count: Two things I would keep, and I think you are saying the same:
I have two fixes from the latest review round sitting unpushed and I am leaving them there rather than adding more reconciliation to code we have agreed to remove. One of them (the Happy to do the restructure and re-run the browser benchmarks at both throttling levels, or to leave it to you if you would rather drive it. Say which and I will get out of the way. |
Moves the gate from before the content build to immediately before the assistant-ui yield, as proposed on the PR. The rebuild and the reasoning tracker now run on every arrival again, and only the yield is coalesced. The cost this change exists to remove is downstream of the yield: assistant-ui, React, markdown rendering and paint. The rebuild is not. Cumulative parseAssistantContent calls against a reply growing to 64,000 characters measure 34 to 41 ms warm in total, against 11.4 to 28.7 seconds of observed backlog, so keeping it on every chunk costs nothing worth having. Pacing the interpretation as well is what pulled reasoning timing, split <think> tags, server summaries, terminal reconciliation and replay metadata into a change about paint cost. All of that bookkeeping goes: gateHeldSince, gateReasoningEndedAt, reconcileReasoning and its five call sites, adoptGatedReasoningGroups, the split-tag search at the chunk join, the reconcile before the server summary frame, and the back-dating arguments on startGroup and finishGroup. reasoning-duration.ts returns to its original form. The adapter diff drops from +311/-53 to +137/-22. What stays, because it is about the publish rather than the parse: one gate per run, the arrival counter and MAX_HELD_CHARS that bound what a Stop can lose, and ungated publishes for anything state-bearing. That last set now also covers a per-call extra_content change, where Gemini puts the thought signature; a Stop that persists the call without it makes the next turn fail outright. The suite is rewritten around the new invariant: pacing must not be able to change any reasoning duration. Every case that cost a review round under the old placement is driven through the loop twice, publishing on every arrival and then on only the last, and required to agree.
Mutation testing found the gap: inserting a gate in front of the tool_start and tool_end publish left the suite green, so nothing stopped a Stop from persisting a card that never got its result. Only the per-delta argument preview above them is paced.
|
Done, in What went
What stayedOne gate per run, the arrival counter and Does keeping the build on every chunk cost the improvementThis was the open question, so I measured it rather than reasoning about it. Same 64,000 character reply, 2000 chunks, in each engine, with a DOM write plus a forced layout read standing in for what is downstream of the yield. Median of three, after warming all three paths together: C does 8x the parses of B and lands within 11 ms of it on Chromium, inside noise on the other two. The stand-in cost is a lower bound on the real downstream work, so this understates C against A and overstates any gap between B and C. Your read was right. TestsRewritten around the property the placement buys, rather than around the machinery it removes:
Every case that cost a review round under the old placement (a split opening tag, the tag as its own delta, successive complete blocks, a later pass that opens bodyless, a long pause before the answer) is driven through the loop twice, publishing on every arrival and then on only the last, and required to agree. All five now do. Under the old placement each of them differed depending on which arrivals the gate let through. 41 tests became 26: the 17 reconciliation tests are gone with the code, and the ordering test now asserts the whole chain, that the rebuild and the tracker precede the gate and the gate precedes the yield. One test simply forbids the four removed names from coming back. Mutation matrix rebuilt for the new shape: 24 mutations, all killed, including moving the gate back in front of the content build. It also caught a genuine gap while I was writing it, that inserting a gate in front of the Suite 2415 passing, typecheck and build clean, 45/45 cross-engine scenarios in Chromium, Firefox and WebKit foreground and backgrounded, staging CI running now. Lint is unchanged: the same 10 pre-existing eslint errors in One thing worth doing that I have not: your throttled Studio numbers were measured against the old placement. The synthetic result says they should be unchanged, but if you still have that setup it would be worth one confirming run. |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
@codex review |
|
Codex Review: Didn't find any major issues. 🚀 Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
|
End to end measurement, since everything up to now has been either synthetic or measured against the old gate placement. Two isolated Studio installs, built by The visible effect is the freeze, not the total timeThe stall ranges do not overlap across any of the three pairs. That number is the complaint in the PR description, that the page stops painting and Stop cannot run promptly: the un-paced build goes four seconds without updating the bubble at all, and the paced one never exceeds two thirds of a second. Total time moves too, consistently, but only about 1.15x. I predicted a several-fold drop there and that prediction was wrong: once the publishes are coalesced, what remains is the cost of rendering a 60,000 character markdown message, which both builds pay in full. Worth stating plainly since it is the number a reader would expect to move most. The stream duration difference is the backpressure effect, visible in Studio's own timing chip in the settled screenshots (4.38s against 2.85s): the saturated main thread slows the un-paced build's own reader. What this does not showA negative control at a stream slower than the renderer, where the two sides come out at 296s against 306s, i.e. identical within noise. The PR is a no-op when there is no backlog, which matches "slower streams still publish every chunk".
Four earlier attempts that produced clean-looking runs and proved nothingRecording these because each one would have been publishable and wrong.
Scene and the full record of what was observed are in |


This PR coalesces streamed text chunks that arrive before the browser's next frame. Slower streams still publish every chunk. When the renderer falls behind, more chunks are combined into each update instead of queueing separate message rebuilds.
Without this, a fast local reply can keep filling the chat bubble for several seconds after llama.cpp has finished and GPU usage has dropped to 0%. If the UI falls far enough behind, the page stops painting and Stop cannot run promptly.
Studio currently rebuilds and publishes the full accumulated message for every text chunk. With this change, the first chunk publishes immediately, later chunks accumulate until the next frame, and the next publish includes all of them. The final update remains unconditional, so completed replies persist in full. A 500 ms timer reopens the gate when a hidden or heavily loaded window does not produce a frame.
The gate sits immediately before the yield to assistant-ui, after the message rebuild and the reasoning bookkeeping. Those still run on every arrival, so nothing about how the stream is interpreted depends on which chunks happen to publish. The cost being removed is downstream of the yield: assistant-ui, React, markdown rendering and paint. Keeping the rebuild on every chunk measures 34 to 41 ms in total across a 64,000 character reply, and a per-engine comparison of the two placements retains 99.5% to 100% of the improvement.
Per-delta tool-call previews are paced the same way, since they repeat per argument fragment and are replaced by the authoritative parse. Anything state-bearing publishes immediately:
tool_startandtool_end, a newly introduced tool call, and replay metadata, including a per-callextra_contentchange, which is where Gemini puts the thought signature.Measurements
Ranges come from alternating Chromium production-build runs on the same machine. The synthetic stream sent the same 60,000 characters at 800 chunks per second to both branches. Real-model runs used Qwen3.5-0.8B-MTP at about 440 tok/s.
In a 40,000-character stream at 6x throttling, a scheduled Stop click ran after 12.95s on main and 0.74s with this PR.
Limitation
Reopening the gate does not publish by itself. If Stop is pressed before another chunk or the final update, text received during the current gate interval can be omitted. That loss is bounded: the gate publishes regardless once 256 characters have arrived since the last publish, measured in UTF-16 code units, so it is conservative in glyphs. Normal completion and non-abort failure keep the full reply.
The controlled Chromium benchmark needed CPU throttling to reproduce the backlog consistently. The original issue was observed during real llama.cpp generation on this machine. The same backlog and improvement reproduced in CPU-constrained webkit2gtk. Hidden-window testing also confirmed that the 500 ms fallback limits updates when animation frames stop.
Checks
npm test: 2,415 passed, including 26 gate testsnpm run typechecknpm run build