anomalyco / anomalyco/opencode

TUI: text disappears during LLM streaming, main thread stuck in timerfd busy-loop (TreeSitter worker stack overflow)

Open
#42,264 2 comments 3 reactions 1 assignee View on GitHub

@kommander is already working on this.

Since Aug 13, 2026.

Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

Description

TL;DR

During LLM streaming, the syntax highlighting worker crashes with a WASM stack overflow in tree-sitter's captures(). Because the streaming code path doesn't update the text buffer independently of the highlight result, a worker crash leaves the displayed code frozen at an arbitrary position. The crash is content-deterministic — the same deeply-nested code triggers the same AST shape, which triggers the same stack overflow — so resume does not help if the same code block is re-rendered.

Related issues

This is the first report to identify the root cause (TreeSitter worker WASM stack overflow). Previous reports described the same or overlapping symptoms but did not identify the cause:

  • #15388 — TUI garbled/corrupted after long output (closed as not planned, same symptom: restart restores rendering)
  • #3905 — Message history randomly flickers/disappears (closed, tagged opentui)
  • #3935 — Unstable TUI, disappearing content area (closed, same symptom cluster)
  • #23914 — TUI text becomes garbled (no developer response)
  • #17793 — TUI display corruption regression on v1.2.27 (regression of #16351 fix)
  • #32335 — opencode run processes don't exit after completing work (same "event loop stuck" symptom)
  • #7301 — Logs sent via client.app.log() not visible with --print-logs (related logging visibility issue)

Reproduction

Send this prompt to the LLM in the opencode TUI:

Please write a complete TypeScript web server project. I need:

1. A main server file with nested middleware (at least 5 levels of composition)
2. A database layer with nested query builders
3. A routing configuration with nested route groups
4. Type definitions with deeply nested generics
5. Utility functions with complex type inference

