A terminal resize the PTY never got is one the app must keep offering - #72
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The driver re-sends
terminal:resizeonly 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.
_TerminalGridFreezere-armed its settle countdown on every invocation, andLayoutBuilderre-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._settlingTonow measures quiet from the last real move. Two related repairs in the same widget:_sameSizereplaces exactSize ==, so sub-pixel jitter from a fractional flex split cannot read as movement; anddispose()cancels a countdown that would otherwise fire against a deadState.2. A dropped send booked as a delivered one.
sendResizesilently 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:215spawns withcols: config.cols ?? this.lastDriverGeometry?.cols, andlastDriverGeometryis process-wide, not per terminal, so the fresh PTY inherits whichever terminal on that bridge resized most recently -- possibly another session entirely.TerminalTab.sizeEpochis the invalidation edge, the exact parallel of the seq cutoff dropped beside it. Bumped on a re-drive and on both respawn signals:terminal:startedis not inkCheckoutDurableReplayTypes({agent:status, git:status, tree:full}), so a relay app that missed the live frame builds its tabs from the replayedagent:statusand sees only that one.Plus a latch-lifetime bug the above exposed. The wrapper's per-PTY latches live in a
Statethat is not per-PTY -- onlyterminal_screen.dartkeys it byterminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap.didUpdateWidgetretires them.A corrected premise
Two comments justified the freeze's existence by claiming
ghostty_vte_flutterdoes 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.dartnow 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
changedontab.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 reworkingsendResizeto a leading-edge debounce, a shared path and a larger change than this diff.driverClientIdclear 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.