Skip to content

fix(terminal): stop stray newline injection on session switch (portable-pty EOF-on-drop) + connect at measured size#28

Merged
miguelrisero merged 11 commits into
mainfrom
fix/terminal-resize-newline
Jul 8, 2026
Merged

fix(terminal): stop stray newline injection on session switch (portable-pty EOF-on-drop) + connect at measured size#28
miguelrisero merged 11 commits into
mainfrom
fix/terminal-resize-newline

Conversation

@miguelrisero

@miguelrisero miguelrisero commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Problem

Switching CLI sessions (or otherwise tearing down a terminal WebSocket) injected a stray newline into the newly-attached pane. Live byte-level instrumentation found this has two independent root causes, and the resize fix alone did not stop it.

Primary: portable-pty writes \n + EOF into the tty when the writer drops

portable-pty 0.8.1's UnixMasterWriter::drop (registry source unix.rs:351-363) reads the master's termios and, if c_cc[VEOF] is non-zero, writes [b'\n', VEOF] — LF + Ctrl-D — into the pty. This is the crate's documented "dropping the writer sends EOF to the slave" behavior.

In local-deployment/src/pty.rs, close_session() SIGHUPs the tmux client and drops the PtySession; the writer's Drop then fires that 2-byte write into the tmux client's tty. The tmux server reads it as client keyboard input and forwards it as keystrokes to the active pane (confirmed in tmux -vv server logs: keys are 2 (\n\004) … writing key 0xa (C-j) … 0x4 (C-d)):

  • Claude TUI composer: 0x0a inserts a stray newline (never submits); 0x04 is a no-op → the visible bug.
  • Bare shell pane: Enter + Ctrl-D exits the shell and kills the persistent vk_ session — the more severe consequence.

Reproduced on 4-6 of 6 teardowns with correctly-sized attaches. SSH sessions are immune (no portable-pty in that path).

Secondary: wrong-size attach reflow

A CLI attach at an unmeasured/default 80×24 grid makes claude reflow and stack blank lines on the follow-up resize (the original PR content).

What changed

Primary fix — disarm VEOF at teardown (pty.rs):

  • New disarm_master_eof(&dyn MasterPty) clears c_cc[VEOF] on the master fd via tcsetattr. The writer holds a dup(2) of the master fd and termios is shared across dup'd fds, so portable-pty's if eot != 0 guard then sees 0 and skips the injection.
  • Applied via impl Drop for PtySession, which runs before the writer field drops. This is the single point every teardown path funnels through — close_session (map remove + drop), process/service shutdown (sessions HashMap dropping), and any future cleanup path — so the disarm can never be forgotten.
  • Teardown-only by construction: Drop never fires at creation, so a live shell-mode terminal keeps working Ctrl-D/VEOF semantics; VEOF is only cleared as the session is being torn down.
  • Adds libc = "0.2" as a direct dependency of local-deployment (already in the lock tree).

Secondary fix — connect at measured size + rebuild WS endpoint on reconnect (prior commit): never connect xterm at an unmeasured size; rebuild the WS endpoint on reconnect. A tracing::warn! tripwire fires if a CLI attach ever arrives at default 80×24.

FIX 4 — attach-window input tripwire (terminal.rs): for the first 2 seconds after attach OR first 128 input bytes (whichever first), tracing::info! a hex dump of client→pty bytes tagged with the pty session name (vk_<uuid> in CLI mode), per-attach id, and ms-since-attach. Strictly bounded (no allocation growth after the window), permanently safe to leave on. Purpose: production canary that confirms the kill is gone and catches any future client-side injector by distinguishing client-origin bytes from server-origin ones.

How verified (honest)

  • VEOF fix behavior was validated in a live instrumented byte-hunting repro (separate local-only lab, not shipped): 0/10 injections across WS teardown cycles after the fix; 10/10 byte-stable composer with a real browser + claude TUI; typing and Ctrl-C unaffected; the shell-pane kill no longer occurs.
  • This PR's own verification: new unit test disarm_master_eof_zeroes_veof (opens a pty via the same openpty path, asserts a fresh master has VEOF != 0 — guarding against a silent no-op — then asserts VEOF == 0 after the helper); cargo test -p local-deployment 57 passed, cargo test -p server 36 passed; cargo clippy -p local-deployment -p server clean; cargo fmt clean.
  • Not re-run in this PR: the full live browser/tmux repro (that evidence is from the instrumented experiment above).

Risks

  • disarm_master_eof is Unix-only (#[cfg(unix)], no-op elsewhere); the injection path only exists on Unix ptys.
  • The tripwire logs input bytes at info for a bounded post-attach window; bytes are hex, bounded to 128, and the window closes after 2s, so there is no unbounded logging or allocation.

ship-it battery

Final ship-it review battery (round 2) completed on head 783a2543. Phases before this checkpoint (council R1, /simplify, codex+xhigh, round-2 FIX BATCH) landed commits through 8845db3c.

Council round 2 — mixed engine (Codex panel + 1 Claude cross-check), on 8845db3c

Panel: security-analyst, performance-engineer, dx-advocate, maintainability-architect.

Persona Engine Verdict
security-analyst Codex APPROVE
performance-engineer Codex APPROVE
maintainability-architect Codex APPROVE
maintainability-architect Claude (cross-check) APPROVE
dx-advocate Codex BLOCK (P1)

Backend concerns all cleared by the panel: Drop-ordering guarantee (VEOF disarm runs before field drops; outliving Arc<writer> clones covered via shared device termios), unsafe termios correctness (POD init, checked syscalls, non-blocking TCSANOW, no panic in Drop), teardown funneling (no fallible step strands a VEOF-armed writer; close_session drops outside the lock), and tripwire boundedness + redaction (2s/128-byte latch, all 256 byte values masked except C0+DEL).

Backstop — codex xhigh (CodeRabbit substitute)

CodeRabbit CLI cannot authenticate non-interactively in this environment (deterministic environment_unsupported), so codex xhigh is the substitute for every sweep below.

  • Sweep 1 (on 8845db3c): DO-NOT-SHIP — one P1, independently matching the dx-advocate finding (cross-engine convergence).
  • Sweep 2 (on e8223aa1, after fix P1-a): DO-NOT-SHIP — confirmed P1-a closed, surfaced the sibling P1-b.
  • Sweep 3 (on 783a2543, after fix P1-b): SHIP — "Findings: none. No P0/P1 defects found." Both races closed; no new blocker (false visible-attach discard, double reconnect, hidden-live teardown, VEOF disarm, tripwire, tmux attach all checked).

Findings fixed (2 × P1, same wrong-session-bind class)

P1-a — stale CONNECTING socket could bind the pane to the previous session (e8223aa1). The endpoint-epoch validation ran only right after await openLocalApiWebSocket() resolved, but the default transport resolves with a still-CONNECTING socket. A session switch landing after that socket was registered but before onopen only bumped the epoch (the createTerminalConnection occupied path) without closing/revalidating the connecting socket, so it could become live bound to the previous session on a first-attach / post-reap create. Fix: re-validate at onopen (not just after the open resolves) via a new pure, unit-tested terminalAttemptIsStale() helper that also DRYs the existing post-await check; on stale, latch a superseded flag, drop the tab's connection entry iff it is this socket, close it, and re-kick through the single timer without spending the retry budget. The socket's own onmessage/onclose go inert once superseded.

P1-b — gated-child variant (783a2543). The onopen re-validation gated on the epoch delta, but the epoch is bumped only by a MOUNTED terminal child; when CliMainPane stops rendering XTermInstance (executorRunning / !sessionsReady) during the CONNECTING window and the session changes while gated, no effect bumps the epoch, so the stale socket was accepted. Fix: at the onopen site pass epochChanged = true unconditionally so the endpoint is ALWAYS re-inspected; a gated pane's getEndpoint() resolves to null (element detached), so the stale socket is discarded and the poll reconnects with the current session once the pane is shown. The post-await site keeps the epoch-delta gate to avoid churn when a pane is briefly unmeasurable without a switch.

Both fixes are frontend-only and never tear down a live/hidden connection (locked decision preserved) — they reject only a not-yet-live stale handshake.

Findings rejected

None. No finding was dismissed; the two P1s were fixed and every other seat/sweep found no P0/P1. The maintainability nits raised by the Claude seat (portable-pty 0.8.x Drop version coupling; the effect-ordering invariant being correct-but-refactor-fragile; the Drop test hand-building the struct) are P2/P3 by their own author and out of scope for this correctness fix.

Verification on 783a2543

  • cargo test -p local-deployment -p server — pass (incl. the 2 new PTY Drop/VEOF tests + 4 tripwire tests); cargo clippy -p local-deployment -p server clean; cargo fmt --check clean (backend unchanged by the round-2 fixes).
  • npx vitest run terminalWsUrl.test.ts — 15/15 (added terminalAttemptIsStale coverage incl. the onopen force-inspect contract); frontend tsc (local-web, web-core, remote-web, ui) clean; prettier clean.
  • CI on 783a2543: frontend-checks, backend-test, backend-clippy, backend-schema-checks, tauri-checks all success; backend-remote-checks skipped (no crates/remote changes). (pnpm run check cannot complete in the review sandbox only because the GTK/glib system libs the tauri-app crate needs are absent there; CI, which has them, is green.)

… on reconnect

Kill the stray-newline-on-switch/reconnect artifact (claude reflowing and
stacking blank lines when a CLI pane attaches at xterm's 80x24 default and is
then refit to the real size).

- XTermInstance: never open the WS at an unmeasured size. Resolve the endpoint
  from FitAddon.proposeDimensions() after fit(); if the container is hidden/
  0-height, defer the initial connect until the first non-zero ResizeObserver
  tick instead of baking cols=80&rows=24 into the URL. Never tears down a live
  connection (hidden mobile tabs keep theirs by design).
- TerminalProvider: store a getEndpoint() callback instead of a frozen endpoint
  string; connectWebSocket rebuilds it fresh on every (re)connect so reconnects
  attach at the pane's CURRENT grid, and skips+reschedules when unmeasured.
- Observability: log cols/rows on the tmux attach (pty.rs) and warn on a mode=cli
  attach at the default 80x24 (terminal.rs) as a permanent regression tripwire.
- Extract resolveTerminalEndpoint() as a pure, node-testable helper; add vitest
  covering unmeasured->null, measured->real grid, and reconnect re-fit.
@miguelrisero miguelrisero changed the title fix(terminal): connect xterm at measured size and rebuild WS endpoint on reconnect (stray-newline on switch) fix(terminal): stop stray newline injection on session switch (portable-pty EOF-on-drop) + connect at measured size Jul 8, 2026
… logs

Control bytes (the \n+EOT injection / escape-sequence signature) stay
verbatim; printable keystrokes are masked so passwords or pasted secrets
never reach server logs.
…backs

Council review round 1:
- pty.rs: a failure between take_writer() and PtySession construction
  (e.g. try_clone_reader under fd pressure) dropped the writer with VEOF
  still armed, injecting \n+EOT into the just-spawned child — cover the
  window with a VeofDisarmOnDrop guard (defused once PtySession owns the
  writer) + unit test; log tcsetattr failure in disarm_master_eof; state
  the real Drop guarantee (device-scoped termios persistence) in the
  comments instead of overselling field order.
- XTermInstance: the stored connection callbacks captured this mount's
  refs, which cleanup nulls — after a gate-flip remount (executorRunning
  toggle, hidden side tabs) a surviving connection silently dropped all
  output; resolve terminal/fitAddon through the provider registry (same
  lifetime as the connection) and read sessionId via a latest-value ref
  so later reconnects join the session the UI currently shows.
- TerminalProvider: expose hasTerminalConnection (established OR live
  in-flight generation) and gate ensureConnection on it so two quick
  calls can't stack a duplicate backend PTY/tmux attach inside the async
  open window (real on slow relay transports).
- terminal.rs: lock the tripwire redaction invariant with an exhaustive
  0x00-0xff mask test.
…hidden-pane retry budget

/simplify review (reuse / simplification / efficiency / altitude):
- pty.rs: VeofDisarmOnDrop now owns a dup(2) of the master fd and rides
  the spawn_blocking return tuple (declared first — tuple fields drop in
  order), extending creation-window coverage to the request future being
  cancelled at the .await, where the detached task's tuple previously
  dropped the writer VEOF-armed with no guard alive; shared
  disarm_eof_on_fd helper; writer-field doc deduped; open_test_pty test
  helper; guard test updated for the owned-fd form.
- terminal.rs: attach-window tripwire state/bounds co-located in an
  AttachInputTripwire struct — identical 2s/128-byte semantics; expiry
  now latches the spent budget so post-window frames cost one integer
  compare.
- TerminalProvider: an unmeasurable pane is a wait state, not a connect
  failure — poll at 1s without spending the retry budget, so a pane
  hidden longer than the backoff ladder doesn't burn its 6 retries and
  give up before the user ever shows it; getEndpoint contract comments
  deduped to point at resolveTerminalEndpoint.
- XTermInstance: shared fitInstance() helper for the registry-resolved
  callbacks; onClose read via latest-ref so its unstable prop identity
  stops churning ensureConnection/fitTerminal and the ResizeObserver
  effect on every parent render.
…ting and healing

Phase-3 review battery (codex xhigh + 10-angle finder pass), all fixes
behavior-verified:

- pty.rs: construct the PtySession INSIDE create_session's blocking task
  and move the child kill into Drop for PtySession. Every teardown —
  close_session, service shutdown, creation failure after spawn, and the
  caller's future being cancelled at the .await (which previously
  orphaned the tmux client: the VEOF guard stopped the injection but
  nothing killed the child or unblocked the reader thread) — now funnels
  through the one Drop. The VeofDisarmOnDrop guard, its dup(2), and the
  tuple-order/defuse-placement conventions are deleted outright; the
  disarm stays teardown-only. New test drives a real pty child through
  session drop (VEOF disarmed on the device, child killed and reaped).
