Skip to content

A terminal resize the PTY never got is one the app must keep offering - #72

Merged
bharathm03 merged 1 commit into
developmentfrom
fix/terminal-grid-geometry-stranding
Sep 1, 2026
Merged

A terminal resize the PTY never got is one the app must keep offering#72
bharathm03 merged 1 commit into
developmentfrom
fix/terminal-grid-geometry-stranding

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

The bug

The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app believes the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens: the terminal renders clipped at a stale column with dead space beside it, for the life of the session.

The three defects

1. The freeze never settled while the agent was working. _TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds -- not only when constraints change. Any rebuild cadence faster than the 150ms delay (a streaming agent, a selection drag, a session-list tick) cancelled the timer forever. Measured: a panel grown 300 -> 600 with a rebuild every 50ms stays at 300.0 indefinitely; the same scenario with no rebuilds settles in 150ms.

_settlingTo now measures quiet from the last real move. Two related repairs in the same widget: _sameSize replaces exact Size ==, so sub-pixel jitter from a fractional flex split cannot read as movement; and dispose() cancels a countdown that would otherwise fire against a dead State.

2. A dropped send booked as a delivered one. sendResize silently returned when the per-install client id had not resolved, while the caller had already recorded the size as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation.

3. Nothing re-asserted geometry across a reconnect or a respawn. A resize sent into a keyless window vanishes unreported -- _rehydrateTerminals' own comment already says so, and drops the snapshot-seq cutoff for that reason -- but the geometry was left standing. A same-id respawn is worse than it looks: terminal-manager.ts:215 spawns with cols: config.cols ?? this.lastDriverGeometry?.cols, and lastDriverGeometry is process-wide, not per terminal, so the fresh PTY inherits whichever terminal on that bridge resized most recently -- possibly another session entirely.

TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the seq cutoff dropped beside it. Bumped on a re-drive and on both respawn signals: terminal:started is not in kCheckoutDurableReplayTypes ({agent:status, git:status, tree:full}), so a relay app that missed the live frame builds its tabs from the replayed agent:status and sees only that one.

Plus a latch-lifetime bug the above exposed. The wrapper's per-PTY latches live in a State that is not per-PTY -- only terminal_screen.dart keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them.

A corrected premise

Two comments justified the freeze's existence by claiming ghostty_vte_flutter does not reflow. It does: soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half -- a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart now pins both halves with margin-filling rows (an earlier draft used 30-char rows in a 40-column grid, where nothing wraps and the assertion held for an unrelated reason).

Left open, deliberately

  • Altitude. Gating changed on tab.cols/tab.rows -- the authoritative geometry the bridge echoes -- would make every bump site redundant and close every path at once. Not taken here: that value is stale for the whole window between our send and the agent's echo, so a sustained rebuild stream would re-arm the 100ms debounce and never fire, which is the same class of bug as fix(bridge): serialize checkout runtime build/teardown so sessions stay deletable #1. Doing it safely means reworking sendResize to a leading-edge debounce, a shared path and a larger change than this diff.
  • Respawn re-send race. The epoch bump and the driverClientId clear land in the same frame, so every attached client becomes a driver at once and races to re-send; first to land wins. Nondeterministic but legitimate and recoverable by focus or pointer-down. Making it deterministic needs the bridge to preserve driver identity across a respawn.

Test plan

  • flutter analyze -- No issues found (warnings and infos included).
  • flutter test -- 3087 passed / 2 skipped (baseline 3084).
  • npm run check:font-tokens -- OK.
  • Every new test mutation-checked: each fails when its fix is reverted.

The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app BELIEVES the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens.

_TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds, not only when constraints change. Any rebuild cadence faster than the 150ms delay -- a streaming agent, a selection drag, a session-list tick -- cancelled the timer forever, so the grid stayed pinned to whatever it held when the panel last changed size: content clipped at the stale column with dead space beside it, for exactly as long as the agent keeps working. _settlingTo now measures quiet from the last real move, _sameSize replaces exact Size == so sub-pixel flex jitter does not read as movement, and dispose() cancels.

sendResize dropped a request whose per-install client id had not resolved while the caller booked it as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation.

Nothing re-asserted geometry across a reconnect or a same-id respawn. A resize sent into a keyless window vanishes unreported, and a respawned PTY takes terminal-manager's process-wide lastDriverGeometry -- whichever terminal on that bridge resized most recently, not the one this driver sent the dead process. TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the snapshot-seq cutoff dropped beside it in _rehydrateTerminals, bumped on re-drive and on both respawn signals (terminal:started is not in kCheckoutDurableReplayTypes, so a relay app builds its tabs from the replayed agent:status and sees only that one).

The wrapper's per-PTY latches lived in a State that is not per-PTY: only terminal_screen keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them.

Also corrects a load-bearing false premise. Two comments justified the freeze by claiming ghostty_vte_flutter does not reflow. It does -- soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half: a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart pins both halves with margin-filling rows.
@bharathm03
bharathm03 merged commit c21d091 into development Sep 1, 2026
4 checks passed
@bharathm03
bharathm03 deleted the fix/terminal-grid-geometry-stranding branch September 1, 2026 14:27
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