Fix Studio CPU saturation on long streaming replies - #8750
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b66dbca6d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| // into one block so definitions can resolve references anywhere in the | ||
| // document. Such a construct is globally scoped and cannot retain a prefix. | ||
| if ( | ||
| FOOTNOTE_REFERENCE_RE.test(repaired) || |
There was a problem hiding this comment.
Skip fenced code when detecting footnotes
When a long coding reply contains an ordinary negated character class such as /[^a]/ inside a fenced or inline code span, the raw regex matches [^a] as a footnote reference here. That switches the cache into sticky full-document mode, so every subsequent token repairs and parses the complete growing response even though the Markdown contains no footnote; make this detection Markdown/code-context-aware rather than testing the raw text.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Both regexes are copied from Streamdown 2.5.0. Its parseMarkdownIntoBlocks begins with the same /\[\^[\w-]{1,200}\](?!:)/ and /\[\^[\w-]{1,200}\]:/ applied to the raw string and returns the whole document as a single block on a match, code context included. Making this detection Markdown-aware would desync the cache from the splitter it has to agree with.
It would also buy nothing. With /[^a]/ present, Streamdown returns one block for the entire reply either way, so there is no prefix left to retain. Verified on that input: the incremental path and a full parse both return 1 block.
There was a problem hiding this comment.
I would leave this as it is. The regex does over-match /[^a-z]/, but Streamdown 2.5.0's own parseMarkdownIntoBlocks short-circuits on the identical pair of raw regexes and returns the whole document as a single block. Mirroring it is what keeps the two in agreement, and making this detection code-aware would produce N blocks where the splitter produces 1, which is a rendering bug rather than a speedup.
The impact is also not what is described. Since Streamdown collapses those documents to one block anyway, incremental parsing was never available for them, and the full-document path is the cheap answer: I measured that case at +2.7% against the full repair, not a reproduction of the saturation.
The one thing worth adding is coverage. The existing case escapes the regex only because /[^\s]+/ contains a backslash, which is not in [\w-]; /[^a-z]/ does match, so a regression test asserting parity with Streamdown's own splitting would pin the behaviour.
|
I spent a while on this one because the premise is good and I wanted to be sure before pushing back. Everything below is reproducible with one script against the code on this branch; it is included at the bottom. First, the parts that hold up. I checked the three claims in the description against
So the diagnosis is right. My concern is with the incremental path itself. Three things. 1. The reply stops updating when the repaired tail repeats
(e,t)=>e.children===t.children&&e.shikiTheme===t.shikiTheme&&e.isAnimating===t.isAnimating&&
e.animated===t.animated&&e.mode===t.mode&&e.plugins===t.plugins&&e.className===t.className&&…Every other compared prop is now a module constant thanks to the hoisting in this PR, so when
It never recovers, because the frozen The fix that works here is making Worth noting that 2. Eight cases where the incremental blocks differ from the full repairThe invariant the PR rests on is that incremental output matches Cause by cause:
Unclosed
Fenced
Escape and fence are checked in the wrong order ( Link reference definitions are globally scoped too. The footnote bail-out at For scale: a differential fuzzer over the marker grammar found 108 divergences across 1,039,256 prefixes on this branch. With all seven parity fixes applied to a scratch copy, 0 across 4,174,597 prefixes. So the model is close, and these look like finishable gaps rather than a dead end. 3. The optimisation turns off on the replies it is meant to helpThis is the part I would most want addressed before merge. Two roots.
remend("```sh\necho $HOME\n```\n\npara") === "```sh\necho $HOME\n```\n\npara" // trueUnlike bold, asterisk and underscore there is no context prefix that can recover it, so every shell, PHP and Makefile snippet disables retention permanently. Paragraphs ending in inline code are the other symptom of the Two more all-or-nothing bail-outs are worth a look. None of this shows up in the benchmark table because it uses prose and equations. Adding one code-heavy reply would make the picture much more representative. Smaller things
On the testsThe prefix equivalence oracle in Instrumenting the corpus explains why: across all 30 cases at every prefix, Each of the eight documents in section 2 is short enough to drop straight into What I checked that is fine
Reproduction scriptSave as import remend from "remend";
import { parseMarkdownIntoBlocks } from "streamdown";
import { IncrementalMarkdownCache } from "../src/components/assistant-ui/streaming-render-schedule.ts";
import { stabilizeStreamingMarkdown } from "../src/components/assistant-ui/streaming-markdown.ts";
import { preprocessLaTeX } from "../src/lib/latex.ts";
// exactly what MarkdownTextImpl feeds the cache
const pipe = (text: string) => stabilizeStreamingMarkdown(preprocessLaTeX(text), true);
const filler = (count: number, label = "p") =>
Array.from({ length: count }, (_, i) => `${label} ${i}\n\n`).join("");
console.log("=== 1. streamdown skips the render when the repaired tail repeats ===\n");
{
const cache = new IncrementalMarkdownCache();
let source = "";
let renderedChildren: string | null = null;
let renderedBlocks = 0;
for (let frame = 1; frame <= 24; frame += 1) {
source += "I am sorry.\n\n";
const render = cache.update(pipe(source));
// streamdown's memo compares `children`; it does not compare parseMarkdownIntoBlocksFn
if (render.markdown !== renderedChildren) {
renderedChildren = render.markdown;
renderedBlocks = render.parseMarkdownIntoBlocks(render.markdown).length;
}
const actual = render.parseMarkdownIntoBlocks(render.markdown).length;
if (frame % 4 === 0 || frame === 5) {
console.log(
` frame ${String(frame).padStart(2)} reply ${String(source.length).padStart(4)} chars` +
` children ${String(render.markdown.length).padStart(3)} chars` +
` on screen ${String(renderedBlocks).padStart(3)} blocks` +
` should be ${String(actual).padStart(3)}` +
(renderedBlocks === actual ? "" : " <- stalled"),
);
}
}
}
console.log("\n=== 2. incremental blocks diverge from the full repair ===\n");
{
const gap = "\n\np0\n\np1\n\np2\n\np3\n\n";
const cases: [string, string][] = [
["four-backtick fence, then ~~", "```x````" + gap + "~~ y"],
["unclosed [ in prose", "see [note" + gap + "tail"],
["escaped backtick, then $$", "\\`" + gap + "$$"],
["run of eight asterisks", "********" + gap + "*a"],
["** inside a fence", "```\n**\n```" + gap + "_u then **v then **"],
["* inside inline code", "use `a *b* c` here" + gap + "****x"],
["escaped fence \\```", "a ``` b\n\nc \\``` d" + gap + "- >= 4 GB"],
["repeated link label", `[x]: https://e.test\n\n${filler(12)}[x]: https://e.test\n\nq\n\n`],
];
for (const [name, document] of cases) {
const cache = new IncrementalMarkdownCache();
let report = "identical at every prefix";
for (let length = 0; length <= document.length; length += 1) {
const input = pipe(document.slice(0, length));
const render = cache.update(input);
const incremental = render.parseMarkdownIntoBlocks(render.markdown);
const full = parseMarkdownIntoBlocks(remend(input));
if (JSON.stringify(incremental) !== JSON.stringify(full)) {
report =
`prefix ${String(length).padStart(3)} ` +
`rendered ${JSON.stringify(incremental.at(-1))} expected ${JSON.stringify(full.at(-1))}`;
break;
}
}
console.log(` ${name.padEnd(30)} ${report}`);
}
}
console.log("\n=== 3. share of the reply still re-parsed per update ===\n");
console.log(" 1.00 means the whole document is repaired and split on every token.\n");
{
const probe = (name: string, document: string) => {
const input = pipe(document);
const render = new IncrementalMarkdownCache().update(input);
const ratio = render.markdown.length / input.length;
console.log(
` ${name.padEnd(34)} ${ratio.toFixed(2)}${ratio > 0.5 ? " <- optimisation off" : ""}`,
);
};
const tail = filler(60);
probe("plain prose (baseline)", tail);
probe("paragraph ends in `inline code`", "run `foo`\n\n" + tail);
probe("```sh echo $HOME", "```sh\necho $HOME\n```\n\n" + tail);
probe("```php $x = 1;", "```php\n$x = 1;\n```\n\n" + tail);
probe("```make $(CC) x.c", "```make\nall:\n\t$(CC) x.c\n```\n\n" + tail);
probe("`echo $HOME` inline", "run `echo $HOME` now\n\n" + tail);
probe("```js const s = 'a`b';", "```js\nconst s = 'a`b';\n```\n\n" + tail);
probe("```js /[^a]/ (reads as footnote)", "```js\nconst re = /[^a]/;\n```\n\n" + tail);
probe("prose: takes 5~10 minutes", "takes 5~10 minutes\n\n" + tail);
} |
|
Two follow-ups on top of this round.
Verification for both commits: |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. 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". |
|
Thanks @mahiatlinux, this is an unusually careful review and it found real problems. Your reproduction script ran as written. Everything below is on 1. The stall when the repaired tail repeatsYour diagnosis matches what I landed in Your point about 2. The eight divergencesAll eight are now identical at every prefix, and all eight are in
3. Retention
The rest of that table is unchanged, and here I think the reasoning is wrong rather than the measurement. remend's math parity is not inert on fenced dollars, it is fence-blind, and the effect is visible as soon as the tail has a marker to act on: remend("see *italic") // "see *italic*"
remend("```sh\necho $HOME\n```\n\nsee *italic") // "see *italic" closer withheldYour The remaining bail-outs I have left alone on the same reasoning: the footnote regexes are copied from Streamdown's own splitter, which single-blocks the whole reply on the same raw match, so making ours context aware would only desync it. Smaller things
TestsYour eight documents are in the corpus, plus three that reach contexts nothing previously touched. Instrumenting the corpus before,
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dad4740099
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| if (character === "(") { | ||
| return cursor > 0 && text[cursor - 1] === "]" ? cursor : -1; |
There was a problem hiding this comment.
Handle nested parentheses in link destinations
When a valid link destination contains nested parentheses before a single underscore, such as [topic](https://example.test/a_(b)_overview), this scan reaches the inner ( first and immediately returns -1 because it is not preceded by ]. The underscore is then counted as an unmatched emphasis marker rather than URL content, so no later commit boundary is considered neutral and the live tail grows with the rest of the stream; long replies containing such links consequently return to reparsing an ever-growing suffix on every frame. Track balanced/escaped parentheses while searching for the destination opener instead of rejecting the first nested opener.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This scan is a deliberate mirror of remend's own, which stops at the first ) in exactly the same way, so tracking balanced parentheses here would make the parity disagree with the repair it exists to predict.
remend counts that underscore as an unmatched marker itself:
remend("plain text") // "plain text"
remend("[topic](https://example.test/a_(b)_overview)\n\nplain text") // tail becomes "plain text_"
remend("see _italic") // "see _italic_" closes
remend("[topic](https://example.test/a_(b)_overview)\n\nsee _italic") // tail stays "see _italic"So the document genuinely has an open marker as far as remend is concerned, and no prefix is safely retainable while that is true. The retention loss you measured is real and I reproduced it, 217 characters live against 24 for the same link without parentheses, but it is inherited from remend rather than introduced here, and the alternative is a tail repair that disagrees with a full one. Deliberate, same as the fenced-dollar case above.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a2a026428f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| this.committedLinkLabels.size > 0 && | ||
| linkDefinitionLabels(blocks).some((label) => | ||
| this.committedLinkLabels.has(label), | ||
| ) |
There was a problem hiding this comment.
Preserve global reference-link resolution
When a long stream commits [x]: /url and a later tail contains see [label][x]—or commits the reference before a distant definition—Marked's document-wide definition table resolves the link in a full parse, but this condition only detects duplicate definitions. The tail is therefore lexed without earlier definitions, and committed references are never re-lexed for later definitions, leaving the link as plain text until streaming completes; track reference/definition dependencies or use the full-document path once definitions are present.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Streamdown does not resolve a reference link across blocks at all, so there is nothing here for the retained prefix to preserve. Rendering the full document through <Streamdown mode="streaming"> and looking for the anchor:
definition in the very next block resolves: false
definition 20 blocks later resolves: false
a lone reference paragraph resolves: false
Each block is rendered by its own remark pipeline, so the document-wide table you describe never reaches the block holding the reference. That is true on main as well, with no incremental cache in play, so the behaviour is unchanged rather than degraded here.
The block split is also unaffected: a reference is a paragraph token with the same raw whether or not a definition exists, which is why retainedCase("A [ref][x]\n\n[x]: https://example.com") matches a full parse at every prefix. Definitions are handled for the one thing that does change the split, Marked absorbing a repeated label, and 15d19b8 does that by keeping every definition in the live tail.
|
Codex Review: Didn't find any major issues. Can't wait for the next one! 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". |
|
Reviewed this at head The optimisation worksTwo isolated Studio installs ( In the same 8 seconds of producer time:
The DOM figure lands within a tenth of a percent of the -79.6% in the description. The saturation also showed up in my own harness clock: the 5s and 7s capture deadlines landed at 7.004s and 10.016s on the old renderer because the page could not service them, while the new one hit 5.002s and 7.000s. At 2 seconds: At 5 seconds, where the old renderer has already finished its (much shorter) stream: One caveat on my own harness rather than on this PR: the producer is driven by Separately, replaying just the repair and split path over 420 late-stream updates on a 175,000 character reply reproduces the headline number: 8,689 ms to 76 ms, or -99.1% against the -98.9% in the description. On the three review commentsParser identity ( Footnote detection ( Math markers in fenced code ( The problem is the retry, not the parity. Because
So a shell snippet anywhere in a reply makes this path about 2.3x slower than the renderer it replaces, which matters because code-heavy replies are the common case. The description already has the right principle for this ("keeps that fallback cost equivalent to the existing renderer rather than repeatedly retrying the failed prefix"), it is just applied only to the mid-string repair path. Extending it to a tail that has committed nothing fixes it: const STALLED_TAIL_CHARACTERS = 32_768;
if (!commit.parity) {
if (commit.repairBroke || this.tail.length > STALLED_TAIL_CHARACTERS) {
return this.renderFullDocument(markdown);
}
return this.render(repaired);
}That takes Nice work on this one, the measurements back up what the description claims. |
|
Thanks @danielhanchen. The stalled-tail finding is right and I had it wrong, so that one is fixed in I reproduced your table independently on With the character budget applied those become -2.7%, +1.9% and +0.6%, clean unchanged at -96.0%. Your -2.6% and my -2.7% on the same case is closer agreement than I expected across two machines. What I got wrong is worth stating plainly, because it is a measurement mistake rather than a judgement call. I had rejected this on the grounds that the cost "falls back to what main already pays", and I reached that by comparing characters repaired over the stream, 892k against 895k for the full-document path. That metric looks equivalent and is not, because it counts neither the boundary scan nor the second block split. Wall clock says 2.7x. I have corrected that reply and flipped the reaction on the item. I also took the test-coverage gap you and @mahiatlinux both named. The existing character-class case used One tradeoff in the fix worth flagging. The budget reuses the existing sticky fallback, so a reply whose live tail passes 32,768 characters with nothing committed stays on the full-document path even if the marker later closes. A single fenced block longer than the budget is the realistic way to hit that. It is equal to the current renderer rather than better, which seemed like the right end of the trade against retrying a prefix that keeps failing, but it is a real limit rather than none. Your other two verdicts match where this ended up. The footnote regexes are unchanged and I would leave them: they are copied from Streamdown 2.5.0's own short-circuit, so matching it is what keeps the two in agreement. And Since |
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb228e55a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
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".
| } | ||
| const incrementalCache = incrementalCacheRef.current.cache; | ||
| const incrementalRender = isStreaming | ||
| ? incrementalCache.update(processedText) |
There was a problem hiding this comment.
Defer cache mutation until the render commits
When React interrupts or discards a concurrent render, this call has already mutated source, committedBlocks, lastMarkdown, and possibly renderGeneration even though Streamdown never received that render. This is observable for repeating tails whose child Markdown cycles back to the last committed value: Streamdown's comparator ignores the parser callback and can skip that later render, leaving newly retained blocks absent until another distinct update or completion. Keep render calculation pure or commit the cache state in an effect/layout step.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The mechanism is real and I reproduced it, but the only render this path actually discards is StrictMode's, and that one is safe.
Forcing renders to be thrown away, 35% of updates over repeating content, does lose blocks, so the hazard you describe is not theoretical:
blocks missing from the DOM
normal 0
StrictMode double invoke, first result discarded 0
renders forcibly discarded 48
The middle row is the one that decides it. React calls the component twice under StrictMode and throws the first result away, which is exactly your scenario, and it loses nothing because update returns its previous result unchanged when the text has not moved. That guard went in for a different reason, and on the commit before it the same run loses 392 blocks, so this path was genuinely exposed until then.
Beyond StrictMode, nothing in the chat render path is interruptible: no startTransition, no useDeferredValue, no useTransition anywhere in components/assistant-ui/ or features/chat/components/. Streamdown has its own useTransition but that is inside the memoised child, below this call.
So I am leaving the call in render. It is worth writing down that this rests on React not discarding a committed render here rather than on the cache being pure, and that the remedy if that ever changes is the one you name, moving the commit into an effect.
| <div data-status={status.type} className="min-w-0 max-w-full"> | ||
| <Streamdown | ||
| key={messageId} | ||
| key={`${messageId}:${incrementalCache.renderGeneration}`} |
There was a problem hiding this comment.
Force Streamdown to render when streaming completes
When the final text was already painted before the status changes—common for a balanced fenced-code reply—the streaming child and completed child are byte-identical, and this key also remains unchanged. Streamdown 2.5's memo comparator ignores isAnimating, parseIncompleteMarkdown, and the parser callback; now that the other compared configuration props are stable, it skips the completion render and leaves the last block permanently isIncomplete. As a result, code copy/download controls remain disabled and completed SVG or HTML fences can remain in their loading/raw placeholders; include the streaming state in the render identity or otherwise force the completion render.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The comparator does compare isAnimating, and that is the prop this flips at completion, so the completion render is not skipped.
Streamdown.compare.toString() contains isAnimating: true
identical children, isAnimating true -> false equal (skip)? false
identical children, isAnimating unchanged equal (skip)? true
only parseIncompleteMarkdown differs equal (skip)? true
You are right that parseIncompleteMarkdown and the parser callback are both ignored, but isAnimating={isStreaming} changes with the status, so the comparator returns false whatever the text did.
Confirmed in Chromium on the exact shape you describe, a balanced fenced-code reply whose final text is painted while still streaming, then the status flipped with byte-identical text: the DOM changes on completion (9,367 to 9,343 characters, and the first data-streamdown element is not the same node). If the render had been skipped both would be identical.
The budget is what gets spent before the retained prefix is abandoned, and that spending grows with the square of the tail, so the value is not free. A long fenced block is not what reaches it: while the fence is open the tail lexes to a handful of blocks, candidateCount stays 0, and the prefix is still retainable. What reaches it is an inline marker with many paragraphs before its closer. Measured on an emphasis marker closing 40,000 characters later, 420 updates, five repetitions, median against the full-document path: +73% at 32,768 and +1.6% at 8,192. 8,192 is still about 1,300 words of slack, so a marker a later line genuinely closes keeps its recovery. Adds regression cover for both shapes, since the existing case only covers a marker that never closes at all.
|
Thanks for the correction, and for reproducing the table independently. Agreement that close across two machines is worth more than either number alone. I put the branch through a broader simulation before signing off, aimed at the things that only show up off this machine: other browser engines, other line endings, and inputs that are not ASCII. Everything ran in isolated sandboxes on Linux against What was checkedDifferential fuzz, 14,234,746 prefixes, 0 failures. A grammar of about 40 Markdown fragment generators (headings, setext, fenced and tilde and indented code, ordered and nested and task lists, blockquotes, GFM tables, rules, closed and unclosed HTML, all four math syntaxes, footnotes, link reference definitions, autolinks, escapes) crossed with truncated tails and the mid-string repair shapes ( Browser engines. The cache was bundled and the same corpus run inside Chromium, Firefox and WebKit as well as Node. All four produce byte-identical digests over the whole prefix history, and each matches a full Streamdown split, across 1.7M prefixes per engine. Chromium covers Chrome and Edge; the desktop shell's WebView is Chromium on Windows and Linux and WebKit on macOS, so those three are the shipped matrix. No lookbehind, named groups or newer built-ins in the changed files; the only notable construct is Whole app, all three engines. A real Studio, a mocked SSE stream and no model, streaming a fixed sequence of chunks so content is identical by construction:
Identical across engines, and identical to the merge base on all three. That also retires the caveat on my earlier comment: the final-hash control failed there only because that producer was driven by Line endings and Unicode. CRLF documents across every construct, lone CR, mixed endings; streams cut through a surrogate pair (emoji, ZWJ sequences, flag pairs); CJK, RTL, combining marks, zero-width joiners; astral-plane word characters around emphasis markers. All match a full split at every prefix. Lifecycle and compatibility. Non-prefix edits, shrinking replies, repeated identical updates, empty and single-character inputs, aborts mid-token in six shapes, two concurrent caches. Plus the forwards-compatible degradation of One thing on the tradeoff you flaggedThe limit is real but the example is not the one that triggers it. A single fenced block longer than the budget keeps retaining, at any size I tried up to 80,000 characters: while the fence is open the tail lexes to a handful of blocks, What does reach it is an inline marker with many paragraphs before its closer. And since the budget is what gets spent before giving up, and that spending grows with the square of the tail, the value matters more than it looks. An emphasis marker closing 40,000 characters later, 420 updates, five repetitions, median against the full-document path:
The distributions do not overlap. I pushed
|
|
Correcting the limitation I stated above, since @danielhanchen's I said a single fenced block longer than the budget was the realistic way to reach it. It is not, and the reason is the one his commit message gives: while a fence is open the tail lexes to one block, so and once the fence closes, retention resumes fully: a 12,000 character fence followed by 200 paragraphs ends with a 24 character live tail. What actually reaches the budget is an inline marker with many paragraphs before its closer, which is what he measured and sized against. His 8,192 is the better number and I can reproduce why. On the same 420-update, 175,000 character benchmark the stalled cases now sit at -1.1%, +0.7% and -1.3% against the full-document path, against -2.7%, +1.9% and +0.6% at 32,768, with the clean case unchanged at -95.9%.
The other two from that round I have argued rather than changed, both on the comments themselves. The completion render is not skipped, because the comparator does check |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. 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". |
|
@codex review |
The long-task total is the metric the budgets turn on, and it reads 0 both
when the render is free and when the observer never ran. observe({type:
"longtask"}) is specified to abort silently on an engine that lacks the
entry type rather than throw, so the try/catch around it never fired: under
firefox or webkit the harness scored a perfect zero and exited 0. Detect
support with PerformanceObserver.supportedEntryTypes, record it, and fail
the run when no long tasks were seen or when throttling was disabled.
Also:
- add the entry to tsconfig.app.json, which lists the smoke entries one by
one, so npm run typecheck actually covers the 270 lines it was reported
against
- write the JSON under logs/ like every sibling harness instead of dropping
an untracked stream-pacing.json in the repo root, and create the directory
- treat an exported-but-empty SMOKE_BASE_URL as unset, matching the siblings;
it drove "" as the base URL and burned the full readiness timeout
- register the harness in the two contract tests, which is what surfaced the
SMOKE_BASE_URL bug, and pin the new guards there
- record the second mutation: reverting #7892 moves the longest stall 4-5x
while leaving the long-task total inside the clean range, the exact
opposite of reverting #8750, so both budgets are load-bearing
buffered: true replays whatever the performance timeline already held, so module evaluation and the first React render landed in the budgeted total: one entry, ~140ms, about 2.6% of a clean run here, and larger on a cold or loaded runner. Nothing filtered by startTime and run() reset nothing, so a slow page load read as a slow renderer. Open the measurement window in run() and drop entries that began before it. Pre-stream share goes 2.6% to 0.00% while the stream's own tasks are unchanged (60 and 52 entries over two clean runs), and reverting #8750 still fails the budget at 52,465ms.
… the stream Two holes left by the measurement window added in the previous commits. A long task carries the start time of its whole task, so appending the user message in the same task that assigned measureFrom stamped runtime startup and the first publish as earlier than the window and dropped them as page load. Hand the append to a later task so the work that begins the stream sorts inside it. longestStallMs was only ever written when a later paint closed the stall, so a freeze that ran to the end of the stream was never recorded: the tail can go missing inside the 90% floor and the quiet-frame loop then calls it settled. Measure the stall in progress while text is still arriving, which is what the number means, and not afterwards, where the settle window's own quiet frames would read as a freeze. Clean runs unchanged (stall 933 to 1,050ms, long tasks 4,749 to 5,159ms over three) and both mutations still caught: #7892 reverted fails the stall at 5,233ms, #8750 reverted fails long tasks at 52,263ms.
The stall in progress was measured only while text was still arriving. A freeze that spans the moment the stream ends blocks the frame loop across it, so the first frame afterwards already observes a non-null streamEndedAtMs and the whole frozen interval was skipped. With the lost tail able to hide inside the 90% workload floor, thirty quiet frames then settled the reply and the run reported a short longest stall, which is the one shape this number exists to catch. Cap the interval at the absolute stream-end timestamp instead. A freeze across that moment is recorded in full, and the stall stops growing once there is no more text to wait for, so the settle check's own quiet frames are still not counted as a freeze. The rule moves into smoke-stream-pacing-stall.ts so it can be tested without importing the harness entry, which mounts React on import. The new tests cover the spanning freeze, the settle-window bound, idempotence and late tail paint; restoring the previous rule fails two of the five. Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and both mutations still caught: #7892 reverted fails the stall at 5,017ms, #8750 reverted fails long tasks at 63,687ms.
#8969) * Studio: keep the streaming render harness the perf PRs kept rebuilding * Studio: close the false-green paths in the stream pacing harness The long-task total is the metric the budgets turn on, and it reads 0 both when the render is free and when the observer never ran. observe({type: "longtask"}) is specified to abort silently on an engine that lacks the entry type rather than throw, so the try/catch around it never fired: under firefox or webkit the harness scored a perfect zero and exited 0. Detect support with PerformanceObserver.supportedEntryTypes, record it, and fail the run when no long tasks were seen or when throttling was disabled. Also: - add the entry to tsconfig.app.json, which lists the smoke entries one by one, so npm run typecheck actually covers the 270 lines it was reported against - write the JSON under logs/ like every sibling harness instead of dropping an untracked stream-pacing.json in the repo root, and create the directory - treat an exported-but-empty SMOKE_BASE_URL as unset, matching the siblings; it drove "" as the base URL and burned the full readiness timeout - register the harness in the two contract tests, which is what surfaced the SMOKE_BASE_URL bug, and pin the new guards there - record the second mutation: reverting #7892 moves the longest stall 4-5x while leaving the long-task total inside the clean range, the exact opposite of reverting #8750, so both budgets are load-bearing * Studio: budget only the long tasks the stream itself caused buffered: true replays whatever the performance timeline already held, so module evaluation and the first React render landed in the budgeted total: one entry, ~140ms, about 2.6% of a clean run here, and larger on a cold or loaded runner. Nothing filtered by startTime and run() reset nothing, so a slow page load read as a slow renderer. Open the measurement window in run() and drop entries that began before it. Pre-stream share goes 2.6% to 0.00% while the stream's own tasks are unchanged (60 and 52 entries over two clean runs), and reverting #8750 still fails the budget at 52,465ms. * Studio: tighten the stream pacing harness comments Comments and docstrings only, no code change. Every measured number, PR reference and causal reason is kept verbatim. * Studio: check the reply that settled, not the peak it once reached paintedChars is a high-water mark and only ever climbs, so a completion render that truncated the bubble would leave the peak behind and the 90% workload floor would still pass on a DOM that no longer held the reply. Record what is on screen at settlement and check that too. Measured equal to the peak today (24,033 both), so this is a guard rather than a live discrepancy, and it is pinned in the harness contract test. Also count slow frames only inside the measurement window and reset the counter in run(), the same rule long tasks now follow. Contamination measured at 0 of 286 here, but an external server or a slower box need not be 0 and the number is meant to be comparable across them. * Studio: record a stall that never ends, and keep the task that starts the stream Two holes left by the measurement window added in the previous commits. A long task carries the start time of its whole task, so appending the user message in the same task that assigned measureFrom stamped runtime startup and the first publish as earlier than the window and dropped them as page load. Hand the append to a later task so the work that begins the stream sorts inside it. longestStallMs was only ever written when a later paint closed the stall, so a freeze that ran to the end of the stream was never recorded: the tail can go missing inside the 90% floor and the quiet-frame loop then calls it settled. Measure the stall in progress while text is still arriving, which is what the number means, and not afterwards, where the settle window's own quiet frames would read as a freeze. Clean runs unchanged (stall 933 to 1,050ms, long tasks 4,749 to 5,159ms over three) and both mutations still caught: #7892 reverted fails the stall at 5,233ms, #8750 reverted fails long tasks at 52,263ms. * Studio: tighten the comments added since the first pass Comments only, no code change. Every measured number and every causal reason is kept. * Studio: record a freeze that spans the end of the stream The stall in progress was measured only while text was still arriving. A freeze that spans the moment the stream ends blocks the frame loop across it, so the first frame afterwards already observes a non-null streamEndedAtMs and the whole frozen interval was skipped. With the lost tail able to hide inside the 90% workload floor, thirty quiet frames then settled the reply and the run reported a short longest stall, which is the one shape this number exists to catch. Cap the interval at the absolute stream-end timestamp instead. A freeze across that moment is recorded in full, and the stall stops growing once there is no more text to wait for, so the settle check's own quiet frames are still not counted as a freeze. The rule moves into smoke-stream-pacing-stall.ts so it can be tested without importing the harness entry, which mounts React on import. The new tests cover the spanning freeze, the settle-window bound, idempotence and late tail paint; restoring the previous rule fails two of the five. Clean runs unchanged (stall 967 to 983ms, long tasks 5,442 to 5,842ms) and both mutations still caught: #7892 reverted fails the stall at 5,017ms, #8750 reverted fails long tasks at 63,687ms. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the stream pacing smoke page load again Merging main brought in ASSISTANT_PART_COMPONENTS, which thread.tsx builds at module scope with Text: MarkdownText. Entering the markdown-text -> features/chat -> chat-page -> thread cycle from markdown-text runs that object literal while the MarkdownText binding is still in its temporal dead zone, so the page died with Cannot access MarkdownText before initialization and rendered nothing. Import the chat barrel first, as the app's entry does. The page is also a new HTML entry, so it has to load the crypto polyfill before its module entry like every other one. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>



Summary
Long assistant replies could saturate Studio's renderer, grow a very large DOM, and make the desktop UI unresponsive while generation continued.
This removes unnecessary animation DOM and makes Markdown parsing incremental. Streaming updates remain synchronized with browser paints while the work performed for each update stays bounded to the active tail of the response.
Root cause
Four costs compounded on every token update:
animatedoption. Studio usesduration: 0andstagger: 0to select direct updates and avoid React transition starvation. Those values disable visible timing, but they do not disable Streamdown's rehype animation transform. Every non-whitespace text segment was still wrapped in adata-sd-animatespan.ntherefore repeatedly performedO(n)work even though only its tail changed.Fix
requestAnimationFrame.remenduses for whole-document repair decisions across the retained boundary.Production benchmark
Fresh production builds mounted a long formatted reply, then appended 4,000 characters over eight seconds. The producer submitted an update on every animation frame.
Chromium, viewport following the active tail
The old renderer was already saturated at 175,000 characters and committed only 16 of roughly 420 possible paints. The new renderer stayed current at about 58 paints per second.
Markdown repair and block splitting
This isolates the work that previously scanned the complete 175,000-character document on every update.
Conservative fallback
Replies containing a mid-string
remendrewrite use the normal full-document path. This benchmark begins with- >= 16 GB of RAM, the failure shape that prevents safe prefix retention.Equation-heavy compatibility check
An equation-heavy reply was tested separately with 14,000 characters, 57 rendered KaTeX equations, and 4,000 characters appended over eight seconds at full display cadence.
This preserves the direct update behavior added by #7892 while reducing the work performed for each update.
Memoization boundary
Hoisting the configuration props also arms Streamdown's own memoization. Its comparator checks
childrenand a list of props that are now all module constants, and it does not checkparseMarkdownIntoBlocksFn, so the Markdown string became the only thing that can schedule a render. On main the inlinepluginsobject literal made that comparison fail on every render, which hid the boundary.The rendered block list is the retained blocks followed by a parse of
children, so both halves have to be signalled. Retaining blocks waits for an update whose string differs, which a repeating reply would otherwise never produce. Dropping retained blocks cannot wait, because the document really did change, so the chat<Streamdown>key carries a generation that advances only in that case.Correctness
Streaming content remains fully formatted. Math, code, Mermaid, sanitization, URL handling, and artifact behavior continue through the existing Streamdown component and plugins.
Incremental block results were compared against Streamdown's normal full repair and split at every character prefix across headings, paragraphs, links, nested lists, blockquotes, fenced code, HTML, display math, reference links, horizontal rules, tables, footnotes, mid-string tilde and comparison-operator repairs, and unmatched global markers. The marker corpus includes single, double, and triple emphasis markers, five-asterisk runs, inline-code candidates, shell-code dollar signs, strikethrough, single-line display math, and inline and multiline math.
Review added the whole-document rules the retained prefix also has to reproduce: remend completing a dangling link or truncating at a dangling image, its inline-code state at a block-final backtick, an asterisk run scoring once, a fenced
**ordering the closers, an escaped fence, the marker contexts injected for inline code, and Marked absorbing a repeated link reference definition. Each has a reduced case in the corpus.Validation
npm test: 2,295 tests passednpm run typechecknpm run build