fix(terminal): poll across frames in post-spawn PTY reconcile

A single post-spawn requestAnimationFrame was not enough to close the
first-mount column race: the pane's real layout can keep changing for
several frames (split equalize, sidebar/title reflow), so a one-shot
re-fit could still measure a stale width and leave the PTY pinned. Verified
by repeated e2e runs — the golden 'during initial mount' test failed ~1 in 9
runs with the single-frame version.

Poll for up to 12 frames, forwarding the settled size to the PTY only when
it differs from what the PTY was last told (mirroring the existing
ResizeObserver stability loop). Each resize is gated on an actual change, so
a TUI sees at most a couple of SIGWINCH during startup, not a loop. Combined
with the earlier call placement, the golden test now passes 6/6 with
repeat-each plus retries, and the full spec passes 15/15.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-06-28 14:22:34 -07:00
co-authored by Orca
parent 6b4cc2f4f8
commit 81eb416e03
@@ -2124,20 +2124,41 @@ export function connectPanePty(
spawnCols: number,
spawnRows: number
): void => {
requestAnimationFrame(() => {
// Why: a single post-spawn frame is not enough — the pane's real layout can
// keep changing for several frames (split equalize, sidebar/title reflow),
// so a one-shot re-fit can still measure a stale width. Poll across frames
// and forward the settled size to the PTY whenever it differs from what the
// PTY was last told, mirroring the ResizeObserver stability loop. Each
// resize is gated on an actual change, so a TUI sees at most a couple of
// SIGWINCH during its own startup, not a loop.
const MAX_RECONCILE_FRAMES = 12
let lastSentCols = spawnCols
let lastSentRows = spawnRows
let frame = 0
const tick = (): void => {
if (disposed || transport.getPtyId() !== ptyId) {
return
}
// Mobile legitimately parks the PTY at phone dims; never fight that.
if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) {
return
}
safeFit(pane)
const cols = pane.terminal.cols
const rows = pane.terminal.rows
if (cols > 0 && rows > 0 && (cols !== spawnCols || rows !== spawnRows)) {
if (cols > 0 && rows > 0 && (cols !== lastSentCols || rows !== lastSentRows)) {
// Initial spawn-time sync is authoritative, so it bypasses the
// visibility gate that onResize honors (but not the mobile guards above).
transport.resize(cols, rows)
lastSentCols = cols
lastSentRows = rows
}
})
frame += 1
if (frame < MAX_RECONCILE_FRAMES) {
requestAnimationFrame(tick)
}
}
requestAnimationFrame(tick)
}
// Defer PTY spawn/attach to next frame so FitAddon has time to calculate