Skip to content

[Bug]: opencode TUI never renders under gVisor (alt-screen never entered) — regression between 1.0.142 and 1.4.17 #29802

Closed as not planned

Description

@omarhuss

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.

Environment

  • gVisor / runsc (GKE/AKS Sandbox), kernel 4.19.0-gvisor, x86_64
  • opencode 1.15.10 and 1.15.12; real controlling PTY; TERM=xterm-256color

Repro

  1. Run opencode attached to a real PTY in a gVisor-sandboxed Linux container.
  2. Observe: OSC palette/theme queries are emitted, then a blank screen (no alternate screen, no UI), and the process idles.
  3. Compare [email protected] in the same environment → renders normally.

Happy to provide full FFI traces.

Activity

  1. 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
  2. omarhuss commented on May 29, 2026

    @omarhuss
    Author

    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.

  3. omarhuss commented on May 29, 2026

    @omarhuss
    Author

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

  4. SecretSun commented on Jul 17, 2026

    @SecretSun

    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):

    1. 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.
    2. 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.
    3. 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.
    4. /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.
    5. /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).
    6. 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.

  5. SecretSun commented on Jul 17, 2026

    @SecretSun

    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.
  6. SecretSun commented on Jul 17, 2026

    @SecretSun

    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:

    1. 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 {};
    2. File.Writer defaults to mode = .positional, so the drain path first attempts pwritev(fd, iov, pos) (lib/std/fs/File.zig).
    3. std.posix.pwritev maps ESPIPE → error.Unseekable, while EINVAL falls into else => unexpectedErrno.
    4. 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).
    5. 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.
    6. 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:

  7. github-actions commented on Sep 17, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

  8. milantracy commented on Oct 5, 2026

    @milantracy

    @simonklee I am from the gVisor community, I am bit confused by the comments, here, is the issue fixed?

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

Metadata

Metadata

Assignees

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