From 588a7f85b3c75acf8ad87ca7fda46e53f6eb8dbf Mon Sep 17 00:00:00 2001 From: Griffin Long Date: Thu, 16 Jul 2026 10:09:21 -0400 Subject: [PATCH] fix(terminal): flush pending PTY resize on teardown (tmux pane-bleed) The renderer pool refits the xterm grid synchronously on resize but debounces the PTY winsize update by PTY_RESIZE_DEBOUNCE_MS (256ms). If a leaf was hidden or evicted inside that window, detachSlotFromLeaf cleared the pending resize timer without flushing it, so the queued winsize update was dropped and the PTY stayed behind the rendered grid. releaseSlot then reported the uncommitted grid size as the session size, and a retained fast rebind suppressed the size-matched resizePty, so the two stayed diverged until an unrelated later resize resynced them. tmux positions every pane by absolute coordinates against the size it was told, so the stale winsize made it draw pane content across the dividers. Extract the grid/PTY delta into a pure pendingPtyResize() helper and commit it on every teardown path, not only when the debounce timer fires, so the winsize can never lag the grid. Refs #981 --- src/modules/terminal/lib/rendererPool.ts | 31 +++++++++-- .../terminal/lib/rendererResize.test.ts | 54 +++++++++++++++++++ src/modules/terminal/lib/rendererResize.ts | 21 ++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 src/modules/terminal/lib/rendererResize.test.ts create mode 100644 src/modules/terminal/lib/rendererResize.ts diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 81c7d098a..433122a7f 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -18,6 +18,7 @@ import { terminalLineNavigationSequence, terminalWordNavigationSequence, } from "./keymap"; +import { pendingPtyResize } from "./rendererResize"; export const POOL_MAX_SIZE = 5; const FIT_DEBOUNCE_MS = 8; @@ -574,6 +575,21 @@ function rewireSlot(slot: Slot, p: AcquireParams): void { p.onSearchReady(slot.searchAddon); } +// Push any grid/PTY size delta to the PTY now, keeping slot.lastCols/lastRows +// as the source of truth for "what the PTY was last told". Called both when the +// debounce timer fires and on every teardown path, so an interrupted debounce +// can never leave the PTY winsize behind the rendered grid (tmux pane-bleed). +function commitPendingResize(slot: Slot, leafId: number): void { + const pending = pendingPtyResize( + { cols: slot.term.cols, rows: slot.term.rows }, + { cols: slot.lastCols, rows: slot.lastRows }, + ); + if (!pending) return; + slot.lastCols = pending.cols; + slot.lastRows = pending.rows; + adapter?.resolveLeaf(leafId)?.resizePty(pending.cols, pending.rows); +} + function setupResizeObserver(slot: Slot, p: AcquireParams): void { slot.observer?.disconnect(); if (slot.fitTimer) clearTimeout(slot.fitTimer); @@ -585,11 +601,7 @@ function setupResizeObserver(slot: Slot, p: AcquireParams): void { const flushPty = () => { slot.ptyTimer = null; if (slot.currentLeafId !== p.leafId) return; - if (slot.term.cols === slot.lastCols && slot.term.rows === slot.lastRows) - return; - slot.lastCols = slot.term.cols; - slot.lastRows = slot.term.rows; - adapter?.resolveLeaf(p.leafId)?.resizePty(slot.lastCols, slot.lastRows); + commitPendingResize(slot, p.leafId); }; slot.observer = new ResizeObserver(() => { @@ -647,6 +659,15 @@ function serializeSlot(slot: Slot): SerializeOutput { } function detachSlotFromLeaf(slot: Slot, retain: boolean): void { + // Commit any debounced-but-unsent resize before tearing down the timers. + // Dropping it here (the old behavior) left the PTY winsize behind the + // rendered grid, so tmux drew pane content into cells that no longer lined up + // with the grid and it bled across the pane dividers. releaseSlot reports + // slot.term.cols/rows as the session's size, so the PTY must already match. + if (slot.currentLeafId !== null) { + commitPendingResize(slot, slot.currentLeafId); + } + if (retain && slot.currentLeafId !== null) { slot.retainedLeafId = slot.currentLeafId; parkSlotHost(slot); diff --git a/src/modules/terminal/lib/rendererResize.test.ts b/src/modules/terminal/lib/rendererResize.test.ts new file mode 100644 index 000000000..e0db4e2c8 --- /dev/null +++ b/src/modules/terminal/lib/rendererResize.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { pendingPtyResize } from "./rendererResize"; + +describe("pendingPtyResize", () => { + it("returns null when the grid already matches the committed PTY size", () => { + expect( + pendingPtyResize({ cols: 80, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + }); + + it("reports the grid size when columns advanced past the PTY", () => { + expect( + pendingPtyResize({ cols: 100, rows: 24 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 100, rows: 24 }); + }); + + it("reports the grid size when rows advanced past the PTY", () => { + expect( + pendingPtyResize({ cols: 80, rows: 30 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 80, rows: 30 }); + }); + + it("reports a shrink as readily as a grow", () => { + expect( + pendingPtyResize({ cols: 60, rows: 20 }, { cols: 80, rows: 24 }), + ).toEqual({ cols: 60, rows: 20 }); + }); + + it("never emits a degenerate (zero/negative) resize during teardown", () => { + expect( + pendingPtyResize({ cols: 0, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + expect( + pendingPtyResize({ cols: 80, rows: 0 }, { cols: 80, rows: 24 }), + ).toBeNull(); + expect( + pendingPtyResize({ cols: -1, rows: 24 }, { cols: 80, rows: 24 }), + ).toBeNull(); + }); + + // Regression: tmux pane-bleed. A container resize fits the grid to 100x30 but + // the leaf is released before the debounced PTY-resize timer fires. The + // pending delta must be surfaced so the caller can commit it; a dropped + // resize leaves the PTY winsize behind the grid and tmux smears pane content + // across the dividers. + it("surfaces a pending resize an interrupted debounce would have dropped", () => { + const grid = { cols: 100, rows: 30 }; + const lastCommittedToPty = { cols: 80, rows: 24 }; + expect(pendingPtyResize(grid, lastCommittedToPty)).toEqual({ + cols: 100, + rows: 30, + }); + }); +}); diff --git a/src/modules/terminal/lib/rendererResize.ts b/src/modules/terminal/lib/rendererResize.ts new file mode 100644 index 000000000..862c64810 --- /dev/null +++ b/src/modules/terminal/lib/rendererResize.ts @@ -0,0 +1,21 @@ +export type GridSize = { cols: number; rows: number }; + +// A terminal slot's rendered grid is refit synchronously on every container +// resize, but the matching PTY winsize update is debounced. This returns the +// dimensions still owed to the PTY, or null when the two already agree. +// +// The caller must commit this delta on EVERY teardown path, not only when the +// debounce timer fires. If a leaf is released or evicted mid-debounce the +// pending resize used to be dropped, leaving the PTY winsize behind the grid. +// A multiplexer like tmux positions every pane by absolute coordinates against +// the size it was told, so a stale winsize makes it draw pane content into +// cells that no longer line up with the grid: content bleeds across the pane +// dividers. Degenerate (<= 0) dimensions never produce a resize. +export function pendingPtyResize( + grid: GridSize, + committed: GridSize, +): GridSize | null { + if (grid.cols <= 0 || grid.rows <= 0) return null; + if (grid.cols === committed.cols && grid.rows === committed.rows) return null; + return { cols: grid.cols, rows: grid.rows }; +}