Skip to content

A re-attaching app replays the screen, not a suffix of a diff stream - #47

Merged
bharathm03 merged 1 commit into
developmentfrom
antgrid/terminal-reattach-repaint
Aug 29, 2026
Merged

A re-attaching app replays the screen, not a suffix of a diff stream#47
bharathm03 merged 1 commit into
developmentfrom
antgrid/terminal-reattach-repaint

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

Returning to a session left parts of the terminal blank or showing stale content. Two independent causes, both measured with probes rather than inferred.

Cause 1 — nothing ever asked for the missed output

The bridge drops terminal:output while connState.suppressed and keeps bumping the seq regardless. Three triggers reach that state and none of them recovered:

Trigger Recovery before this PR
peerOnline = false (socket drop) snapshot pull fired only for tabs the app had never seen
appFocusPaused (app backgrounded) none — the focused project is exempt from the background demote
mayDeliver (remote-access toggle) none

TerminalService's tier-3 hydrator made it worse: it discarded its seq cutoffs instead of refreshing them. Measured after a reconnect — the app sent file:tree:snapshot:request, config:read, preview:snapshot:request, session:list, and zero terminal pulls.

Cause 2 — the replay could not have worked either

_applySnapshot erased the grid and fed back a 10,000-char ScrollbackBuffer tail. That tail is a suffix of a diff stream: Ink-style TUIs (Claude Code) paint their chrome once and rewrite only the rows that changed. Measured: 4 of 50 rows addressable. Enlarging the buffer cannot fix a stream that was never a screen.

What changed

A headless VT on the bridge. bridge/src/terminal-screen.ts runs @xterm/headless per PTY, fed before the suppression drop, and serializes the visible grid on attach — 9.4 KB for a 200×50 screen, smaller than the broken tail and structurally complete. Chosen over a PTY resize-jiggle, which is a write to satisfy a read, does nothing for exited-but-retained terminals or plain shells, and offers no signal for seq alignment.

The bridge composes the whole attach sequence, advertised by a new optional composed flag on terminal:snapshot; the app applies such a blob verbatim. The preamble resets DECSTBM and defaults the modes @xterm/addon-serialize emits set-only, and deliberately never issues 3J — the app's engine holds far more history than the bridge does, and an erase reaching past the screen would destroy the user's own with nothing able to put it back.

getAttachSnapshot is barrier-safe. It registers a tail sink across the serialize barrier, re-checks screen identity after it, and reads the seq afterwards. The barrier is a real suspension point: a terminal can exit and a same-id respawn take the slot across it, and a dead screen stamped with the live PTY's seq arms a cutoff above everything that PTY will ever emit — a blank pane in front of a running process.

The hydrator re-pulls per live tab, on transport re-establishment and on a new narrow focusResumed edge. The backgrounded-app trigger reconnects nothing, so it had no other recovery.

Cell metrics are measured synchronously up front (app/lib/widgets/terminal_cell_metrics.dart), so a non-driver remount costs zero engine resizes instead of two. ghostty_vte_flutter does not reflow, so every resize leaked stale fragments out of an Ink-style TUI.

Known gaps, flagged not taken

  • FileService and PreviewService have the same suppression defect with no recovery. FileService drops tree:update while suppressed and still bumps its seq. The honest fix is redriveHydrators() on AgentTransport, which lives in the Apache-2.0 packages/ tree — the licence boundary is one-way, so hoisting it there is not reversible.
  • Grid-height mismatch. The composed blob is driverRows tall, but a non-driver viewer sizes rows from its own viewport. Needs a new protocol field plus a product decision about viewer geometry.
  • Setup transcript truncation. Composition cut it from a 10,000-char tail to one visible screen, which hurts cold readers of a failed setup log.

New dependencies

@xterm/headless 6.0.0 and @xterm/addon-serialize 0.14.0, both MIT. The bridge ships as a compiled binary, so both are redistributed — added to the bridge/ table in THIRD-PARTY.md.

