diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 393996707..5f3f0ef2f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -8787,6 +8787,122 @@ describe('connectPanePty', () => { expect(writes.join('')).toContain('live-after-snapshot') }) + it('forwards the destination grid on reveal when the reattach fit was deferred by a display:none pane', async () => { + // Why: a restored floating-workspace pane reattaches while its panel is still closed, so the + // pane is display:none for the whole replay. The snapshot pins xterm to the PTY's grid, and the + // reattach fit is the only step that pushes the client grid back and signals SIGWINCH — losing + // it strands the PTY at the snapshot grid and the first reveal reflows the replay under a live TUI. + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + + // Reveal: the panel opens, the pane gains a box, and the first fit becomes measurable. + xtermContainerDisplay = 'block' + ;(deps.isVisibleRef as { current: boolean }).current = true + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('suppresses the deferred reattach grid push when mobile claims the PTY while hidden', async () => { + // Why: the pre-check at reattach time cannot see a takeover that happens while the pane + // waits for a box, and this path calls transport.resize directly, bypassing + // forwardPtyResize's own suppression. + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }, + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + + try { + // Mobile takes the PTY while the pane is still hidden. + setFitOverride('tab-pty', 'mobile-fit', 49, 20) + + xtermContainerDisplay = 'block' + ;(deps.isVisibleRef as { current: boolean }).current = true + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).not.toHaveBeenCalledWith(120, 40) + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + } finally { + setFitOverride('tab-pty', 'desktop-fit', 0, 0) + } + }) + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { const { connectPanePty } = await import('./pty-connection') const { markTerminalFollowOutput, markTerminalPinnedViewport } = diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 908d703b1..98868cbe2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4311,6 +4311,47 @@ export function connectPanePty( }, forwardResize: forwardPtyResize }) + // Why built here and not inside handleReattachResult: a hidden pane parks this until it is + // revealed, and a closure created in that scope would pin the whole reattach payload + // (snapshot/replay/coldRestore bytes) for as long as the pane stays hidden. Taking the + // generation and pty id by value keeps only connection-level state alive. + const createReattachGridPush = ( + attemptGeneration: number, + reattachPtyId: string + ): { shouldContinue: () => boolean; continuation: () => void } => { + const isCurrent = (): boolean => + !disposed && + attemptGeneration === transportStreamGeneration && + transport.getPtyId() === reattachPtyId + return { + shouldContinue: isCurrent, + continuation: () => { + if (!isCurrent()) { + return + } + // Why re-checked at fire time: the caller's pre-check cannot see a mobile takeover that + // lands while the pane waits for a box, and transport.resize here bypasses + // forwardPtyResize's own suppression. + if (shouldSuppressDesktopPtyResize()) { + return + } + const reattachCols = pane.terminal.cols + const reattachRows = pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + // Why here: a deferred reveal resolves the fit handle as incomplete, so an awaited + // reassertion at the call site would never run for that path. + if (deps.isVisibleRef.current) { + ptySizeReassertion.request({ fit: false }) + } + } + } + } let pendingForegroundGridDriftCheckRaf: number | null = null let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY const readProposedTerminalGrid = (): { cols: number; rows: number } | null => { @@ -8484,38 +8525,25 @@ export function connectPanePty( return } if (!getFitOverrideForPty(reattachPtyId)) { - const fit = safeFitAndThen( - pane, - 'reattach-pty-resize', - () => { - if (!isCurrentReattachPayload() || transport.getPtyId() !== reattachPtyId) { - return - } - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } - // Why: POSIX only sends SIGWINCH on an actual dimension change; signal explicitly so restored TUIs repaint at the correct cursor after replay. - if (!isRemoteRuntimePtyId(reattachPtyId)) { - window.api.pty.signal(reattachPtyId, 'SIGWINCH') - } - }, - { shouldContinue: isCurrentReattachPayload, retryIfUnmeasurable: true } - ) + const gridPush = createReattachGridPush(attemptGeneration, reattachPtyId) + const fit = safeFitAndThen(pane, 'reattach-pty-resize', gridPush.continuation, { + shouldContinue: gridPush.shouldContinue, + retryIfUnmeasurable: true, + // Why only this caller: a restored floating workspace is display:none until the + // user opens it, so dropping the grid push strands the PTY at the replay grid. + deferIfHidden: true + }) pendingReattachFit = fit - let fitCompleted = false try { - fitCompleted = await fit.completion + // Why: reattach resize is fire-and-forget, so the continuation itself requests the + // applied-grid verification — it is the only point reached by both the immediate + // and the deferred-until-revealed path. + await fit.completion } finally { if (pendingReattachFit === fit) { pendingReattachFit = null } } - if (fitCompleted && isCurrentReattachPayload() && deps.isVisibleRef.current) { - // Why: reattach resize is fire-and-forget; verify the provider's applied grid while this reveal still owns the visible pane. - ptySizeReassertion.request({ fit: false }) - } } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { window.api.pty.signal(reattachPtyId, 'SIGWINCH') } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-continuation-registry.ts b/src/renderer/src/lib/pane-manager/pane-fit-continuation-registry.ts new file mode 100644 index 000000000..a143281c4 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-registry.ts @@ -0,0 +1,162 @@ +import type { ManagedPane } from './pane-manager-types' +import { isManagedPaneDisplayNone } from './pane-display-visibility' +import { clearPaneFitContinuationRetry } from './pane-fit-continuation-retry' +import { + clearDeferredFitContinuation, + clearDeferredFitContinuations, + deferFitContinuation, + flushDeferredFitContinuations +} from './pane-fit-deferred-continuations' + +export type PendingSafeFitContinuation = { + continuation: () => void + shouldContinue: () => boolean + resolve: (completed: boolean) => void + // Why opt-in: only the reattach grid push is still owed after an indefinite hide. Every + // other caller keeps the bounded-degradation contract and is dropped when unmeasurable. + deferIfHidden: boolean +} + +const pendingSafeFitContinuations = new WeakMap< + ManagedPane, + Map +>() + +export function hasPendingSafeFitContinuations(pane: ManagedPane): boolean { + return Boolean(pendingSafeFitContinuations.get(pane)?.size) +} + +export function isPendingSafeFitContinuationCurrent( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation +): boolean { + return pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending +} + +/** Returns false when a newer registration already owns the key. */ +export function settlePendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation, + completed: boolean +): boolean { + const operations = pendingSafeFitContinuations.get(pane) + if (operations?.get(operationKey) !== pending) { + return false + } + operations.delete(operationKey) + if (operations.size === 0) { + pendingSafeFitContinuations.delete(pane) + clearPaneFitContinuationRetry(pane) + } + pending.resolve(completed) + return true +} + +export function registerPendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation +): void { + const operations = pendingSafeFitContinuations.get(pane) ?? new Map() + const replaced = operations.get(operationKey) + if (replaced) { + settlePendingSafeFitContinuation(pane, operationKey, replaced, false) + } + // Why: this registration owns the key now, so an earlier deferred twin must not survive to + // fire alongside it on the next fit. + clearDeferredFitContinuation(pane, operationKey) + const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations + currentOperations.set(operationKey, pending) + pendingSafeFitContinuations.set(pane, currentOperations) +} + +export function flushPendingSafeFitContinuations(pane: ManagedPane): void { + // Why first: continuations deferred while the pane was display:none carry the reattach + // grid push, and the pane is measurable exactly now. Reading the pending map afterwards is + // what keeps a continuation that fits re-entrantly from re-running a settled entry. + flushDeferredFitContinuations(pane) + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + continue + } + try { + pending.continuation() + settlePendingSafeFitContinuation(pane, operationKey, pending, true) + } catch { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +// Why settle-then-defer rather than settle: the awaiting caller must be released now, but the +// grid push the continuation carries is still owed to the PTY once the pane measures. +export function releaseSafeFitContinuationUntilMeasurable( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation +): void { + // Why gate on the settle: a superseded entry is already owned by a newer registration, and + // re-parking it would resurrect work that caller deliberately replaced. + if ( + settlePendingSafeFitContinuation(pane, operationKey, pending, false) && + pending.deferIfHidden + ) { + deferFitContinuation(pane, operationKey, pending) + } +} + +export function pruneStaleSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } else if (isManagedPaneDisplayNone(pane)) { + releaseSafeFitContinuationUntilMeasurable(pane, operationKey, pending) + } + } +} + +export function failPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of Array.from(operations.entries())) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } +} + +export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + clearPaneFitContinuationRetry(pane) + // Why: this is pane teardown/rebuild — a grid push owed to the old pane is now meaningless. + clearDeferredFitContinuations(pane) + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + pendingSafeFitContinuations.delete(pane) + for (const pending of operations.values()) { + pending.resolve(false) + } +} + +export function cancelPendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation +): void { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + // Why identity-gated: a stale handle may cancel after a replacement with the same key parks; + // it must invalidate its own deferred work without deleting the newer grid push. + clearDeferredFitContinuation(pane, operationKey, pending) +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit-deferred-continuations.ts b/src/renderer/src/lib/pane-manager/pane-fit-deferred-continuations.ts new file mode 100644 index 000000000..0e3961218 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-deferred-continuations.ts @@ -0,0 +1,75 @@ +import type { ManagedPane } from './pane-manager-types' + +export type DeferredFitContinuation = { + continuation: () => void + shouldContinue: () => boolean +} + +type PaneTerminal = ManagedPane['terminal'] + +// Why: a continuation parked on `pendingSafeFitContinuations` also holds its awaiting +// caller — reattach keeps live PTY bytes behind that promise. A pane that is display:none +// may not become measurable for minutes (a restored floating workspace stays closed until +// the user opens it), so its completion must resolve now while the work it carried — the +// client-grid push and its SIGWINCH — survives to the first measurable fit. +// +// Why keyed on the terminal, not the pane: a PTY connection holds the toPublicPane() +// wrapper it was created with, while PaneManager drains via its internal panes, so park and +// drain are never the same object. `terminal` is carried by reference and dies with the pane. +// Same trap as pane-metric-options-deferral. +const deferredByTerminal = new WeakMap>() + +export function deferFitContinuation( + pane: ManagedPane, + operationKey: string, + entry: DeferredFitContinuation +): void { + const deferred = + deferredByTerminal.get(pane.terminal) ?? new Map() + // Same key replaces: a newer reattach owns the grid the older one was going to send. + deferred.set(operationKey, entry) + deferredByTerminal.set(pane.terminal, deferred) +} + +export function flushDeferredFitContinuations(pane: ManagedPane): void { + const deferred = deferredByTerminal.get(pane.terminal) + if (!deferred) { + return + } + // Why delete first: a continuation can fit again re-entrantly, and it must not re-run itself. + deferredByTerminal.delete(pane.terminal) + for (const entry of deferred.values()) { + if (!entry.shouldContinue()) { + continue + } + try { + entry.continuation() + } catch { + // Why: one superseded pane must not strand the rest of the bucket. + } + } +} + +export function clearDeferredFitContinuations(pane: ManagedPane): void { + deferredByTerminal.delete(pane.terminal) +} + +// Why: a caller re-registering or cancelling one operation must not leave that operation's +// older deferred twin armed — it would fire alongside the new one on the next fit. +export function clearDeferredFitContinuation( + pane: ManagedPane, + operationKey: string, + expected?: DeferredFitContinuation +): void { + const deferred = deferredByTerminal.get(pane.terminal) + if (!deferred) { + return + } + if (expected && deferred.get(operationKey) !== expected) { + return + } + deferred.delete(operationKey) + if (deferred.size === 0) { + deferredByTerminal.delete(pane.terminal) + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 1e0a9cf92..70e7d2052 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -23,6 +23,7 @@ function flushAnimationFrames(timestamp = 16): void { type TestPane = ManagedPane & { setRect: (rect: { width: number; height: number }) => void setXtermRect: (rect: { width: number; height: number }) => void + setDisplay: (display: string) => void } function createPane(options: { @@ -32,6 +33,7 @@ function createPane(options: { let rect = options.rect // Why: the reveal gate measures the inner xterm host, which can differ from the outer .pane. let xtermRect: { width: number; height: number } | null = null + let display = 'block' const leafId = '22222222-2222-4222-8222-222222222222' const pane = { id: 7, @@ -46,7 +48,9 @@ function createPane(options: { getBoundingClientRect: () => ({ width: (xtermRect ?? rect).width, height: (xtermRect ?? rect).height - }) + }), + parentElement: null, + ownerDocument: { defaultView: { getComputedStyle: () => ({ display }) } } }, fitAddon: { fit: vi.fn(), @@ -60,6 +64,9 @@ function createPane(options: { }, setXtermRect: (next: { width: number; height: number }) => { xtermRect = next + }, + setDisplay: (next: string) => { + display = next } } return pane as unknown as TestPane @@ -203,6 +210,141 @@ describe('safeFitAndThen unmeasurable-pane retry', () => { expect(continuation).not.toHaveBeenCalled() }) + it('runs a display:none pane continuation on the first fit after it is revealed', async () => { + // Why: a restored floating-workspace pane is display:none for its whole reattach, so the + // reattach grid push has to survive to the reveal — dropping it strands the PTY at the + // replay grid with nothing left to correct it. + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const continuation = vi.fn() + vi.mocked(recordRendererCrashBreadcrumb).mockClear() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + + // Completion resolves immediately so reattach never holds live output behind a hidden pane. + await expect(handle.completion).resolves.toBe(false) + expect(continuation).not.toHaveBeenCalled() + expect(recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + safeFit(pane) + expect(continuation).toHaveBeenCalledTimes(1) + + // One-shot: a later fit must not re-send the reattach grid. + safeFit(pane) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('runs the continuation when the reveal fit arrives on a different pane object', async () => { + // Why: PTY connections hold a toPublicPane() wrapper while PaneManager.fitAllRevealedPanes + // iterates the internal panes, so park and drain are never the same object. Anything keyed + // on the pane identity is silently unreachable — see the same trap in + // pane-metric-options-deferral. + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const continuation = vi.fn() + + safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + const revealedPane = { ...pane } as typeof pane + safeFit(revealedPane) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('stops a deferred continuation when its handle is cancelled before reveal', async () => { + // Why: callers cancel to invalidate stale work (a new stream generation must not inherit an + // old replay's grid push). A deferred entry is that same work waiting for a box. + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const continuation = vi.fn() + + const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + handle.cancel() + + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + safeFit(pane) + expect(continuation).not.toHaveBeenCalled() + }) + + it('does not fire an older deferred continuation alongside its replacement', async () => { + // Why: re-registering the key hands ownership to the new continuation; leaving the old + // deferred twin armed would double-send the grid and its SIGWINCH in one tick. + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const first = vi.fn() + const second = vi.fn() + + safeFitAndThen(pane, 'reattach-pty-resize', first, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + safeFitAndThen(pane, 'reattach-pty-resize', second, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('does not let a stale handle cancel its deferred replacement', () => { + // Why: stream replacement can park under the same key before the superseded owner cancels. + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const first = vi.fn() + const second = vi.fn() + + const staleHandle = safeFitAndThen(pane, 'reattach-pty-resize', first, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + safeFitAndThen(pane, 'reattach-pty-resize', second, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + staleHandle.cancel() + + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + safeFit(pane) + + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('drops a display:none pane continuation when the pane is torn down before reveal', async () => { + const { cancelPendingSafeFitContinuations } = await import('./pane-fit') + const pane = createPane({ rect: { width: 0, height: 0 } }) + pane.setDisplay('none') + const continuation = vi.fn() + + safeFitAndThen(pane, 'reattach-pty-resize', continuation, { + retryIfUnmeasurable: true, + deferIfHidden: true + }) + cancelPendingSafeFitContinuations(pane) + + pane.setDisplay('block') + pane.setRect({ width: 800, height: 600 }) + safeFit(pane) + expect(continuation).not.toHaveBeenCalled() + }) + it('resolves failure when hidden-window animation frames are withheld', async () => { const pane = createPane({ rect: { width: 0, height: 0 } }) const continuation = vi.fn() diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index 0b7d3bd8e..c9807dbb1 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -23,6 +23,17 @@ import { } from './terminal-scroll-intent-rebuild' import { flushDeferredPaneMetricOptions } from './pane-metric-options-deferral' import { canMeasurePaneForFit, getProposedPaneDimensions } from './pane-fit-measurability' +import { + cancelPendingSafeFitContinuation, + failPendingSafeFitContinuations, + flushPendingSafeFitContinuations, + hasPendingSafeFitContinuations, + isPendingSafeFitContinuationCurrent, + pruneStaleSafeFitContinuations, + registerPendingSafeFitContinuation, + releaseSafeFitContinuationUntilMeasurable +} from './pane-fit-continuation-registry' +import type { PendingSafeFitContinuation } from './pane-fit-continuation-registry' import { notifyPaneFitSucceeded } from './pane-fit-webgl-attach-signal' import { recordPaneFitClientSize } from './pane-fit-client-size' @@ -32,22 +43,16 @@ export { flushDeferredPaneMetricOptionsIfMeasurable } from './pane-fit-measurability' +export { + cancelPendingSafeFitContinuations, + flushPendingSafeFitContinuations +} from './pane-fit-continuation-registry' + export type SafeFitContinuationHandle = { completion: Promise cancel: () => void } -type PendingSafeFitContinuation = { - continuation: () => void - shouldContinue: () => boolean - resolve: (completed: boolean) => void -} - -const pendingSafeFitContinuations = new WeakMap< - ManagedPane, - Map ->() - export { readFitClientSize } from './pane-fit-client-size' function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { @@ -145,43 +150,6 @@ function performSafeFit(pane: ManagedPane): boolean { } } -function settlePendingSafeFitContinuation( - pane: ManagedPane, - operationKey: string, - pending: PendingSafeFitContinuation, - completed: boolean -): void { - const operations = pendingSafeFitContinuations.get(pane) - if (operations?.get(operationKey) !== pending) { - return - } - operations.delete(operationKey) - if (operations.size === 0) { - pendingSafeFitContinuations.delete(pane) - clearPaneFitContinuationRetry(pane) - } - pending.resolve(completed) -} - -export function flushPendingSafeFitContinuations(pane: ManagedPane): void { - const operations = pendingSafeFitContinuations.get(pane) - if (!operations) { - return - } - for (const [operationKey, pending] of operations) { - if (!pending.shouldContinue()) { - settlePendingSafeFitContinuation(pane, operationKey, pending, false) - continue - } - try { - pending.continuation() - settlePendingSafeFitContinuation(pane, operationKey, pending, true) - } catch { - settlePendingSafeFitContinuation(pane, operationKey, pending, false) - } - } -} - export function safeFit(pane: ManagedPane): boolean { const completed = performSafeFit(pane) if (completed) { @@ -197,33 +165,11 @@ export function safeFit(pane: ManagedPane): boolean { return completed } -function pruneStaleSafeFitContinuations(pane: ManagedPane): void { - const operations = pendingSafeFitContinuations.get(pane) - if (!operations) { - return - } - for (const [operationKey, pending] of operations) { - if (!pending.shouldContinue() || isManagedPaneDisplayNone(pane)) { - settlePendingSafeFitContinuation(pane, operationKey, pending, false) - } - } -} - -function failPendingSafeFitContinuations(pane: ManagedPane): void { - const operations = pendingSafeFitContinuations.get(pane) - if (!operations) { - return - } - for (const [operationKey, pending] of Array.from(operations.entries())) { - settlePendingSafeFitContinuation(pane, operationKey, pending, false) - } -} - function armSafeFitContinuationRetry(pane: ManagedPane): void { armPaneFitContinuationRetry(pane, { retry: () => { pruneStaleSafeFitContinuations(pane) - if (!pendingSafeFitContinuations.get(pane)?.size) { + if (!hasPendingSafeFitContinuations(pane)) { return true } return safeFit(pane) @@ -236,31 +182,18 @@ function armSafeFitContinuationRetry(pane: ManagedPane): void { }) } -export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { - clearPaneFitContinuationRetry(pane) - const operations = pendingSafeFitContinuations.get(pane) - if (!operations) { - return - } - pendingSafeFitContinuations.delete(pane) - for (const pending of operations.values()) { - pending.resolve(false) - } -} - // Why: callers that forward xterm's grid to a PTY must wait for a measurable // fit or explicit lifecycle cancellation instead of observing replay dimensions. export function safeFitAndThen( pane: ManagedPane, operationKey: string, continuation: () => void, - options: { shouldContinue?: () => boolean; retryIfUnmeasurable?: boolean } = {} + options: { + shouldContinue?: () => boolean + retryIfUnmeasurable?: boolean + deferIfHidden?: boolean + } = {} ): SafeFitContinuationHandle { - const operations = pendingSafeFitContinuations.get(pane) ?? new Map() - const replaced = operations.get(operationKey) - if (replaced) { - settlePendingSafeFitContinuation(pane, operationKey, replaced, false) - } let resolveCompletion = (_completed: boolean): void => {} const completion = new Promise((resolve) => { resolveCompletion = resolve @@ -268,13 +201,12 @@ export function safeFitAndThen( const pending: PendingSafeFitContinuation = { continuation, shouldContinue: options.shouldContinue ?? (() => true), - resolve: resolveCompletion + resolve: resolveCompletion, + deferIfHidden: options.deferIfHidden === true } - const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations - currentOperations.set(operationKey, pending) - pendingSafeFitContinuations.set(pane, currentOperations) + registerPendingSafeFitContinuation(pane, operationKey, pending) const cancel = (): void => { - settlePendingSafeFitContinuation(pane, operationKey, pending, false) + cancelPendingSafeFitContinuation(pane, operationKey, pending) } if (!pending.shouldContinue()) { cancel() @@ -285,10 +217,12 @@ export function safeFitAndThen( pane.terminal, `safe-fit-and-then:${operationKey}`, () => { - if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { + if (isPendingSafeFitContinuationCurrent(pane, operationKey, pending)) { if (!safeFit(pane) && options.retryIfUnmeasurable) { if (isManagedPaneDisplayNone(pane)) { - cancel() + // Why not the frame retry: a zero-box pane can stay hidden indefinitely, so + // burning the budget only logs an exhaustion crumb. Hand it to the reveal instead. + releaseSafeFitContinuationUntilMeasurable(pane, operationKey, pending) } else { armSafeFitContinuationRetry(pane) } @@ -301,7 +235,7 @@ export function safeFitAndThen( } if (!safeFit(pane) && options.retryIfUnmeasurable) { if (isManagedPaneDisplayNone(pane)) { - cancel() + releaseSafeFitContinuationUntilMeasurable(pane, operationKey, pending) } else { armSafeFitContinuationRetry(pane) }