anomalyco / anomalyco/opencode
TUI: CodeRenderable posts redundant full-document Tree-sitter highlights during Markdown streaming
@simonklee is already working on this.
Since Jul 28, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
Description
While investigating sustained CPU usage in OpenCode 1.18.5 during long, code-heavy reasoning streams, I found a source-level scheduling issue in OpenTUI 0.4.5: one streaming CodeRenderable can post additional full-document highlightOnce() requests before earlier requests complete.
OpenTUI discards stale results by snapshot ID, but only after the worker has fully parsed and queried those stale snapshots. There is no admission guard, cancellation, or latest-only coalescing for one-shot highlights. This creates a direct path for redundant work whenever updates arrive faster than highlighting completes.
The field observation that led to this source review had a Bun thread named Worker at approximately 100% CPU during a large reasoning stream. Its exact worker entry point was not resolved: the profile does not directly prove that this TID was OpenTUI's Tree-sitter worker. The source defect and the runtime hotspot should therefore be treated separately unless instrumentation connects them.
This looks related to the broad streaming symptom in #6172, but the available profiles differ:
- #6172 sampled the native Zig text-buffer measurement path, and CPU dropped near zero when streaming stopped.
- Here, the sampled full-core hotspot was a Bun
Worker, with approximately 80% ofperfoverhead in anonymous JIT code and effectively no syscall activity from that TID during a five-second trace. - This does not match #6172's symbolized native Zig stack, although native layout may still contribute to the remaining process CPU.
Impact
- OpenCode used approximately 170-198% CPU during the affected response.
- One worker consumed approximately 99-101% continuously.
- The TUI remained responsive enough to inspect while the stream was active.
- OpenTUI can post obsolete full-document highlight requests for intermediate content snapshots even though only the newest result can be displayed.
Environment
- OpenCode:
1.18.5 - OpenTUI core:
0.4.5 - OS: Linux
7.0.0-28-generic, x86_64 - CPU: 12 logical CPUs
- Terminal: Ghostty,
TERM=xterm-ghostty - Reasoning display mode during the observation: expanded (
thinking_mode=show) - Animations: disabled
The affected session had approximately 0.84 MiB of reasoning split across 14 parts. Individual reasoning parts reached approximately 79-127 KB and contained hundreds of fenced Go and untyped code blocks. The size of the SQLite database itself is not being used as causal evidence.
Observed profiling data
Per-thread sampling repeatedly showed the same worker consuming a full core:
TGID TID Command %CPU
453708 453783 Worker 99-101
At the process level, OpenCode was generally around 170-198% CPU. Storage I/O was normally 0 KB/s read and approximately 4 KB/s write with no I/O delay.
A perf record -F 199 -g -t <hot-worker-tid> capture produced approximately 1,000 samples with no lost samples. Aggregating the report puts approximately 80% of samples in:
[anon:JSJITCode]
The JIT frames were not symbolized, so this profile does not distinguish JavaScript from WASM or identify Tree-sitter functions. It does show that the sampled TID was predominantly executing user-space generated code rather than spending its sampled time in kernel syscalls.
In a five-second strace capture, the hot TID appeared only once, in an epoll_pwait2 entry present when tracing attached; no further syscall from that TID was recorded. Other process threads showed normal epoll, futex, and timer activity.
The timing does not prove post-stream queue draining. The session log reported:
10:14:14 local message="exiting loop"
10:14:25 local message=stream
At 10:16:53-10:16:55 local, pidstat measured the hot worker at 99.01% and 100.00% CPU. The supplied log does not show that the second stream ended before this measurement, so these samples establish sustained CPU during a long stream, not CPU after a confirmed final idle boundary.
SQLite, terminal, and GPU observations:
- The database and WAL were stable for long windows while CPU remained high.
pidstatshowed no read traffic, minimal writes, and no I/O delay.- Ghostty and aggregate Radeon GPU usage increased during redraws but were much smaller than OpenCode's CPU usage.
- These components may add secondary cost, but they do not explain the sampled full-core hotspot.
Source-level scheduling defect
At OpenCode tag v1.18.5 (e5cc278dec9294a627a7b05f47ce6a564408c1a2), ReasoningPart renders the accumulated reasoning body as a streaming Markdown code block:
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
content={summary().body}
...
/>
Source: packages/tui/src/routes/session/index.tsx, ReasoningPart around lines 1572-1627.
OpenTUI core 0.4.5 (0c8c4f7cff2927e3df63a9757a45eff9a343611c) then follows this path:
CodeRenderable.contentmarks highlights dirty and increments_highlightSnapshotIdwhenever the accumulated content changes.renderSelf()callsstartHighlight()whenever highlights are dirty. It does not check_isHighlightingbefore starting another request.startHighlight()callstreeSitterClient.highlightOnce(content, filetype)with the complete current content.TreeSitterClient.highlightOnce()sends a distinctONESHOT_HIGHLIGHTmessage for every call.messageCallbacksretains each request until its response arrives. There is no cancellation or coalescing key.ParserWorker.handleOneShotHighlight()runsparser.parse(parseContent), queries the full Markdown tree, processes injections, and parses fenced injected languages before posting a response.CodeRenderablechecks the snapshot ID only afterhighlightOnce()returns. A stale result is not applied, but all parsing and query work for that stale snapshot has already happened.
Relevant OpenTUI files:
packages/core/src/renderables/Code.tspackages/core/src/lib/tree-sitter/client.tspackages/core/src/lib/tree-sitter/parser.worker.ts
For an accumulated document with snapshots of lengths n1, n2, ... nk, the current behavior can perform work proportional to parsing each accepted snapshot, rather than the latest snapshot only. Code-fence injections amplify the cost because each one-shot Markdown parse also discovers and parses injected code blocks.
Evidence assessment
Directly observed at runtime:
- A Bun worker, not a renderer-named native thread, held one full CPU core.
- The worker was dominated by anonymous JS JIT code and did essentially no syscalls while hot.
- The visible reasoning consisted of large, code-fence-heavy Markdown parts.
Directly verified in source:
- OpenCode sends the complete accumulated reasoning body to a streaming Markdown
CodeRenderable. - OpenTUI starts full one-shot highlights without a single-flight guard and rejects stale results only after completion.
Source-supported working hypothesis:
- Redundant full-document Markdown/Tree-sitter highlights are a plausible contributor to the observed streaming CPU usage, but their runtime contribution was not measured.
Not proven by the available profile:
- The JIT profile does not provide a symbolized stack naming
web-tree-sitteror a specific parser function. - The hot TID was not directly identified as OpenTUI's Tree-sitter worker.
- Actual one-shot queue depth and the number of obsolete snapshots were not measured.
- Post-stream queue draining was not captured across a confirmed final stream-to-idle boundary.
- The exact fraction of total process CPU attributable to parsing, highlight-to-chunk conversion, Solid updates, GC, and native layout was not isolated with an instrumented build.
An instrumented build that records the Tree-sitter worker's OS TID and counts ONESHOT_HIGHLIGHT enqueue/start/finish events, content length, caller ID, and queue depth should confirm or falsify the runtime attribution directly.
Expected behavior
- Streaming should remain visible and responsive.
- At most one highlight operation per
CodeRenderableshould be in flight. - If content changes while highlighting is in flight, intermediate snapshots should be replaced by one pending latest snapshot.
- One renderable should not post work for every intermediate snapshot when highlighting is slower than the update rate.
Proposed fixes
-
Make
CodeRenderablehighlighting single-flight and latest-only.Keep at most one active
highlightOnce()call and one pending latest snapshot. When the active call finishes, skip directly to the newest content if its snapshot differs. Do not launch one request per render while_isHighlightingis true. Reset_isHighlightingin afinallypath, including stale-result paths. -
Add a client-side scheduler if aggregate pressure remains a problem.
Add an explicit scheduler in
TreeSitterClientbeforepostMessage(), keyed by caller/renderable ID. Retain only one active request and one pending latest request per key. Do not rely on replacing messages already queued by the runtime's Worker transport. Dropping only the response after parsing is too late. -
Consider incremental Tree-sitter buffers for streaming content.
TreeSitterClientalready hascreateBuffer()andupdateBuffer()APIs. Appending deltas to a persistent buffer could avoid reparsing the complete base Markdown document for every accepted update. Markdown queries and injections still need careful invalidation and benchmarking. -
Add bounded throttling as a secondary safeguard.
Coalesce content updates to a practical refresh interval, for example one highlight every 33-100 ms, while preserving immediate plain or last-highlighted text updates. Throttling alone is insufficient unless stale queued work is also bounded.
-
Add an application-level fallback for very large active reasoning parts.
During active streaming, show plain text or the most recent completed highlighted snapshot, then run one final highlight when
part.time.endis set. This preserves streaming while bounding highlight work. A byte/fence threshold could trigger this fallback if always using it is undesirable.
Suggested regression tests
- Use a fake
TreeSitterClientwhose firsthighlightOnce()promise is held unresolved. - Update one streaming
CodeRenderablehundreds of times with an increasing Markdown document and render after each update. - Assert that only one request is active while the first promise is unresolved.
- Resolve it and assert that exactly one follow-up request uses the newest content, not every intermediate snapshot.
- Assert that stale responses never replace the latest content and
_isHighlightingis cleared on success, error, destruction, and stale completion. - Add an integration benchmark using a 100+ KB Markdown fixture with many fenced Go blocks. Record one-shot jobs, total bytes parsed, queue depth, completion latency after the final update, and worker CPU time.
Useful acceptance criteria:
- One active plus at most one pending highlight per renderable.
- Per-renderable queue depth remains bounded when updates arrive faster than highlighting completes.
- For each renderable, after its final update, at most its current active snapshot and one pending latest snapshot remain. A separate global scheduler is required to bound aggregate work across renderables.
- Final highlighted output matches the newest content.
Related issues
- #6172: similar high CPU during TUI streaming, but a native Zig text-buffer hot path and CPU drop at idle.
- #11119: Linux TUI worker CPU report centered on futex/timer wakeups; different syscall behavior from this case.
- #30086: broad newer-version CPU regression report without this Tree-sitter queue mechanism.
- #31548: web UI Shiki/Markdown work per SSE delta; conceptually similar full-pipeline work per update, but a different frontend and highlighter.
Diagnostic artifacts available
I can provide the sanitized pidstat, perf, strace, and GDB captures. I am not attaching the session database or raw reasoning because it contains unrelated private work.
Plugins
No response
OpenCode version
1.18.5
Steps to reproduce
Deterministic component reproduction
- Create a fake
TreeSitterClientwhose firsthighlightOnce()promise remains unresolved. - Create one
CodeRenderablewithfiletype="markdown"andstreaming=true. - Render once so the first highlight starts.
- Repeatedly append content, assign the accumulated string to
content, and render again while the first promise is unresolved. - Inspect the fake client's calls.
Current behavior: every dirty render can call highlightOnce() again with another complete content snapshot while the first call is still active.
Expected behavior: while the first request is active, retain only the newest pending snapshot. When the first request finishes, make at most one follow-up call using that newest content.
Observed field scenario
-
Start OpenCode 1.18.5 in the TUI.
-
Use
/thinkingto expand reasoning before generation starts. -
Run a model/task that produces a long reasoning part with many fenced code blocks. The observed case used multiple 79-127 KB Markdown reasoning parts with many Go fences.
-
Monitor CPU per thread while the response streams:
pidstat -p <opencode-pid> -t -u -w -d 1
This field scenario is not deterministic because model output, delta rate, and render timing vary. It demonstrates the performance symptom, while the component reproduction directly demonstrates the scheduling behavior.
Screenshot and/or share link
No response
Operating System
Ubuntu 24.04.4
Terminal
Ghostty
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.