Skip to content

fix(tui): contain stray stdio writes - #43065

Closed
spiatrenka wants to merge 2 commits into
anomalyco:devfrom
spiatrenka:capture-plugin-stderr
Closed

spiatrenka wants to merge 2 commits into
anomalyco:devfrom
spiatrenka:capture-plugin-stderr

Conversation

@spiatrenka

@spiatrenka spiatrenka commented Aug 17, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #43064

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

If a plugin or the server logs with process.stderr.write, the text goes straight to the terminal and paints over the TUI. The screen stays broken until the next full repaint. opentui captures console.*, but nothing captures raw stream writes, so there was no way to stop this from the app side.

The TUI runs on the main thread and the server runs in a worker. Each has its own process object but they share the same terminal, so both need covering. This swaps stdout.write/stderr.write for the renderer's lifetime and sends that output to log/stdio-main.log and log/stdio-worker.log instead, restoring the originals on teardown so crash output still reaches the terminal.

Frames are unaffected because opentui writes them natively, below the JS wrappers. I confirmed that by installing the guard before createCliRenderer and checking it captured no frame bytes while rendering stayed correct.

This only covers JS-level writes. Native code writing to the fd directly (the ALSA block in #41763 / #41770) still gets through and would need dup2.

How did you verify your code works?

--print-logs is the easiest repro, since the log writer uses process.stderr.write (packages/core/src/observability/logging.ts:54):

bun dev --print-logs --log-level DEBUG

Before, the log lines overwrite the composer. After, the frame is clean and the same lines land in ~/.local/share/opencode/log/stdio-worker.log.

Also: 204 tests and typecheck pass in packages/tui, typecheck passes in packages/opencode.

Screenshots / recordings

Before, on dev:

before

After, on this branch:

after

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@spiatrenka

Copy link
Copy Markdown
Author

Scope note: this guards the JavaScript process.stdout/stderr.write wrappers, so it contains writes from plugins, the worker's server logging, and --print-logs.

It does not contain writes made by native code straight to the file descriptor — e.g. the ALSA diagnostics block in #41763 / #41770. Same class of bug, but that needs fd-level redirection (dup2), not a JS wrapper.

The same mechanism is what makes this safe: opentui writes frames natively, so frame output never passes through these wrappers. Verified — a guard installed before createCliRenderer captured zero frame bytes while rendering stayed correct.

@spiatrenka
spiatrenka force-pushed the capture-plugin-stderr branch from 7ad6e4d to 9e4da24 Compare August 17, 2026 18:52
@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

  • packages/tui/src/util/stdio-guard.ts:47 — fchmodSync and fstatSync run OUTSIDE the try/catch protecting openSync, so a log directory whose filesystem rejects permission changes (FAT/exfat mounts, some network shares) throws during worker.ts module initialization and kills the entire TUI backend before it starts — move the post-open syscalls into the same guarded block with the existing blackhole-guard fallback, and add a test simulating the chmod failure path.
  • packages/tui/src/util/stdio-guard.ts:53 — content.subarray(0, remaining) can slice through a multi-byte UTF-8 sequence, leaving a corrupted final character at the cap boundary; trimming to the last complete codepoint is cheap and keeps the containment logs greppable.
  • packages/tui/src/util/stdio-guard.ts:27 — The wrapper unconditionally returns true, suppressing the backpressure signal real streams give when their buffer is full; libraries that throttle on the return value will happily flood a slow file sink — worth at least a comment documenting that suppression as intentional for the TUI lifetime.
  • packages/tui/test/util/stdio-guard.test.ts:150 — Excellent breadth (sentinel isolation, both callback positions, throwing sinks, truncation math); one missing pair: interleaved stdout/stderr ordering across the two wrapped streams should be preserved in the file, and the worker.ts module-scope installation should have a smoke assertion that importing the worker doesn't throw when Global.Path.log is unwritable — that is exactly the crash class the first finding describes.

— AI code review (automated)

@Enough1122

Copy link
Copy Markdown

AI code review — automated review for reference; please use your judgment.

  • packages/tui/src/util/stdio-guard.ts — well-built primitive: exact-original restoration, callback handling in both argument positions, sink isolation via try/catch, 0600 perms enforced post-open (umask-proof), and a hard byte cap so a chatty plugin can't fill the disk.
  • packages/tui/src/app.tsx:194 — smart placement detail in the comment: releasing the guard before the fatal-error print means crash output still reaches a restored stderr. Worth keeping that invariant in mind if scope structure changes.
  • Nit: two concurrent opencode instances share stdio-main.log with truncate: true, so instance B startup wipes instance A's captured output. A pid-suffixed filename would avoid the clobbering.
  • packages/opencode/src/cli/tui/worker.ts:14 — worker realm gets its own log file, good separation; if rpc.dispose never runs (hard crash) the fd stays open until process exit, which is fine but worth knowing.
  • Test suite covers the failure modes I'd worry about (throwing sink, unopenable file, limit accounting across installs) — excellent coverage.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI is overwritten by raw plugin or worker output

2 participants