Skip to content

fix(tui): only the renderer may write to the terminal while the pane is up - #408

Merged
agjs merged 1 commit into
mainfrom
fix/mcp-stderr-leak
Sep 29, 2026
Merged

agjs merged 1 commit into
mainfrom
fix/mcp-stderr-leak

Conversation

@agjs

@agjs agjs commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

The bug

Calling any stdio MCP tool painted the server's own logging over the UI. mcp-remote (the bridge Linear, Notion and Sentry use) logs every message as [pid] [Local→Remote] tools/call, and StdioMcpTransport spawned servers with stderr: "inherit", so those bytes went straight to the terminal. It affects every stdio MCP server; HTTP servers (Twenty) have no child process.

This is the third leak of this class: jsdom's CSS warnings, library console.error, and now MCP stderr. Each earlier fix patched the site that showed it. This PR closes the class.

The fix, in three layers

1. MCP stderr is captured, not inherited. StderrTail drains each server's stderr. Every line goes to the debug trace (TSFORGE_TRACE), and the last lines explain a server that dies: connection closed unexpectedly: fatal: …. That includes stderr arriving just after stdout closes, which is common for a dying bridge.

2. Runtime: TerminalGuard. It engages exactly while the pane holds the screen (a new PaneScreen activity hook) and releases on leave or exit.

  • The renderer writes through a handle bound to the real stdout.
  • Any other process.stdout.write becomes transcript text. process.stderr.write and console.* go to the trace. Bun's console writes natively and bypasses the stream methods, so it's taken over separately.
  • This also fixes the harness's own raw writes under the pane (/gate, /plan, /model notices were painting over the frame).
  • Deliberate raw writers use the owned handle explicitly: editor control codes, the scaffold wizard, the recipe picker.
  • Release restores the exact original functions, not bound copies.

3. CI: a static check plus an end-to-end check.

  • tests/terminal-ownership.test.ts parses src/ with the TS compiler. It fails on any Bun.spawn/spawnSync that inherits stdout/stderr or omits stderr (Bun's default is "inherit"), and on any child_process stdio: "inherit". It names the line and the fix.
  • scripts/e2e-terminal-ownership-pty.py (added to e2e:pty) drives the real UI in a PTY. A noisy stdio MCP server is called by the model mid-session, and the test fails if one byte of its stderr reaches the terminal, or if /gate's line is painted raw.

The rule is written into AGENTS.md house rules.

Proof the guards work

Each guard was run against the bug it prevents:

Reintroduced Caught by
stderr: "inherit" in the MCP transport static check (mcp/stdio-transport.ts:53 … pipe it) and PTY test (found: ['Local→Remote', '[4242]'])
guard never engages PTY test (/gate's confirmation was not written raw over the frame)
no wait for late stderr mcp-stderr.test.ts (shell server that closes stdout first)
guard restores bound copies terminal-guard.test.ts (it caught this during development)

Against the real Linear mcp-remote bridge: 0 bytes reached the terminal, and all 29 log lines went to the trace.

bun run ci:local: 6329 pass, 0 fail. Every PTY suite passes, including the wizard, config, editor and scaffold flows that use raw writes.

…is up

stdio MCP servers were spawned with stderr: "inherit", so their own logging
painted straight over the UI — mcp-remote (the bridge for Linear, Notion,
Sentry) logs every call as `[pid] [Local→Remote] tools/call`. It is the third
leak of this kind (jsdom CSS warnings, library console.error), each fixed
where it showed up. This closes the class:

- MCP: server stderr is piped and drained (StderrTail) — every line to the
  debug trace (TSFORGE_TRACE); the last lines explain a server that dies
  ("connection closed unexpectedly: fatal: …"), including stderr that lands
  just after stdout closes.
- Runtime: TerminalGuard engages exactly while the pane holds the screen.
  The renderer writes through a handle bound to the real stdout; any other
  process.stdout.write becomes transcript text, process.stderr.write and
  console.* (Bun's console bypasses the streams) go to the trace. Fixes the
  harness's own raw writes under the pane too (`/gate`, `/plan`, /model's
  notices). Deliberate raw writers (editor control codes, scaffold wizard,
  recipe picker) use the owned handle explicitly.
- Static (CI): tests/terminal-ownership.test.ts parses src/ and fails on any
  Bun.spawn/spawnSync that inherits or omits stderr (Bun's default is
  "inherit") and any child_process stdio: "inherit".
- End to end: scripts/e2e-terminal-ownership-pty.py drives the real UI with
  a noisy stdio MCP server called mid-session and fails if one byte of its
  stderr reaches the terminal, or if /gate's line is painted raw.

Each guard was checked against the bug it prevents: reverting to
stderr: "inherit" fails the static check (naming the line) and the PTY test;
disabling the guard fails the PTY test; unit tests caught the guard restoring
bound copies instead of the original writers.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
tsforge 87c7474 Commit Preview URL

Branch Preview URL
Sep 29 2026, 01:00 PM

@agjs
agjs merged commit 8735534 into main Sep 29, 2026
9 checks passed
@agjs
agjs deleted the fix/mcp-stderr-leak branch September 29, 2026 13:10
This was referenced Sep 29, 2026
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.

1 participant