Skip to content

use plain write(2) for terminal stdout instead of stdlib's pwritev writer#1124

Draft
adamnroman wants to merge 2 commits into
anomalyco:mainfrom
adamnroman:debug/0.2.16-pwritev-workaround
Draft

use plain write(2) for terminal stdout instead of stdlib's pwritev writer#1124
adamnroman wants to merge 2 commits into
anomalyco:mainfrom
adamnroman:debug/0.2.16-pwritev-workaround

Conversation

@adamnroman

@adamnroman adamnroman commented May 29, 2026

Copy link
Copy Markdown

Summary

StdoutOutput.write in renderer-output.zig drives stdout through std.fs.File.stdout().writer(), whose buffered Writer interface attempts pwritev(fd, iov, n, /*offset=*/0) as a fast path before falling back to writev on ESPIPE. Stdout connected to a TTY is not seekable, so the pwritev fast path collapses to writev inside the kernel on Linux and gains nothing. It is also where things go wrong under gVisor (runsc), which returns EINVAL instead of ESPIPE for pwritev on a non-seekable fd. The ESPIPE fallback never fires and every render frame is silently dropped inside a gVisor sandbox.

This PR replaces the buffered Writer interface with std.fs.File.stdout().writeAll(data), which loops over posix.write on Unix and WriteFile on Windows (no pwritev path on either), and removes the now-unused stdoutBuffer field.

Repro

Run any OpenTUI program (for example opencode under @opentui/core@0.3.0) inside a runsc-backed Kubernetes pod (AKS gVisor RuntimeClass) inside a tmux pane. Expected: full TUI. Observed: just the shell prompt.

strace on the binary shows the first render emit as:

pwritev(1, [iov], 1, 0) = -1 EINVAL (Invalid argument)

and nothing after it. The same setup under runc returns ESPIPE and the writev fallback succeeds.

Why this change is correct

Stdout connected to a terminal is not seekable. pwritev(fd, iov, n, /*offset=*/0) on a non-seekable fd is a no-op at best and a portability hazard at worst. On Linux it collapses to writev inside the kernel, so the fast path buys nothing. On gVisor it returns EINVAL instead of the POSIX-expected ESPIPE, which defeats pwritev-vs-writev fallbacks (Bun, Zig stdlib) and silently drops every render frame inside a gVisor sandbox.

Other TUI projects, including ones with hand-rolled renderers, all emit their frames with plain write(2)/WriteFile or the language-native stream wrapper on top of it. Three direct references:

ncurses does the terminal byte-push with write(2) in _nc_flush and _nc_outch:

earendil-works/pi has its own from-scratch differential renderer (pi-tui). Its ProcessTerminal.write is the single sink the renderer drives, and it uses process.stdout.write:

OpenAI codex CLI delegates terminal rendering to ratatui + crossterm. The dependency declarations:

Through that stack, codex's render path is ratatui::CrosstermBackend::flush -> inner W: Write (io::stdout()) -> write(2). No pwrite* is reached on the rendered-bytes path.

This change routes OpenTUI's StdoutOutput the same way.

Cross-platform note

std.fs.File.stdout() resolves to the correct handle on every supported platform (verified against Zig 0.15.2 stdlib in lib/std/fs/File.zig):

pub fn stdout() File {
    return .{ .handle = if (is_windows) windows.peb().ProcessParameters.hStdOutput else posix.STDOUT_FILENO };
}

File.write then routes to windows.WriteFile or posix.write depending on platform. File.writeAll loops over File.write. Neither touches pwrite* on any platform.

Notes

  • StdoutOutput becomes a zero-sized struct after the stdoutBuffer field is removed. The existing .{} initializer in BufferedBackend.createStdout still works on a zero-field struct.
  • The two other File.stdout() sites in the repo (bench-utils.zig, bench.zig) are benchmark harness output, not the renderer's user-facing path; intentionally untouched.

Testing

  • zig build -Dtarget=x86_64-linux-gnu.2.17 -Doptimize=ReleaseFast against the patched tree produces a working libopentui.so, cross-compiled successfully.
  • The patched .so was swapped into a Bun-compiled opencode binary built against @opentui/core@0.3.0 and deployed to an AKS pod running the gVisor RuntimeClass inside tmux. TUI renders correctly. Same binary continues to render correctly on runc (Minikube) and on macOS/Linux laptops.
  • Windows path not exercised in our environment, but follows the standard cross-platform std.fs.File.write/WriteFile route used by the rest of OpenTUI.
  • Native test suite (zig build test --summary all) on native-native-musl fails to compile in our build environment, but this failure also reproduces on a clean checkout of main with no changes; it is pre-existing and unrelated to this patch.

No behavior change on Linux/macOS/Windows. Fixes silently-dropped rendering on gVisor.

@adamnroman adamnroman force-pushed the debug/0.2.16-pwritev-workaround branch from 99c1d0e to 4e24770 Compare May 29, 2026 00:59
@kommander

Copy link
Copy Markdown
Collaborator

I assume this breaks on windows...

@adamnroman adamnroman marked this pull request as draft May 29, 2026 14:12
StdoutOutput.write in renderer-output.zig drove stdout through
std.fs.File.stdout().writer(), which attempts pwritev(fd, iov, n,
/*offset=*/0) as a fast path before falling back to writev on ESPIPE.
Stdout connected to a TTY is not seekable, so the pwritev fast path
collapses to writev inside the kernel on Linux and gains nothing. It is
also where things go wrong under gVisor (runsc), which returns EINVAL
instead of ESPIPE for pwritev on a non-seekable fd. The ESPIPE fallback
therefore never fires and every render frame is silently dropped inside
a gVisor sandbox.

Replace the buffered writer interface with std.fs.File.stdout().writeAll,
which is cross-platform (posix.write on Unix, WriteFile on Windows) and
does not take the pwritev fast path. The 4096-byte stdoutBuffer field is
no longer needed and is removed; the struct becomes zero-sized but is
still allocated via the existing ownership scheme in
BufferedBackend.createStdout (the `.{}` initializer still works on a
zero-field struct).

Other TUI projects with hand-rolled renderers do the same: ncurses uses
write(2) in _nc_flush (ncurses/tinfo/lib_tputs.c:139) and _nc_outch
(ncurses/tinfo/lib_tputs.c:194, :199); earendil-works/pi routes its
differential renderer through ProcessTerminal.write, which calls
process.stdout.write (packages/tui/src/terminal.ts:496-497); OpenAI's
codex CLI delegates to ratatui + crossterm (codex-rs/Cargo.toml:274,
:338), which write through io::Write on io::stdout(). None reach for
pwrite/pwritev.

No behavior change on Linux/macOS/Windows. Fixes silently-dropped
rendering on gVisor.
@adamnroman adamnroman force-pushed the debug/0.2.16-pwritev-workaround branch from 4e24770 to 9ef2313 Compare May 29, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants