anomalyco / anomalyco/opencode

TUI: CodeRenderable posts redundant full-document Tree-sitter highlights during Markdown streaming

Open
#39,342 1 comment 0 reactions 1 assignee View on GitHub

@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% of perf overhead 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.
  • pidstat showed 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:

  1. CodeRenderable.content marks highlights dirty and increments _highlightSnapshotId whenever the accumulated content changes.
  2. renderSelf() calls startHighlight() whenever highlights are dirty. It does not check _isHighlighting before starting another request.
  3. startHighlight() calls treeSitterClient.highlightOnce(content, filetype) with the complete current content.
  4. TreeSitterClient.highlightOnce() sends a distinct ONESHOT_HIGHLIGHT message for every call. messageCallbacks retains each request until its response arrives. There is no cancellation or coalescing key.
  5. ParserWorker.handleOneShotHighlight() runs parser.parse(parseContent), queries the full Markdown tree, processes injections, and parses fenced injected languages before posting a response.
  6. CodeRenderable checks the snapshot ID only after highlightOnce() 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.ts
  • packages/core/src/lib/tree-sitter/client.ts
  • packages/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-sitter or 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 CodeRenderable should 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
  1. Make CodeRenderable highlighting 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 _isHighlighting is true. Reset _isHighlighting in a finally path, including stale-result paths.

  2. Add a client-side scheduler if aggregate pressure remains a problem.

    Add an explicit scheduler in TreeSitterClient before postMessage(), 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.

  3. Consider incremental Tree-sitter buffers for streaming content.

    TreeSitterClient already has createBuffer() and updateBuffer() 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.

  4. 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.

  5. 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.end is 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
  1. Use a fake TreeSitterClient whose first highlightOnce() promise is held unresolved.
  2. Update one streaming CodeRenderable hundreds of times with an increasing Markdown document and render after each update.
  3. Assert that only one request is active while the first promise is unresolved.
  4. Resolve it and assert that exactly one follow-up request uses the newest content, not every intermediate snapshot.
  5. Assert that stale responses never replace the latest content and _isHighlighting is cleared on success, error, destruction, and stale completion.
  6. 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
  1. Create a fake TreeSitterClient whose first highlightOnce() promise remains unresolved.
  2. Create one CodeRenderable with filetype="markdown" and streaming=true.
  3. Render once so the first highlight starts.
  4. Repeatedly append content, assign the accumulated string to content, and render again while the first promise is unresolved.
  5. 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
  1. Start OpenCode 1.18.5 in the TUI.

  2. Use /thinking to expand reasoning before generation starts.

  3. 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.

  4. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.