- terminal.rs: cols/rows become Option<u16> so the stray-newline
  tripwire warns on true ABSENCE instead of false-positiving on panes
  that genuinely measure 80x24 (a literal-default regression stays
  visible in pty.rs's per-attach size log); unit tests for the
  attach-window tripwire's byte budget and expiry latch.
- terminalWsUrl: reject non-finite dims — FitAddon.proposeDimensions()
  can yield NaN for detached/unstyled containers, and cols=NaN in the
  URL would 400 and burn the reconnect ladder; vitest added.
- XTermInstance: gate measurability on the terminal element's real DOM
  box (display:none containers pass proposeDimensions' >=1-cell clamp
  with a garbage 2x1 grid — the tiny-then-resize bounce in new clothes);
  heal-on-session-switch effect; fitTerminal reuses fitInstance.
- TerminalProvider: createTerminalConnection is now ensure-style
  idempotent — while a live/in-flight connection owns the tab it only
  refreshes the stored callbacks + endpoint source, fixing the remount
  staleness the ref indirection alone missed (a remount creates NEW ref
  cells; the stored closures kept reading the old mount's frozen ones)
  — and the public hasTerminalConnection gate is deleted in favor of
  enforcing the invariant where the state lives. Backend-error and
  clean-close paths now release the tab's entries (if still theirs) so
  a later mount/session switch can heal instead of finding the slot
  occupied by a dead socket forever. Already-open sockets from
  pluggable transports get the size sync onopen would have sent.
  UNMEASURED_POLL_MS named to keep the wait-state cadence out of the
  retry ladder.
…on reader-clone failure

Council round 2 (mixed engine, 4x codex + claude cross-check):

- TerminalProvider: an open started before a remount/session switch could
  resolve AFTER the endpoint source was refreshed and still register —
  binding the visible pane to the PREVIOUS session's tmux (all four codex
  seats, P1). Post-open the attempt is now validated against the current
  getEndpoint(): size-only drift passes (the post-open resize corrects
  it), a material change (session/mode/workspace) closes the socket and
  re-kicks through the single-timer path without spending retry budget.
  terminalEndpointsEquivalent lives in terminalWsUrl.ts with vitest.
- TerminalProvider/XTermInstance: the measurability gate moved from the
  component into createTerminalConnection, so an occupied-but-hidden tab
  still refreshes its stored callbacks on remount (the component-side
  gate skipped the refresh exactly when the predecessor closures were
  already dead).
- XTermInstance: the DOM gate also checks the PARENT box — the one
  FitAddon actually measures — so a splitter collapsed to 0-height can't
  slip a ~Nx1 clamped grid past the element-box check.
- pty.rs: try_clone_reader failure now kills AND reaps the spawned child
  inline (was: kill only, one zombie per failed create); the dead
  PtySession.closed field is gone; close_session drops the removed
  session outside the registry lock; the Drop comment states the real
  (accepted, pre-existing, few-instruction) load-then-kill window
  instead of overclaiming.
…t epoch

Final backstop sweep: the stale-open guard skipped validation when the
pane was unmeasurable at resolve time (getEndpoint() null), so a session
switch racing a slow open could still register the previous session's
socket if the pane hid mid-open. Every getEndpoint refresh now bumps an
epoch the attempt snapshots before awaiting: unchanged epoch = untouched
source, safe to register even hidden; changed epoch = revalidate (size
drift passes, material change discards, unmeasurable discards
conservatively and re-polls). Discards never spend the retry budget.
…after paint

Backstop round 2: a websocket open resolving in the commit-to-passive-
effect gap could still read the previous session's ref value and an
unbumped endpointEpoch, registering the old session's socket. The ref
sync and the heal/refresh effect are now useLayoutEffect — they run
synchronously inside the commit, before any promise continuation can
interleave, so an in-flight open can never observe a half-switched
session.
…e CONNECTING attach

The endpoint-epoch check ran only right after openLocalApiWebSocket() resolved,
but the default transport resolves with a still-CONNECTING socket. A session
switch landing after that socket was registered but before it opened only bumped
the epoch (occupied path) without closing or revalidating the connecting socket,
so it could become the tab's live connection and bind the pane to the previous
session's conversation on a first-attach / post-reap create.

Re-validate at onopen (and make the socket's own message/close handlers inert
once superseded) via a shared, unit-tested terminalAttemptIsStale() helper that
also DRYs the existing post-await check; discard + re-kick through the single
timer without spending the retry budget. Never tears down a live/hidden
connection — it rejects only a not-yet-live stale handshake.
…ed-child stale bind

The onopen re-validation gated on the epoch delta, but the epoch is bumped only
by a MOUNTED terminal child. When the host stops rendering the terminal
(executorRunning / !sessionsReady) during the CONNECTING window and the session
changes while gated, no mounted effect bumps the epoch, so the epoch delta stays
false and the stale socket (carrying the previous session_id) was accepted at
onopen — binding the pane to the previous conversation on a first-attach /
post-reap create.

Pass epochChanged=true at the onopen site (the point of no return) so the
endpoint is ALWAYS re-inspected; a gated pane's endpoint resolves to null, so the
stale socket is discarded and the poll reconnects with the current session once
the pane is shown again. The post-await site keeps the epoch-delta gate to avoid
churn when a pane is briefly unmeasurable without a switch. Never tears down a
live/hidden connection.
@miguelrisero miguelrisero merged commit a22aa05 into main Jul 8, 2026
7 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