fix(terminal): keep the reattach grid push alive for a hidden pane (#13155)
* fix(terminal): keep the reattach grid push alive for a hidden pane A restored Floating Workspace pane is display:none for its whole PTY reattach, because the panel always mounts closed after a restart. The display:none guard in safeFitAndThen dropped the reattach continuation outright, so the PTY never received the client grid and no explicit SIGWINCH was sent to repaint the restored TUI after the replay. Parking the continuation is not an option: the structural replay coordinator holds live PTY bytes behind its promise, so a closed panel would stall output indefinitely. Settle the promise immediately instead and move the continuation to a per-pane deferred bucket that the first measurable fit drains. Deferral is opt-in so the bounded-degradation contract still covers every other caller, and the continuation re-checks mobile PTY ownership at fire time now that the wait is unbounded. * fix(terminal): preserve replacement deferred grid push
This commit is contained in:
parent
383665ebb8
commit
98cc730289
|
|
@ -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<typeof vi.fn>
|
||||
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<typeof vi.fn>
|
||||
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 } =
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, PendingSafeFitContinuation>
|
||||
>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<PaneTerminal, Map<string, DeferredFitContinuation>>()
|
||||
|
||||
export function deferFitContinuation(
|
||||
pane: ManagedPane,
|
||||
operationKey: string,
|
||||
entry: DeferredFitContinuation
|
||||
): void {
|
||||
const deferred =
|
||||
deferredByTerminal.get(pane.terminal) ?? new Map<string, DeferredFitContinuation>()
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<boolean>
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
type PendingSafeFitContinuation = {
|
||||
continuation: () => void
|
||||
shouldContinue: () => boolean
|
||||
resolve: (completed: boolean) => void
|
||||
}
|
||||
|
||||
const pendingSafeFitContinuations = new WeakMap<
|
||||
ManagedPane,
|
||||
Map<string, PendingSafeFitContinuation>
|
||||
>()
|
||||
|
||||
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<boolean>((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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue