Skip to content

DiffRenderable split view: rebuild loop made only of microtasks never yields (hang with unbounded memory growth) #1543

Closed

Description

@bbartels

Description

In 0.5.12, a DiffRenderable in split view with wrapMode "word" or "char" can get stuck rebuilding itself in a chain of microtasks. After that the event loop never runs again: no frames, no input, no timers, no signal handlers. The main thread stays at 100% and resident memory grows linearly at about 0.5-1 GB/s until the process is killed.

We hit this through the OpenCode v2 TUI; the downstream report is anomalyco/opencode#51761. In our case the process reached about 920 GB before it was killed by hand. It also outlived its terminal: SIGHUP handling runs on the event loop, which never came back.

The loop. We found it from a live capture of the stuck process, with breakpoints on every exported native symbol, and then read the v0.5.12 source.

  1. handleLineInfoChange (renderables/Diff.ts:248): in split view with word or char wrap, if either pane's width !== _lastCodeWidths[i], it calls requestRebuild().

  2. requestRebuild (Diff.ts:221) queues buildView() with queueMicrotask.

  3. buildSplitView does the following:

    • sets flexDirection = "row" (:593);
    • snapshots _lastCodeWidths (:722);
    • writes the left pane's unpadded content;
    • reads both panes' lineInfo;
    • writes the padded content (:905).

    Each content write emits line-info-change, which starts step 1 again.

What the capture showed. Each iteration made exactly the same native calls:

Call Per iteration
YGNodeStyleSetFlexDirection 1
textBufferReplaceMemBuffer + textBufferSetTextFromMem 2 (568 B unpadded content, then 2485 B padded)
textBufferViewGetLogicalLineInfoDirect 2
yogaNodeMarkDirty 8

The loop made no layout calculation and no draw calls. So the pane widths cannot change between iterations, yet the width check fires every time.

Why it doesn't settle. This part is inferred, not directly observed. The likely cause is a pane width of NaN:

  • Renderable.updateFromLayout computes Math.max(layout.width, 1) (Renderable.ts:1112). That is NaN when Yoga reports NaN for a node that hasn't been laid out.
  • NaN !== NaN is always true, so the width check can never pass.
  • It fits the capture: the right pane wrapped an 86-column first line into about 76 rows, which is a 1-column wrap width. That's why the left pane's padded content holds about 75 blank lines.

Why memory grows instead of the process just spinning. Each iteration calls setText twice. UnifiedTextBuffer.setTextFromMemId (native/src/text-buffer.zig:520) calls clear(), which doesn't reset the arena. rope.setSegments then allocates a new tree in that arena (native/src/rope.zig:1175-1179). So every setText leaks the previous rope; setStyledText resets its arena and doesn't have this problem.

Why we think it's a regression. In 0.5.10, onResize started the split re-alignment by comparing the diff's own width with _lastWidth (Diff.ts:221 in 0.5.10). 0.5.12 removed that and added the width check in handleLineInfoChange plus the snapshot in buildSplitView.

Suggested fixes:

  • Diff.ts: start split re-alignment from an actual layout or resize, as 0.5.10 did or from the panes' resize events. At minimum, ignore non-finite widths, compare against the widths after the rebuild, and cap how many rebuilds can happen without a frame.
  • Renderable.updateFromLayout: treat a non-finite computed width or height as 0 before clamping.
  • text-buffer.zig setTextFromMemId: reset the arena the way setStyledText does, so repeated setText calls use bounded memory.

Plugins / consumer

Seen through the OpenCode v2 TUI. It renders tool diffs with DiffRenderable in split view with wrapMode: "word" when the terminal is wider than 120 columns.

Version

@opentui/core / @opentui/solid 0.5.12, as bundled in an OpenCode v2 build from 2026-09-29. OpenCode v2 has pinned 0.5.12 since 2026-09-25. #51761 reports the same symptoms on OpenCode v2.0.18.

Steps to reproduce

This depends on timing and we haven't reproduced it in isolation. In the OpenCode TUI, open a session whose visible transcript contains a small edit diff rendered in split view, then:

  1. Start at 180x50, then resize to 218x33.
  2. Press Home.
  3. Wait about 2.7 s, then resize to 127x71 (103x37 and 120x60 also worked).
  4. Wait about 0.5 s, then resize to 254x68.
  5. Press End within about 50 ms of that resize.

A scripted pty replay of this sequence reproduced it 19 out of 19 times. It stopped reproducing once more messages were added to the session, which changed which rows were mounted. Resizing alone, or scrolling alone, never triggered it. Pressing End about 1 s after the last resize didn't trigger it either.

Screenshot and/or share link

None. The process is frozen, and the last frame shows the split diff.

Operating System

Linux x86_64 (container, kernel 5.15)

Terminal

TERM=xterm-256color over SSH. The reproduction was driven through a scripted pty.

Activity

  1. bbartels commented on Sep 29, 2026

    @bbartels
    Author

    @kommander @simonklee Wouldn't usually ping, but this is fairly serious, causing massive memory leak resulting in machine lock-up

  2. simonklee commented on Sep 29, 2026

    @simonklee
    Member

    I'll have a look

  3. juliojesus commented on Sep 30, 2026

    @juliojesus

    Data point from a Windows 11 / OpenCode v2.0.19 occurrence (details in anomalyco/opencode#51761, comment by juliojesus): the frozen TUI had no diff visible on screen. The only diff in the session was earlier in the transcript: a patch tool result adding a new file (*** Add File: .gitattributes, 2 lines), rendered off-screen above the viewport in a wide terminal. If the rebuild loop can start from an off-screen DiffRenderable (e.g. on a layout/width change), that would match this case.

  4. simonklee commented on Sep 30, 2026

    @simonklee
    Member
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions