Skip to content

fix(terminal): stop replayed output from echoing escape-sequence replies into the shell - #43

Merged
clintberry merged 3 commits into
mainfrom
fix/terminal-replay-query-echo
Jul 28, 2026
Merged

fix(terminal): stop replayed output from echoing escape-sequence replies into the shell#43
clintberry merged 3 commits into
mainfrom
fix/terminal-replay-query-echo

Conversation

@clintberry

Copy link
Copy Markdown
Contributor

Problem

Every switch to the Terminal tab pasted garbage at the shell prompt:

11;rgb:0d0d/1111/1717R11;rgb:0d0d/1111/1717R

0d0d/1111/1717 is exactly the #0d1117 background configured in TerminalView.tsx. The bytes never came from the workspace — the browser generated them.

Root cause

A response loop between the replay buffer and xterm.js:

  1. Something in the remote shell emitted a terminal query — ESC ] 11 ; ? ST, "what is your background colour?". Prompt frameworks, ls colour probes, and TUIs all do this.
  2. Session.readLoop copies raw PTY bytes into the 100 KiB replay buffer. Queries get stored verbatim alongside ordinary output.
  3. CenterPanel renders <TerminalView /> conditionally on the active tab, so every switch unmounts and remounts it — a fresh xterm and WebSocket each time.
  4. AddClient replays the whole buffer into that fresh instance.
  5. xterm can't distinguish a replayed query from a live one, so it answers, and the answer went straight to PTY stdin.
  6. bash is at a prompt with readline active. It discards the unrecognised ESC ] introducer and inserts the printable remainder into the line buffer, which is then echoed.

It recurred on every switch because the query never leaves the replay buffer. It appeared doubled because two replies land per remount — React StrictMode double-invokes the effect in dev.

This is cosmetic-looking but is real stdin injection: press Enter without noticing and you run 11;rgb:0d0d/1111/1717R as a command.

Fix

The class is broader than OSC 11 — xterm also auto-answers DA1, DA2, DSR/CPR, XTVERSION, and XTGETTCAP. Any of them sitting in a replay buffer produces the same corruption, so the fix closes the class rather than one instance.

Two new server→client frames let the client tell history from live output:

Frame Direction Meaning
0x00 both Live terminal I/O (unchanged)
0x01 client → server Resize (unchanged)
0x02 server → client New. Replayed historical output
0x03 server → client New. Replay complete; responses may resume

The client renders 0x02 exactly like 0x00, but drops everything onData produces until replay is over. No escape-sequence parsing, no denylist to go stale.

Design decisions

The boundary marker is emitted under the session lock, before live registration. AddClient now takes a Client interface (live write / replay write / replay complete) so all three steps happen in one hold of s.mu. If the marker were sent after the lock was released, live output could interleave ahead of it and the client would suppress the response to a genuinely live query.

ReplayComplete is sent unconditionally, including on an empty buffer. The first client on a fresh PTY has nothing to replay but still needs the marker — otherwise the most common first-use path is muted for the full fallback window.

The client unmutes on xterm's write callback, not on frame arrival. This is the subtle one. term.write() is asynchronous — xterm buffers input and parses it on a later tick. Setting the flag the instant 0x03 arrives leaves the bug fully intact, because the replayed queries haven't been parsed yet and their replies fire afterwards. Writing a zero-length payload and unmuting inside its completion callback guarantees every preceding replay chunk has been parsed first. Verified against xterm's actual implementation, not just its typings: write() has no length guard, and _innerWrite fires each entry's callback in FIFO order after parsing it.

A fallback timer covers an old server, and any 0x02 disarms it. Without the timer, a new client against an old binary would be permanently unable to type — a worse failure than the bug being fixed. But a fixed timeout alone is wrong: a slow link delivering a large replay could trip it and open the gate mid-replay, which is exactly what the gate exists to prevent. Since a 0x02 frame proves the server speaks this protocol, its arrival cancels the fallback and the client waits for the real boundary.

Deploy skew

The frontend is served by the same Go binary, so the window is limited to a browser tab left open across a deploy.

  • Old client, new server0x02 frames are ignored, so the terminal reconnects blank. Degradation, not corruption; a refresh fixes it.
  • New client, old server — no 0x02/0x03 ever arrives, the fallback fires, and behaviour is exactly what it is today. This is why the fallback isn't optional.

Test plan

79 frontend tests (12 new) and the backend suite pass; backend verified under -race across repeated runs. tsc -b clean, eslint 0 errors.

New coverage:

  • TerminalView.test.tsx — replays a literal ESC ] 11 ; ? BEL and asserts nothing containing rgb: is ever sent; same for a replayed DSR producing no CPR reply, proving the gate is class-wide.
  • Ordering guard — 0x03 arriving while replay is still unparsed must not unmute until the write callback fires. This is the regression a naive implementation ships broken.
  • Live queries after the boundary are still answered, so a TUI started post-connect gets its colour/capability replies.
  • Fallback fires when no boundary ever arrives; a 0x02 frame disarms it; resize frames still flow while muted; the timer is cleared on unmount.
  • manager_test.go — replay/marker/registration ordering, unconditional marker on an empty buffer, no registration when either write fails, a second client's replay including output since the first attached, and the existing ring-buffer trim behaviour.

The three guards were mutation-tested: forcing the gate open, unmuting synchronously on 0x03, and dropping the 0x02 disarm each fail exactly the test written for them.

Post-Deploy Monitoring & Validation

No additional operational monitoring required — no backend data, auth, or schema impact, and the change is confined to the terminal WebSocket framing.

Validate manually after deploy:

  • Open a session's Terminal tab, switch away and back several times. The prompt stays clean and prior scrollback still renders.
  • Type immediately after switching — keystrokes register.
  • Start a TUI (vim, htop). It renders with correct colours, proving live queries are still answered.
  • Failure signal: any escape-sequence text appearing at the prompt on tab switch, or a terminal that renders but won't accept input (would indicate the boundary marker never arrives). Rollback is a straight revert — the frames are additive and old clients ignore them.

Known residuals / follow-ups

  • Not verified end-to-end in a browser against a live DevPod workspace. The tests reproduce the exact reported byte sequence, but a real switch-to-tab confirmation hasn't been done.
  • The trailing R in the original report is still unconfirmed empirically — most likely a second replayed query (DSR/CPR). It's suppressed regardless of source, and there's a test covering that case.
  • Keystrokes typed during the replay window are dropped. The window is one bounded write over a local WebSocket plus one xterm parse tick. Strictly better than today, where those keystrokes land in a line buffer already polluted with escape-sequence garbage.
  • If 0x02 succeeds and 0x03 then fails, the terminal stays muted. That path also means the client was never registered for live output, so the socket is already dead.
  • Deferred: keeping TerminalView mounted across tab switches (CSS-hide instead of conditional render) would remove the remount entirely — preserves scrollback, drops a reconnect per switch. A real UX win, but not a fix, since reload and multi-tab attach still replay.

Plan: docs/plans/2026-07-28-002-fix-terminal-replay-query-echo-plan.md


Compound Engineering
Claude Code

The replay buffer stores raw PTY bytes, including any terminal queries
the remote shell emitted earlier (OSC 11 background-colour, DA, DSR).
Every fresh xterm instance received those queries as if they were live
and answered them, writing the reply back into PTY stdin where the
shell echoed it as garbage at the prompt.

Add 0x02 (replay chunk) and 0x03 (replay complete) server-to-client
frames so the client can tell history from live output. Session.AddClient
now takes a Client interface and emits the replay, the boundary marker,
and the live registration under a single hold of the session lock, so
live output cannot interleave ahead of the marker. The marker is sent
unconditionally — a client with an empty replay buffer needs it too.
Switching to the Terminal tab remounts TerminalView, so a fresh xterm
receives the server's replay buffer and answers any terminal queries in
it. The reply was written straight to PTY stdin, where the shell echoed
it — pasting `11;rgb:0d0d/1111/1717` at the prompt on every switch.

Gate onData behind a replay flag, released only when the 0x03 boundary
frame arrives AND xterm has parsed the replayed bytes. The second half
matters: term.write() is async, so unmuting on frame arrival alone lets
replay-triggered replies escape during the pending parse. A zero-length
write's completion callback lands behind the queued replay chunks and
gives the correct release point.

A fallback timer covers an older server that never sends 0x03, so the
terminal can't be muted permanently. Any 0x02 frame disarms it, since a
slow link delivering a large replay would otherwise trip the timer and
open the gate mid-replay.
Marked completed — both implementation units landed in this branch.
@clintberry
clintberry merged commit 1e60a5f into main Jul 28, 2026
2 checks passed
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