fix(ui): O(N²)→O(N) text/reasoning accumulation in processUIMessageStream + DefaultStreamTextResult - #15669
Conversation
processUIMessageStream and DefaultStreamTextResult accumulate streaming
text and reasoning deltas with `part.text += chunk.delta`. On long
thinking-mode streams (Anthropic extended-thinking, MiniMax M2,
GPT-o-series, Qwen3-thinking, etc.) this is quadratic in cumulative
text length: V8/JSC try to keep the rope lazy, but any read of
`.text` between writes (e.g., from a UI render loop or NDJSON
serializer) forces flatten, and the next `+=` allocates a fresh
buffer and memcpy's the prior content.
Effect on real workloads (opencode batch run on SWE-bench-Pro,
MiniMax-M2.7 via Tabnine proxy, same instance):
flipt-21a935: 1666 s (47 steps, 35.4 s/step)
→ 800 s (47 steps, 17.0 s/step) — 2.1× faster
vuls-7e91f5: 7200 s HARD TIMEOUT
→ ~21 s/step steady, completes — escapes timeout
CPU profile (60 s perf record -F 99 -p <bun_pid> -g):
Before: 30.27% __memmove_avx_unaligned_erms, 7× HeapHelper GC
threads pinned at 4.0–4.3% CPU each, main JS thread at
85% CPU state R.
After: <1.4% memmove, HeapHelper threads at 1.3–1.6% CPU each
(normal idle), main JS thread at 52.7% CPU.
Same bug shape as vercel#14619 (already fixed for @ai-sdk/mcp).
Fix: chunks-array + lazy getter on `.text`. The getter materializes
on demand and uses one-shot collapse (when _chunks.length > 1,
join + replace with single-element array). The public API surface
(`.text` returns the cumulative string) is preserved, so chat UIs
that render progressively continue to work. The accumulator stays
O(N) total for writes; reads are O(N) per call but cached between
deltas (cheap after the first read in a quiet window).
Sites patched:
packages/ai/src/ui/process-ui-message-stream.ts:
- text-start / text-delta
- reasoning-start / reasoning-delta
packages/ai/src/generate-text/stream-text.ts:
- activeText delta (in DefaultStreamTextResult)
- activeReasoning delta
Out of scope:
- partialToolCall.text += chunk.inputTextDelta — the next line
is parsePartialJson(partialToolCall.text) which needs the
cumulative text on every delta. Fixing needs an incremental
partial-JSON parser. Left for a follow-up.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review feedback from vercel#15669: the previous patch assigned _chunks via plain `=`, which created an enumerable own property. That leaked _chunks through JSON.stringify(), structuredClone(), and spread (...obj) into all consumers — implementation detail escapes the API surface. Switch to Object.defineProperty with enumerable: false. Subsequent reassignments inside the getter/setter preserve the descriptor (ES spec: assignment to an existing data property only updates [[Value]], not [[Enumerable]]/[[Writable]]/[[Configurable]]). Verified with a bench: - JSON.stringify(part) no longer contains "_chunks" - structuredClone(part) returns {type, text: <string>, ...} (no _chunks) - {...part} spread same - Object.keys(part) no longer lists _chunks - final text accumulated correctly (N × delta_size chars) - progressive .text reads still monotonic - per-delta cost still O(1) (no perf regression) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch — fixed in 6374c81. Swapped the Verified the leak is closed with a small bench against A nice side effect: 🤖 Generated with Claude Code |
aayush-kapoor
left a comment
There was a problem hiding this comment.
there's some code duplication + certain things to be changed.
so marking this as closed in favor of #15897
Fixes #15670
Summary
processUIMessageStreamandDefaultStreamTextResultaccumulate streaming text/reasoning deltas withpart.text += chunk.delta. On long thinking-mode streams (Anthropic extended-thinking, MiniMax M2, GPT-o-series, Qwen3-thinking, etc.) this becomes O(N²) in cumulative text length: V8/JSC try to keep the rope lazy, but any read of.textbetween writes (UI render loop, NDJSON serializer, anything that flattens) forces re-allocation +memcpyof the prior content on every subsequent+=.Same shape as the bug fixed in #14619 for
@ai-sdk/mcp. See #15670 for the full bug write-up with reproducer + CPU profile.Effect on real workloads
opencode batch run on SWE-bench-Pro, MiniMax-M2.7 via Tabnine proxy, same instances both runs:
flipt-io/flipt-21a935future-architect/vuls-7e91f5CPU profile evidence
perf record -F 99 -g -p <bun_pid> -- sleep 60on a live opencode stream:__memmove_avx_unaligned_ermsinlibc.so.6HeapHelperGC threadsFix
Chunks-array + lazy getter on
.text. Public API surface preserved —textPart.text/reasoningPart.textreturns the cumulative string just like before. Internal storage is now an array of chunks; the getter materializes viajoin('')on read with a one-shot collapse (when_chunks.length > 1, join + replace with single-element array so subsequent reads are O(1) until next delta).Accumulator stays O(N) total for writes. Reads are O(N) per call but cached between deltas, so consumers that debounce (e.g. UI render at requestAnimationFrame) pay O(N) total.
Files changed
packages/ai/src/ui/process-ui-message-stream.ts— text-start, text-delta, reasoning-start, reasoning-deltapackages/ai/src/generate-text/stream-text.ts— activeText delta + activeReasoning delta in DefaultStreamTextResultBehavior change
None to the public API. The
.textproperty is now a defined getter/setter (was a plain data property), which:part.textmid-stream returns the cumulative text-so-far (progressive UI rendering keeps working)part.text = '...'still works (handled by the setter)Out of scope (follow-up)
processUIMessageStreamhas a third site atpartialToolCall.text += chunk.inputTextDeltafollowed byawait parsePartialJson(partialToolCall.text). That one reads the cumulative text on every delta, so the same fix doesn't apply cleanly — needs an incremental partial-JSON parser. Left for a follow-up PR; manifests as residual memmove on workloads that stream very large tool inputs (largewrite/editJSON args).Reproducer
Test plan
ai@6.0.168)🤖 Generated with Claude Code