You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: opencode TUI never renders under gVisor (alt-screen never entered) — regression between 1.0.142 and 1.4.17 #29802
Revised: I initially suspected the bundled Bun version — that is now ruled out (details below). The real issue is an opencode-internal render regression. Title and body updated accordingly.
Summary
The interactive opencode TUI does not render inside a gVisor sandbox (runsc — GKE/AKS Sandbox, kernel 4.19.0-gvisor). opencode starts, the OpenTUI renderer initializes and composes the UI into its buffers, but no frame is ever written to stdout — the alternate screen is never entered and the process then idles. Headless modes (opencode serve, opencode run) work fine.
Regression window (key data)
opencode
renders under gVisor?
1.0.142
✅
@snapshot-proxy (Dec 2025 build)
✅
1.4.17
❌
1.14.51
❌
1.15.10 / 1.15.12 (latest)
❌
Rendering broke between 1.0.142 and 1.4.17 and remains broken through 1.15.x. A git bisect across that range should pinpoint the change.
Bun is ruled out
I built opencode v1.15.10 from source compiled with Bun 1.3.3 (the runtime 1.0.142 ships; confirmed Bun v1.3.3 embedded in the produced binary; AVX2 host, so the non-baseline binary runs cleanly). It still does not render — the same stall as stock 1.15.x. So the embedded Bun version is not the cause; the apparent correlation (older opencode shipping older Bun) was coincidental. Likely culprit is the renderer dep @opentui/core (0.2.10 at 1.14.51 → 0.2.15 at 1.15.10) or opencode's own terminal-setup code.
Diagnosis detail
Captured via a controlling-PTY harness that answers the full probe set (OSC color, DA1/DA2, DSR, XTVERSION, DECRQM, kitty). With OTUI_DEBUG_FFI=1 on a broken build, the FFI trace shows the renderer fully initializes and composes a frame — createRenderer, setupTerminal, getTerminalCapabilities, then render×3, bufferDrawTextBufferView×326, textBufferSetStyledText×399, bufferDrawBox, setCursorPosition — yet nothing reaches the terminal (no \e[?1049h, no frame bytes; only the early OSC palette queries appear). /proc/<pid> shows state S with all threads idle. The same binary renders normally on macOS and under runc.
changed the title [-][Bug]: opencode TUI never renders under gVisor sandbox since the bundled Bun 1.3.11+ bump (pin Bun ≤1.3.10?)[/-][+][Bug]: opencode TUI never renders under gVisor (alt-screen never entered) — regression between 1.0.142 and 1.4.17[/+]on May 29, 2026
Correction: I initially attributed this to the bundled Bun version and asked to pin Bun — that's ruled out. I built v1.15.10 from source with Bun 1.3.3 and it still doesn't render under gVisor, identical to stock. The real regression is internal and lands between 1.0.142 (renders) and 1.4.17 (doesn't), persisting through 1.15.x. Updated the title/body with the regression window and FFI evidence. Likely @opentui/core (0.2.10→0.2.15) or terminal-setup code — a bisect across that range should pinpoint it.
Narrowed it to @opentui/core. I pinned @OpenTui (core/keymap/solid) to several versions in a v1.15.10 build (all compiled with Bun 1.3.3) and render-tested each under gVisor:
0.1.x (e.g. 0.1.59, shipped by opencode 1.0.142): renders — but too old to compile against current opencode (missing APIs like @opentui/keymap/extras).
0.2.10 / 0.2.15 / 0.2.16 / 0.3.0 (latest): all build fine with 1.15.10 but do not render — the renderer initializes and composes a frame, but never enters the alternate screen or writes to stdout; the process then idles.
So the regression is in @opentui/core, introduced at the 0.1→0.2 transition (between 0.1.97 and 0.2.10) and still present in 0.3.0. A bisect of @opentui/core across 0.1.97→0.2.10 should pinpoint it. Bun is not a factor (all builds used Bun 1.3.3).
Reproduced on 1.18.1 under gVisor (GKE Sandbox, guest kernel reports 4.4.0, dmesg: Starting gVisor... / Synthesizing system calls...) — so the regression persists well past 1.15.x. Same binary renders normally under runc with the identical image.
I can add one data point that sharpens the diagnosis: the renderer is not blocked waiting for the terminal — it runs a healthy render loop that reports success, while the composed frames are never written to the tty at all.
Method: pty.fork() harness (real controlling PTY, TERM=xterm-256color) that answers the OSC palette queries and DSR/DA1, with OTUI_DEBUG_FFI=1, then probing /proc/<pid> while the TUI is stalled.
Findings (opencode 1.18.1, linux-x64):
Same signature as OP: the pty master receives exactly 205 bytes total — the early OSC palette/theme queries — and nothing else over the whole session. \e[?1049h never appears.
The FFI trace is fully alive the entire time (~21.7k lines in ~7s): per frame it logs bufferDrawTextBufferView, addToHitGrid, then render(...) returned: 0 and updateStats(...) ticking. The renderer believes it is rendering successfully.
No deadlock: 24 threads, all state S (sleeping), none stuck. This rules out "waiting for probe replies" — answering or not answering the queries makes no difference because the process isn't waiting on the terminal.
/proc/<pid>/io while stalled: syscw ≈ 21,979, which lines up almost 1:1 with the debug trace file's line count (~21,750) — i.e. once you subtract the debug log artifact, the process performs essentially no write() of frame data to any fd.
/proc/<pid>/fd shows only the pty (0/1/2 plus dups), log/db files, timerfds/eventfds and two sockets — there is no alternative fd the frames could be going to. fd 1 is the pty slave in regular blocking mode (flags: 0100002, pos: 0).
The trace head shows setUseThread(<renderer>, 0) — non-threaded mode, so the frame write should happen synchronously inside render(), which nevertheless returns 0 with no bytes emitted.
Interpretation: under gVisor, some terminal-setup/capability check inside @opentui/core (0.2.x native core) appears to take a different branch than on a real kernel and the terminal write path ends up disabled — while compose/render keeps running and reporting success. That is consistent with OP's pin matrix (0.1.x renders, 0.2.10+ doesn't) and explains why answering the full probe set doesn't help.
Headless (opencode run / serve) is unaffected, as OP noted. Happy to run further /proc-level diagnostics under gVisor if useful.
Follow-up on my comment above, with data that narrows — and partly complicates — the picture.
anomalyco/opentui#1124 proposes a concrete root cause: Zig stdlib's buffered writer drives stdout via a pwritev(fd, iov, n, /*offset=*/0) fast path and falls back to writev only on ESPIPE; on the reporter's gVisor (AKS) pwritev on a non-seekable fd returned EINVAL instead, so the fallback never fired and every frame was silently dropped.
I tested that exact syscall matrix in the same GKE Sandbox (gVisor/runsc) environment where I reproduced the 1.18.1 hang, side by side with runc using the identical image (raw syscall(2), fd 1 = pty slave):
call
gVisor (GKE, current runsc)
runc
pwritev(off=0)
ESPIPE
ESPIPE
pwritev(off=-1)
EINVAL
EINVAL
pwritev2(off=0, flags=0)
ESPIPE
ESPIPE
pwritev2(off=-1, flags=0)
succeeds
succeeds
So on current GKE runsc the pwritev/pwritev2 errno behavior is Linux-conformant and identical to runc — the EINVAL mechanism from opentui#1124 does not reproduce here — yet the TUI still fails in exactly the way described in my previous comment (render loop alive and reporting success, zero frame bytes ever written to the pty, no blocked threads).
Implications:
The EINVAL observation in opentui#1124 was likely an older runsc; the errno side appears fixed in current GKE runsc, while the rendering failure persists.
Either there is a second gVisor-sensitive spot in the output path, or the writer disables itself for a different reason even on errno-conformant runsc. In particular, the plain-write(2) change proposed in opentui#1124 may not be sufficient on its own — worth re-verifying against a current runsc.
Next step I can offer: an strace diff of the same binary from process start through the first composed frame, runc vs gVisor, to pinpoint the actually-divergent call on current runsc. Happy to post results here if useful.
Correction to my previous comment — and the actual root cause, now pinned down end to end.
My earlier claim that the errno divergence "does not reproduce on current GKE runsc" was a methodology error on my side: those probes ran through exec without a TTY, so fd 1 was a pipe, not a pty. gVisor's pipes return ESPIPE correctly; its ptys do not. Re-running the same matrix against a real pty (pty.openpty(), target fd = slave, S_ISCHR confirmed):
call (real pty slave)
gVisor (GKE, current runsc)
runc
lseek(0, SEEK_CUR)
ESPIPE
ESPIPE
pwritev(off=0)
EINVAL
ESPIPE
pwritev2(off=0, flags=0)
EINVAL
ESPIPE
pwritev2(off=-1)
succeeds
succeeds
writev
succeeds
succeeds
So anomalyco/opentui#1124 was right, including on current GKE runsc — my earlier "not reproducible here" only held for pipes. Apologies for the noise.
With that, the full causal chain, verified against opentui v0.4.3 and Zig 0.15.2 sources:
StdoutOutput.write (packages/core/src/zig/renderer-output.zig) emits frames through Zig 0.15's std.fs.File.stdout().writer(&buf) and swallows errors: w.writeAll(data) catch {}; w.flush() catch {};
File.Writer defaults to mode = .positional, so the drain path first attempts pwritev(fd, iov, pos) (lib/std/fs/File.zig).
std.posix.pwritev maps ESPIPE → error.Unseekable, while EINVAL falls into else => unexpectedErrno.
The drain falls back to streaming (writev) only on error.Unseekable; any other error is error.WriteFailed with no fallback (the .positional branch of File.Writer.drain).
Linux pty: pwritev → ESPIPE → fallback → frames flow. gVisor pty: pwritev → EINVAL → WriteFailed → swallowed by the catch {}. And since StdoutOutput.write constructs a fresh writer (positional again) on every call, every frame and every setup sequence — including \e[?1049h — is silently dropped, forever.
The early OSC queries that do reach the pty come from the TS/Bun side, which writes stdout through a different path — matching the observation that only those bytes ever appear.
This accounts for every symptom reported here: renderer composes and reports success, zero frame bytes on the pty, no blocked threads, headless modes unaffected, runc/macOS unaffected, and the 1.0.142→1.4.17 regression window (the move to the Zig 0.15 File.Writer-based output path).
Where fixes can land:
gVisor: pwritev/pwritev2 with an offset on a tty should return ESPIPE (Linux semantics), not EINVAL — a conformance report to google/gvisor with the 5-line pty repro above would fix this class for everything built on Zig 0.15 std, not just opentui.
Activity
[-][Bug]: opencode TUI never renders under gVisor sandbox since the bundled Bun 1.3.11+ bump (pin Bun ≤1.3.10?)[/-][+][Bug]: opencode TUI never renders under gVisor (alt-screen never entered) — regression between 1.0.142 and 1.4.17[/+]omarhuss commentedon May 29, 2026
Correction: I initially attributed this to the bundled Bun version and asked to pin Bun — that's ruled out. I built v1.15.10 from source with Bun 1.3.3 and it still doesn't render under gVisor, identical to stock. The real regression is internal and lands between 1.0.142 (renders) and 1.4.17 (doesn't), persisting through 1.15.x. Updated the title/body with the regression window and FFI evidence. Likely
@opentui/core(0.2.10→0.2.15) or terminal-setup code — a bisect across that range should pinpoint it.omarhuss commentedon May 29, 2026
Narrowed it to @opentui/core. I pinned @OpenTui (core/keymap/solid) to several versions in a v1.15.10 build (all compiled with Bun 1.3.3) and render-tested each under gVisor:
@opentui/keymap/extras).So the regression is in @opentui/core, introduced at the 0.1→0.2 transition (between 0.1.97 and 0.2.10) and still present in 0.3.0. A bisect of @opentui/core across 0.1.97→0.2.10 should pinpoint it. Bun is not a factor (all builds used Bun 1.3.3).
SecretSun commentedon Jul 17, 2026
Reproduced on 1.18.1 under gVisor (GKE Sandbox, guest kernel reports
4.4.0, dmesg:Starting gVisor.../Synthesizing system calls...) — so the regression persists well past 1.15.x. Same binary renders normally under runc with the identical image.I can add one data point that sharpens the diagnosis: the renderer is not blocked waiting for the terminal — it runs a healthy render loop that reports success, while the composed frames are never written to the tty at all.
Method:
pty.fork()harness (real controlling PTY,TERM=xterm-256color) that answers the OSC palette queries and DSR/DA1, withOTUI_DEBUG_FFI=1, then probing/proc/<pid>while the TUI is stalled.Findings (opencode 1.18.1, linux-x64):
\e[?1049hnever appears.bufferDrawTextBufferView,addToHitGrid, thenrender(...) returned: 0andupdateStats(...)ticking. The renderer believes it is rendering successfully.S(sleeping), none stuck. This rules out "waiting for probe replies" — answering or not answering the queries makes no difference because the process isn't waiting on the terminal./proc/<pid>/iowhile stalled:syscw ≈ 21,979, which lines up almost 1:1 with the debug trace file's line count (~21,750) — i.e. once you subtract the debug log artifact, the process performs essentially no write() of frame data to any fd./proc/<pid>/fdshows only the pty (0/1/2 plus dups), log/db files, timerfds/eventfds and two sockets — there is no alternative fd the frames could be going to. fd 1 is the pty slave in regular blocking mode (flags: 0100002,pos: 0).setUseThread(<renderer>, 0)— non-threaded mode, so the frame write should happen synchronously insiderender(), which nevertheless returns 0 with no bytes emitted.Interpretation: under gVisor, some terminal-setup/capability check inside
@opentui/core(0.2.x native core) appears to take a different branch than on a real kernel and the terminal write path ends up disabled — while compose/render keeps running and reporting success. That is consistent with OP's pin matrix (0.1.x renders, 0.2.10+ doesn't) and explains why answering the full probe set doesn't help.Headless (
opencode run/serve) is unaffected, as OP noted. Happy to run further/proc-level diagnostics under gVisor if useful.SecretSun commentedon Jul 17, 2026
Follow-up on my comment above, with data that narrows — and partly complicates — the picture.
anomalyco/opentui#1124 proposes a concrete root cause: Zig stdlib's buffered writer drives stdout via a
pwritev(fd, iov, n, /*offset=*/0)fast path and falls back towritevonly onESPIPE; on the reporter's gVisor (AKS)pwritevon a non-seekable fd returnedEINVALinstead, so the fallback never fired and every frame was silently dropped.I tested that exact syscall matrix in the same GKE Sandbox (gVisor/runsc) environment where I reproduced the 1.18.1 hang, side by side with runc using the identical image (raw
syscall(2), fd 1 = pty slave):pwritev(off=0)ESPIPEESPIPEpwritev(off=-1)EINVALEINVALpwritev2(off=0, flags=0)ESPIPEESPIPEpwritev2(off=-1, flags=0)So on current GKE runsc the
pwritev/pwritev2errno behavior is Linux-conformant and identical to runc — theEINVALmechanism from opentui#1124 does not reproduce here — yet the TUI still fails in exactly the way described in my previous comment (render loop alive and reporting success, zero frame bytes ever written to the pty, no blocked threads).Implications:
EINVALobservation in opentui#1124 was likely an older runsc; the errno side appears fixed in current GKE runsc, while the rendering failure persists.write(2)change proposed in opentui#1124 may not be sufficient on its own — worth re-verifying against a current runsc.SecretSun commentedon Jul 17, 2026
Correction to my previous comment — and the actual root cause, now pinned down end to end.
My earlier claim that the errno divergence "does not reproduce on current GKE runsc" was a methodology error on my side: those probes ran through
execwithout a TTY, so fd 1 was a pipe, not a pty. gVisor's pipes returnESPIPEcorrectly; its ptys do not. Re-running the same matrix against a real pty (pty.openpty(), target fd = slave,S_ISCHRconfirmed):lseek(0, SEEK_CUR)ESPIPEESPIPEpwritev(off=0)EINVALESPIPEpwritev2(off=0, flags=0)EINVALESPIPEpwritev2(off=-1)writevSo anomalyco/opentui#1124 was right, including on current GKE runsc — my earlier "not reproducible here" only held for pipes. Apologies for the noise.
With that, the full causal chain, verified against opentui v0.4.3 and Zig 0.15.2 sources:
StdoutOutput.write(packages/core/src/zig/renderer-output.zig) emits frames through Zig 0.15'sstd.fs.File.stdout().writer(&buf)and swallows errors:w.writeAll(data) catch {}; w.flush() catch {};File.Writerdefaults tomode = .positional, so the drain path first attemptspwritev(fd, iov, pos)(lib/std/fs/File.zig).std.posix.pwritevmapsESPIPE → error.Unseekable, whileEINVALfalls intoelse => unexpectedErrno.writev) only onerror.Unseekable; any other error iserror.WriteFailedwith no fallback (the.positionalbranch ofFile.Writer.drain).pwritev→ESPIPE→ fallback → frames flow. gVisor pty:pwritev→EINVAL→WriteFailed→ swallowed by thecatch {}. And sinceStdoutOutput.writeconstructs a fresh writer (positional again) on every call, every frame and every setup sequence — including\e[?1049h— is silently dropped, forever.This accounts for every symptom reported here: renderer composes and reports success, zero frame bytes on the pty, no blocked threads, headless modes unaffected, runc/macOS unaffected, and the 1.0.142→1.4.17 regression window (the move to the Zig 0.15
File.Writer-based output path).Where fixes can land:
pwritev/pwritev2with an offset on a tty should returnESPIPE(Linux semantics), notEINVAL— a conformance report to google/gvisor with the 5-line pty repro above would fix this class for everything built on Zig 0.15 std, not just opentui.write(2)change is hereby confirmed sufficient — it bypasses the positional path entirely.@opentui/coreonce Feature Request: Respect OPENCODE_INSTALL_DIR, XDG_BIN_DIR, $HOME/bin, and Fallback for Installation Path #1124 lands resolves the TUI under gVisor even before any gVisor-side fix ships.Pin opencode to 1.0.142 so its TUI renders under gVisor
github-actions commentedon Sep 17, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.
milantracy commentedon Oct 5, 2026
@simonklee I am from the gVisor community, I am bit confused by the comments, here, is the issue fixed?