Output each file as a separate ```ts code block. Make each file at least 100 lines.
Use deeply nested object literals, chained method calls, and complex type unions.

The crash is triggered by deeply nested code structures in the LLM's response. It depends on the specific code the LLM generates, so not every run will trigger it. In our testing, it triggered on the first attempt with this prompt — but since the crash depends on the exact AST shape of the generated code, reproducibility will vary. Once triggered, the same session content will crash deterministically on every subsequent render.

What happens

Original observation (during normal use)

During LLM streaming, some text on the screen suddenly disappears. The TUI is still responsive — the user can still type and interact — but certain characters are gone. Exiting the session and resuming appeared to restore the rendering — but as discovered during reproduction, this was only because the problematic code block was not immediately re-rendered. When the same content is displayed again, the crash recurs deterministically.

This happened three times in a single day of normal use, including once in a brand-new session (not a resume), ruling out it being a random fluke or tied to a specific session's history.

Ruled out via strace: No user interrupt keys (fd 22 = real stdin shows 0 inputs), no SIGHUP, no SIGWINCH, no terminal resize. The issue is purely in the rendering layer — the underlying data is intact.

strace recording revealed the cause: the worker thread was generating thousands of SIGSEGVSEGV_ACCERR with addresses steadily decreasing, the classic signature of a stack overflow hitting the guard page:

First time Second time Third time
Session type Original Resumed Brand new
SIGSEGV count 5,961 4,932 16,378
Duration ~3.5 min ~3.2 min ~5.3 min
Error type SEGV_ACCERR SEGV_ACCERR SEGV_ACCERR
Address pattern Decreasing Decreasing Decreasing

These are not one crash. Each SIGSEGV represents a separate worker crash-and-recreate cycle (see Root Cause §4). The worker crashes, gets recreated, receives the same content, and crashes again — thousands of times over several minutes.

After the worker crashes, the main thread gets stuck in a timerfd busy-loop (reading fd 7 and fd 8 every second), spinning endlessly even after opencode.log shows all session loops have ended (exiting loop).

Finding during reproduction

When reproducing with the prompt above, an additional symptom became clear: the code is not just "disappearing" — it is frozen mid-stream at an arbitrary position, with everything after that point missing. And resume does not fix it: the code stays truncated at the exact same position, because the same code content triggers the same crash.

Notably, TreeSitter, highlighting failed, falling back appear 0 times in all log files. This is because @opentui/core's ConsoleCapture intercepts all console.warn / console.error calls, storing them only in memory, never writing to any log file (see Root Cause §5).

Environment

  • opencode: v1.18.15
  • @opentui/core: 0.4.5
  • web-tree-sitter: 0.25.10
  • OS: Linux 6.8.0-48-generic (x86_64)
  • This issue is not new to v1.18.15 — the same symptom has been observed in older versions, but ConsoleCapture hides all warnings, so there was never enough evidence to report it.

Root cause

A cascading failure with five key links. Source references are from @opentui/core@0.4.5 (locate by function name, not line number).

1. Text rendering is coupled to highlighting during streaming (why text freezes)

The CodeRenderable.content setter in streaming mode only updates _content (in-memory) and does not update textBuffer (the screen). The textBuffer is only updated when startHighlight() succeeds. If highlighting hangs, the screen freezes.

// CodeRenderable.content setter (packages/core/src/renderables/Code.ts)
set content(value: string) {
  if (this._content !== value) {
    this._content = value
    this._highlightsDirty = true
    if (this._streaming && this._filetype && !this._drawUnstyledText) {
      this.requestRender()
      return                    // ← returns WITHOUT updating textBuffer
    }
    this.textBuffer.setText(value)  // ← only reached in non-streaming mode
  }
}

textBuffer is only updated inside startHighlight(), after await this._treeSitterClient.highlightOnce(content, filetype) resolves. If that Promise hangs, the catch block (which falls back to plain text) never executes:

// CodeRenderable.startHighlight() (Code.ts)
private async startHighlight(): Promise<void> {
  const result = await this._treeSitterClient.highlightOnce(content, filetype)
  // ↑ if this hangs, everything below never runs
  this.textBuffer.setStyledText(styledText)   // ← only way textBuffer gets updated
  // ...
  } catch (error) {
  this.textBuffer.setText(content)   // ← fallback, only runs if Promise rejects
}
2. No debounce on streaming highlights (trigger condition)

Every token triggers the full highlight pipeline — content setter → renderSelf()startHighlight()highlightOnce()postMessage to worker. TreeSitterClient has a DebounceController but highlightOnce() bypasses it entirely. At 30fps render rate, this means up to 30 full highlight requests per second, each sending the complete accumulated content.

3. WASM captures() has no safeguards (the crash)

In the worker, ParserWorker.handleOneShotHighlight() (packages/core/src/lib/tree-sitter/parser.worker.ts) calls query.captures(tree.rootNode), which is tree-sitter's C code compiled to WASM using recursion to traverse the AST. Deep nesting → WASM stack overflow → hard abort (emscripten abort()). There is only try/finally, no catch — and WASM stack overflow is a hard abort that JS try/catch cannot reliably intercept.

4. Worker crash loop — no on("exit") handler, no circuit breaker (why it repeats thousands of times)

The NodeWorkerShim constructor (packages/core/src/platform/worker.ts) only attaches on("message") and on("error"):

// NodeWorkerShim constructor (platform/worker.ts)
this.worker = new node.Worker(createWorkerBootstrapSource(resolvedSpecifier), {
  eval: true, type: "module", name: options.name,
  // no resourceLimits, no stackSizeMb
})
this.worker.on("message", this.handleMessage)
this.worker.on("error", this.handleError)
// no on("exit")! no on("close")!

A search of the published @opentui/core@0.4.5 package source confirms no worker.on("exit") exists anywhere.

During a WASM abort, the worker thread dies outright, and the JS error event may not be dispatched. If onerror doesn't fire, handleWorkerFailure never runs, rejectPendingRequests never runs, and the highlightOnce() Promise never settles — neither resolves nor rejects. This is why the fallback catch block is unreachable.

Additionally, handleWorkerFailure (packages/core/src/lib/tree-sitter/client.ts) only sets this.initialized = false — it does not destroy the client and has no circuit breaker. The next highlightOnce() call lazily re-creates a new worker via initialize(), which crashes again on the same content.

This is the crash loop. The thousands of SIGSEGVs in the strace data (5,961 / 4,932 / 16,378) are not one crash generating many signals — they are thousands of separate crash-recreate-crash cycles, each one spawning a new worker that immediately dies on the same content. This loop continues for minutes until the streaming ends or the session is exited.

5. ConsoleCapture hides all warnings (why it was never reported)

TerminalConsoleCache.overrideConsoleMethods() (packages/core/src/console.ts) replaces all console.warn / console.error with in-memory storage only (max 1000 entries). The warnings that startHighlight()'s catch block would produce are invisible in all log files. This is why the issue has existed across multiple versions but was never reported — there was simply no evidence available to users.

Crash flow
LLM streams token → content setter (updates _content only, NOT textBuffer)
  → startHighlight() → highlightOnce() → worker.postMessage(ONESHOT_HIGHLIGHT)
  → worker: parser.parse() → captures(rootNode) → WASM stack overflow → worker dies

If onerror fires (not guaranteed with WASM abort):
  → handleWorkerFailure → rejectPendingRequests → Promise rejects
  → catch block runs → textBuffer.setText(content) → plain text fallback ✓
  → but client not destroyed, no circuit breaker
  → next token recreates worker → crash again (crash loop)

If onerror does NOT fire (observed in production):
  → Promise never settles → startHighlight() hangs forever
  → textBuffer frozen → code truncated mid-stream
  → worker never terminated → timerfd busy-loop

Resume → same content → same crash → code still truncated (content-deterministic)

Suggested fixes

Ordered by impact:

  1. Always update textBuffer in the content setter — even in streaming mode, call this.textBuffer.setText(value) before return. This ensures text is always visible even if highlighting fails or hangs. The styled highlight can upgrade the display later when it succeeds. (This means text may briefly appear unstyled before highlighting completes — a minor visual tradeoff compared to text being completely invisible.)

  2. Add a timeout to highlightOnce() — reject the Promise after N seconds so the catch block can run even when both onerror and on("exit") fail to fire.

  3. Add on("exit") handler to NodeWorkerShim — call handleWorkerFailure on worker exit, so cleanup (including rejectPendingRequests) happens even when WASM abort prevents onerror from firing.

  4. Add a circuit breaker to handleWorkerFailure — after N consecutive failures on the same content, stop re-creating the worker and permanently fall back to plain text for that code block. This is essential because the crash is content-deterministic: without this, the crash loop repeats indefinitely.

  5. Add debounce to highlightOnce() — use the existing DebounceController (already used by resetBuffer()). Reduces crash frequency during streaming but does not prevent the crash (the crash-inducing content will eventually be sent).

  6. Add depth limit or timeout to captures() — or limit content size / nesting depth sent to the worker. Prevents the crash itself.

  7. Set stackSizeMb in Worker optionsresourceLimits: { stackSizeMb: 8 }. Only delays the crash (deeper nesting needed to trigger), doesn't fix the root cause.

  8. Write WARN/ERROR to log files — in TerminalConsoleCache.appendToConsole, at least write WARN/ERROR level messages to an external log file so issues are diagnosable without strace.

How we collected this evidence

ConsoleCapture intercepts all console.warn / console.error, so opencode's own logs contained zero traces. To work around this, we used a shell wrapper to record two types of low-level logs simultaneously:

  1. script (PTY recording) — records all bytes opencode outputs to the terminal (stdout + stderr), including the full TUI rendering sequence. Lets us verify what was actually displayed and which text was missing.
  2. strace -f -e trace=read (syscall recording) — records all read syscalls from the process and all child threads. Lets us observe SIGSEGV, signals, and which fds were read at what time (especially fd 22 = real stdin, to confirm the user pressed no interrupt keys).

By cross-referencing the two, we pinpointed the worker stack overflow and the main thread busy-loop despite opencode's own logs having zero traces.

Plugins

N/A

OpenCode version

v1.18.15

Steps to reproduce

As above

Screenshot and/or share link

N/A

Operating System

Linux 6.8.0-48-generic (x86_64)

Terminal

N/A

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.