Test plan

Gate Result
bunx tsc --noEmit -p bridge exit 0
bun run --filter antgrid-bridge test 2999 pass / 5 skip / 1 fail (see below)
cd app && flutter test 2794 pass, 2 skipped
cd app && flutter analyze No issues found
npm run check:font-tokens OK
this PR's own bridge suites (7 files, ×2) 84 pass / 0 fail both runs

The single bridge failure is tests/git-status-freshness.test.ts:156 ("timed out waiting for the staged file") — a pre-existing load-sensitive flake, not a regression: this PR touches no git-status code, the file is unmodified, it passes 3/3 in isolation at ~2.9s, and the same tree produced 0, 1 and 2 failures across three full-suite runs. Its waitFor budget is 4000 ms while the file alone already burns ~2.9s of it. Worth widening separately; left alone here to keep this diff on topic.

@bharathm03
bharathm03 force-pushed the antgrid/terminal-reattach-repaint branch 3 times, most recently from cef66db to 2a358b8 Compare August 29, 2026 12:00
Returning to a session left parts of the terminal blank or showing stale content. Two independent causes, both measured with probes rather than inferred.

The bridge drops terminal:output while connState.suppressed - a socket drop, a backgrounded app, or a remote-access flip - and keeps bumping the seq regardless. The only recovery, terminal:snapshot:request, fired solely for tabs the app had never seen, and TerminalService's tier-3 hydrator DISCARDED its seq cutoffs instead of refreshing them. Measured after a reconnect: four hydrator pulls, none of them a terminal one.

The replay could not have worked either. _applySnapshot erased the grid and fed back a 10,000-char ScrollbackBuffer tail - a suffix of a DIFF stream, since Ink-style TUIs paint their chrome once and rewrite only the rows that changed. Measured: 4 of 50 rows addressable. Enlarging the buffer cannot fix a stream that was never a screen.

So the bridge keeps a headless @xterm/headless VT per PTY (terminal-screen.ts), fed BEFORE the suppression drop, and serializes the visible grid on attach - 9.4 KB for a 200x50 screen, smaller than the broken tail and structurally complete. It composes the whole attach sequence and advertises it with a new optional 'composed' flag; the app applies such a blob verbatim. The preamble resets DECSTBM and defaults the modes @xterm/addon-serialize emits set-only, and deliberately never issues 3J: the app's engine holds far more history than the bridge does, and an erase reaching past the screen would destroy the user's own with nothing able to put it back.

getAttachSnapshot registers a tail sink across the serialize barrier, re-checks screen identity after it, and reads the seq afterwards. The barrier is a real suspension point - a terminal can exit and a same-id respawn take the slot across it - and a dead screen stamped with the live PTY's seq arms a cutoff above everything that PTY will ever emit: a blank pane in front of a running process.

The hydrator now re-pulls a snapshot for every live tab, on transport re-establishment and on a new narrow focusResumed edge. The backgrounded-app trigger reconnects nothing, so it had no other recovery.

Cell metrics are measured synchronously up front (terminal_cell_metrics.dart), so a non-driver remount costs zero engine resizes instead of two. ghostty_vte_flutter does not reflow, so every resize leaked stale fragments out of an Ink-style TUI.

Known gap, flagged rather than taken: FileService and PreviewService have the same suppression defect with no recovery. The honest fix is redriveHydrators() on AgentTransport, which lives in the Apache-2.0 packages/ tree - the licence boundary is one-way.
@bharathm03 bharathm03 added the bug Something isn't working label Aug 29, 2026
@bharathm03
bharathm03 force-pushed the antgrid/terminal-reattach-repaint branch from 2a358b8 to 18e5636 Compare August 29, 2026 12:05
@bharathm03
bharathm03 merged commit 8333446 into development Aug 29, 2026
5 checks passed
@bharathm03
bharathm03 deleted the antgrid/terminal-reattach-repaint branch August 29, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant