fix(desktop): stop a single message from crashing or freezing the chat - #47664
Conversation
`normalizeFenceBlocks`/`pushProseFence` appended block bodies with `out.push(...lines)`, which spreads every line as a separate call argument. A single message carrying a large fenced block (a logged minified bundle, base64 blob, or big tool dump — common in long sessions) overflows V8's argument-count limit and throws `RangeError: Maximum call stack size exceeded`, breaking the transcript render. Compression doesn't save us: it gates on tokens vs. window, not a single message's line count, and the protected recent tail renders verbatim regardless. Append iteratively via a small `extend()` helper. Behavior is identical for normal-sized blocks.
🔎 Lint report:
|
Streamdown runs our `preprocess` inside its own useMemo, and the user bubble runs `extractEmbeddedImages`/directive parsing inside theirs — so anything thrown while rendering one message (a regex/stack overflow on adversarial content) escapes to the ROOT error boundary and takes down the entire app, as seen in a reported `RangeError: Maximum call stack size exceeded` from a single message. Wrap both the assistant preprocess pipeline and the user-message directive passes in try/catch that degrade to the raw text. One bad message now renders plain instead of nuking the transcript.
A multi-MB message (logged bundle, huge tool dump) froze the renderer before any paint: Streamdown runs `preprocess` + `marked` lex over the whole string synchronously in a useMemo, an uninterruptible long task that no try/catch or content-visibility can help (our JS runs before the browser ever skips layout). Tiered fix: - Message gate: past 200KB, bypass markdown entirely and render the raw text in `content-visibility:auto` line-chunks — synchronous work is bounded to a string split, the browser virtualizes layout natively, and every line stays in the DOM (selectable, find-in-page). - Code-block budget: past 3k lines / 150KB, skip Shiki (which emits a span per token) and render plain, chunked the same way. - Collapse/expand: a reusable ExpandableBlock clamps code blocks and the huge-text fallback to a 120px preview with a gradient + chevron, expanding to 300px. The inner element is always a scroll container so the content-visibility chunks stay lazily laid out in both states. No content is ever dropped; the copy button (card header) always yields the full block.
c09fb0b to
0138282
Compare
|
Reviewed against current Crash class fully fixed. All 6 Render-throw containment is correct. Hooks ordering is safe. The Chunking is lossless and the budget check is cheap. Minor / non-blocking:
|
…rkdown-spread-overflow fix(desktop): stop a single message from crashing or freezing the chat
…rkdown-spread-overflow fix(desktop): stop a single message from crashing or freezing the chat
…rkdown-spread-overflow fix(desktop): stop a single message from crashing or freezing the chat
…rkdown-spread-overflow fix(desktop): stop a single message from crashing or freezing the chat
…rkdown-spread-overflow fix(desktop): stop a single message from crashing or freezing the chat
Summary
A single message could either crash the desktop chat (
RangeError: Maximum call stack size exceeded, hitting the root error boundary so the whole app died) or freeze it hard (uninterruptible long task, no error, requires killing the process). Both are triggered by routine large content in long sessions — backgrounded terminal dumps,catof a minified bundle, base64 blobs, newline-heavy JSON. Auto-compression doesn't prevent it: it gates on tokens vs. window, not a single message's line count, and the protected recent tail renders verbatim.Fixed in layers, cheapest first:
Crash fixes
markdown-preprocess.ts):out.push(...lines)passed every line as a call argument; a block over V8's argument-count limit threw. Replaced with an iterativeextend()helper — identical output, no limit.markdown-text.tsx,directive-text.tsx): Streamdown runs ourpreprocessinside its own useMemo and the user bubble runsextractEmbeddedImages/directive parsing in theirs; a throw there bubbles to the root boundary. Wrapped both in try/catch that degrade to raw text — one bad message renders plain instead of nuking the transcript.Freeze fix
markdown-text.tsx): past 200KB, bypass the markdown pipeline entirely (nopreprocessregex, nomarked, no Shiki) and render raw text incontent-visibility:autoline-chunks. Synchronous work is bounded to a string split; the browser virtualizes layout natively; all content stays in the DOM.shiki-highlighter.tsx): past 3k lines / 150KB, skip Shiki (a span per token) and render plain, chunked the same way.UX
expandable-block.tsx): reusableExpandableBlockclamps code blocks and the huge-text fallback to a 120px preview with a bottom gradient + chevron, expanding to 300px. Inner is always a scroll container, so the content-visibility chunks stay lazily laid out in both states. Copy button (card header) always yields the full block.Not JS thread-virtualization — no scroll math / windowing library. A size gate bounds processing; CSS
content-visibilitylets the engine virtualize layout.Test plan
markdown-preprocess+markdown-textsuites green (200k-line block doesn't throw)shiki-highlighterbudget + chunking unit teststsc --noEmit+ ESLint clean