Repository navigation
Description
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.
-
handleLineInfoChange(renderables/Diff.ts:248): in split view with word or char wrap, if either pane'swidth !== _lastCodeWidths[i], it callsrequestRebuild(). -
requestRebuild(Diff.ts:221) queuesbuildView()withqueueMicrotask. -
buildSplitViewdoes 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. - sets
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.updateFromLayoutcomputesMath.max(layout.width, 1)(Renderable.ts:1112). That isNaNwhen Yoga reportsNaNfor a node that hasn't been laid out.NaN !== NaNis 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.zigsetTextFromMemId: reset the arena the waysetStyledTextdoes, so repeatedsetTextcalls 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:
- Start at 180x50, then resize to 218x33.
- Press Home.
- Wait about 2.7 s, then resize to 127x71 (103x37 and 120x60 also worked).
- Wait about 0.5 s, then resize to 254x68.
- 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
bbartels commentedon Sep 29, 2026
@kommander @simonklee Wouldn't usually ping, but this is fairly serious, causing massive memory leak resulting in machine lock-up
simonklee commentedon Sep 29, 2026
I'll have a look
native: release replaced text buffer ropes (#1544)
juliojesus commentedon Sep 30, 2026
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
patchtool 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.renderable: defer layout for late children (#1545)
simonklee commentedon Sep 30, 2026
#1544 and #1545