fix(terminal): preserve parked remote viewport during recovery

This commit is contained in:
Brennan Benson
2026-08-05 19:43:13 -07:00
parent 6e7d1e426a
commit da2c6bfcd1
10 changed files with 251 additions and 21 deletions
@@ -332,6 +332,7 @@ type MockTransport = {
resize: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
getConnectionId: ReturnType<typeof vi.fn>
getRecoveryState?: ReturnType<typeof vi.fn>
serializeBuffer?: ReturnType<typeof vi.fn>
}
@@ -21082,6 +21083,110 @@ describe('connectPanePty', () => {
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
})
it.each(['disconnected', 'offline'] as const)(
'preserves a parked paired frame and PTY identity while recovery is %s',
async (phase) => {
const { connectPanePty } = await import('./pty-connection')
const remotePtyId = 'remote:env-1@@terminal-1'
const transport = createMockTransport()
transport.connect.mockResolvedValue(undefined)
transport.getRecoveryState = vi.fn(() => ({ phase, epoch: 1, attempt: 1 }))
transportFactoryQueue.push(transport)
await parkTabForReveal('tab-1', remotePtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: remotePtyId }] },
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: remotePtyId }
}
},
runtimeStatusByEnvironmentId: new Map([
[
'env-1',
{
checkedAt: Date.now(),
status: { capabilities: ['terminal.paired-parking.v1'] }
}
]
])
}
const pane = createPane(1)
pane.container.dataset.parkedViewportFrame = 'true'
const { writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: remotePtyId }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ sessionId: remotePtyId })
)
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(1, null)
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(writes.join('')).not.toContain(
buildFreshShellViewportBlankingSequence(pane.terminal.rows)
)
expect(pane.container.dataset.parkedViewportFrame).toBe('true')
expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe(remotePtyId)
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[LEAF_1]).toBe(
remotePtyId
)
}
)
it('replaces an ended paired session after a parked reattach returns no PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const remotePtyId = 'remote:env-1@@terminal-1'
const replacementPtyId = 'remote:env-1@@terminal-2'
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ sessionId }) => {
if (sessionId) {
transport.getPtyId.mockReturnValue(null)
return undefined
}
transport.getPtyId.mockReturnValue(replacementPtyId)
return replacementPtyId
})
transport.getRecoveryState = vi.fn(() => ({ phase: 'ended', epoch: 1, attempt: 0 }))
transportFactoryQueue.push(transport)
await parkTabForReveal('tab-1', remotePtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: remotePtyId }] },
ptyIdsByTabId: { 'tab-1': [remotePtyId] },
runtimeStatusByEnvironmentId: new Map([
[
'env-1',
{
checkedAt: Date.now(),
status: { capabilities: ['terminal.paired-parking.v1'] }
}
]
])
}
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: remotePtyId }
})
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(transport.connect.mock.calls[1]?.[0]).not.toHaveProperty('sessionId')
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(1, remotePtyId)
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', remotePtyId)
})
it('restores a local main-model snapshot after a contentless park reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const localPtyId = 'global-floating-terminal@@terminal-1'
@@ -8162,6 +8162,15 @@ export function connectPanePty(
const ptyId =
connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId())
if (!ptyId) {
// Why: paired recovery retains the valid parked frame while host liveness is unknown.
const recoveryPhase = transport.getRecoveryState?.().phase
if (
mountFollowsTerminalPark &&
isRemoteRuntimePtyId(staleSessionId) &&
(recoveryPhase === 'disconnected' || recoveryPhase === 'offline')
) {
return false
}
warnTerminalLifecycleAnomaly('restored PTY reattach returned no PTY id', {
tabId: deps.tabId,
worktreeId: deps.worktreeId,
@@ -113,7 +113,7 @@ describe('parked terminal viewport frames', () => {
)
expect(mocks.replayIntoTerminalAsync.mock.calls[0]?.[3].shouldReleaseRenderPause()).toBe(true)
await Promise.resolve()
expect(scheduleRevealRepaint).toHaveBeenCalledOnce()
expect(scheduleRevealRepaint).toHaveBeenCalledWith({ invalidatePaneId: matching.pane.id })
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(true)
expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(false)
})
@@ -86,8 +86,8 @@ export function replayParkedTerminalViewportFrames(args: {
pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] === 'true' &&
args.isVisible()
) {
// Why: the immediate refresh can precede measurable layout; repaint the parsed frame after reveal settles.
args.manager.scheduleRevealRepaint()
// Why: invalidate the stale WebGL cell model only after reveal layout settles.
args.manager.scheduleRevealRepaint({ invalidatePaneId: pane.id })
}
})
replayed += 1
@@ -375,11 +375,11 @@ export class PaneManager {
return this.atlasRecoveryVisible && !this.destroyed
}
scheduleRevealRepaint(): void {
scheduleRevealRepaint(options?: { invalidatePaneId?: number }): void {
// Why: the settled-frame callback can fire after destroy(); repainting
// disposed panes could throw in attach and latch the global WebGL
// attach backoff, downgrading unrelated new panes to the DOM renderer.
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()))
schedulePaneRevealRepaint(() => (this.destroyed ? [] : this.panes.values()), options)
}
scheduleRevealPresent(): void {
@@ -10,7 +10,12 @@ type FakePaneManager = {
refreshAllPanes: Mock<() => void>
}
function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal {
function createPane(
options: {
clearRenderModel?: ReturnType<typeof vi.fn>
webglAddon?: FakeWebglAddon | null
} = {}
): ManagedPaneInternal {
const leafId = '33333333-3333-4333-8333-333333333333' as never
return {
id: 1,
@@ -20,7 +25,8 @@ function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): Manag
cols: 80,
rows: 24,
refresh: vi.fn(),
loadAddon: vi.fn()
loadAddon: vi.fn(),
_core: { _renderService: { clear: options.clearRenderModel ?? vi.fn() } }
} as never,
container: {} as never,
xtermContainer: {} as never,
@@ -158,6 +164,46 @@ describe('schedulePaneRevealRepaint', () => {
expect(pane.terminal.refresh).toHaveBeenCalled()
})
it('invalidates a requested WebGL pane only after reveal layout settles', () => {
const clearRenderModel = vi.fn()
const originalAddon = { clearTextureAtlas: vi.fn() }
const pane = createPane({ clearRenderModel, webglAddon: originalAddon })
registerPaneManager(() => [pane])
schedulePaneRevealRepaint(() => [pane], { invalidatePaneId: pane.id })
flushFrame()
expect(clearRenderModel).not.toHaveBeenCalled()
flushFrame()
expect(clearRenderModel).toHaveBeenCalledOnce()
expect(pane.webglAddon).toBe(originalAddon)
})
it('coalesces invalidation requests for different panes into one recovery', () => {
const firstClear = vi.fn()
const secondClear = vi.fn()
const firstPane = createPane({
clearRenderModel: firstClear,
webglAddon: { clearTextureAtlas: vi.fn() }
})
const secondPane = createPane({
clearRenderModel: secondClear,
webglAddon: { clearTextureAtlas: vi.fn() }
})
secondPane.id = 2
const manager = registerPaneManager(() => [firstPane, secondPane])
const getPanes = (): ManagedPaneInternal[] => [firstPane, secondPane]
schedulePaneRevealRepaint(getPanes, { invalidatePaneId: firstPane.id })
schedulePaneRevealRepaint(getPanes, { invalidatePaneId: secondPane.id })
flushFrame()
flushFrame()
expect(firstClear).toHaveBeenCalledOnce()
expect(secondClear).toHaveBeenCalledOnce()
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce()
})
it('resolves the pane list at repaint time, not at scheduling time', () => {
const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } })
@@ -1,10 +1,14 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
import { invalidateTerminalRenderModel } from './terminal-render-model-invalidation'
type PaneGetter = () => Iterable<ManagedPaneInternal>
const pendingRevealRepaints = new Set<PaneGetter>()
const pendingRevealRepaints: {
getPanes: PaneGetter
invalidatePaneId?: number
}[] = []
let revealRepaintScheduled = false
function scheduleSettledFrame(callback: () => void): void {
@@ -37,23 +41,25 @@ function forEachPaneOnSettledFrame(
function flushPaneRevealRepaints(): void {
revealRepaintScheduled = false
const paneGetters = Array.from(pendingRevealRepaints)
pendingRevealRepaints.clear()
const livePanes = new Set<ManagedPaneInternal>()
const paneGetters = pendingRevealRepaints.splice(0)
const livePanes = new Map<ManagedPaneInternal, boolean>()
for (const getPanes of paneGetters) {
for (const { getPanes, invalidatePaneId } of paneGetters) {
try {
for (const pane of getPanes()) {
livePanes.add(pane)
livePanes.set(pane, livePanes.get(pane) === true || pane.id === invalidatePaneId)
}
} catch {
/* ignore — a manager may be destroyed while its repaint is pending */
}
}
for (const pane of livePanes) {
for (const [pane, shouldInvalidate] of livePanes) {
try {
reattachWebglIfNeeded(pane)
if (shouldInvalidate && pane.webglAddon && !pane.webglDisabledAfterContextLoss) {
invalidateTerminalRenderModel(pane.terminal)
}
} catch {
/* ignore — one pane's teardown must not block global recovery */
}
@@ -69,13 +75,14 @@ function flushPaneRevealRepaints(): void {
* Why: while a pane is hidden, parsed output can update the WebGL renderer's
* per-cell model without ever presenting a frame. At reveal the model diff
* reports those cells unchanged, so plain refreshes skip them and the canvas
* keeps compositing pre-hide pixels until a selection or resize rebuilds the
* model. Once layout settles, reattach every revealed renderer before one
* registry-wide atlas reset so no delayed pane-local clear can invalidate a
* sibling terminal's rebuilt model.
* keeps compositing pre-hide pixels. Once layout settles, invalidate the
* replayed pane's model before one registry-wide atlas reset and refresh.
*/
export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneInternal>): void {
pendingRevealRepaints.add(getPanes)
export function schedulePaneRevealRepaint(
getPanes: () => Iterable<ManagedPaneInternal>,
options?: { invalidatePaneId?: number }
): void {
pendingRevealRepaints.push({ getPanes, invalidatePaneId: options?.invalidatePaneId })
if (revealRepaintScheduled) {
return
}
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { invalidateTerminalRenderModel } from './terminal-render-model-invalidation'
describe('invalidateTerminalRenderModel', () => {
it('clears an available render service', () => {
const clear = vi.fn()
expect(invalidateTerminalRenderModel({ _core: { _renderService: { clear } } })).toBe(true)
expect(clear).toHaveBeenCalledOnce()
})
it('safely ignores unavailable or disposed render services', () => {
expect(invalidateTerminalRenderModel(null)).toBe(false)
expect(invalidateTerminalRenderModel({ _core: {} })).toBe(false)
expect(
invalidateTerminalRenderModel({
_core: {
_renderService: {
clear: () => {
throw new Error('disposed')
}
}
}
})
).toBe(false)
})
})
@@ -0,0 +1,23 @@
type MaybeClearableRenderService = {
clear?: () => void
}
type TerminalWithRenderService = {
_core?: {
_renderService?: MaybeClearableRenderService
}
}
/** Clears xterm's retained cell model so the next refresh redraws every cell. */
export function invalidateTerminalRenderModel(terminal: unknown): boolean {
const service = (terminal as TerminalWithRenderService | null)?._core?._renderService
if (!service || typeof service.clear !== 'function') {
return false
}
try {
service.clear()
return true
} catch {
return false
}
}
@@ -52,6 +52,7 @@ writeFileSync(
'const size = () => `${process.stdout.columns}x${process.stdout.rows}`',
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
'record(`READY:${size()}`)',
"process.stdout.write('\\x1b[2J\\x1b[H')",
"process.stdout.write('\\x1b[44m \\x1b[0m\\r\\n')",
'process.stdout.write(`READY:${size()}\\r\\n`)',
"process.stdout.on('resize', () => {",
@@ -84,7 +85,7 @@ function shellQuote(value: string): string {
function fixtureCommand(sinkPath: string): string {
const command = [process.execPath, fixturePath, sinkPath]
return process.platform === 'win32'
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
? `& ${command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')}`
: command.map(shellQuote).join(' ')
}
@@ -455,6 +456,12 @@ async function seedScenario(
message: 'target terminal never painted its READY marker'
})
.toContain('READY:')
await expect
.poll(async () => (await readPaintedPaneViewport(client.page, target.webTabId)).text, {
timeout: 60_000,
message: 'target terminal READY marker never reached the visible viewport'
})
.toContain('READY:')
return { target, decoys }
}
@@ -602,6 +609,12 @@ test('paired client fast-paints a parked terminal before the runtime responds',
const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
const client = await launchPairedElectronClient(offer, testInfo, 'parked-fast-paint')
const clientWindow = await client.app.browserWindow(client.page)
await clientWindow.evaluate((window) => {
window.show()
window.focus()
})
await expect.poll(() => clientWindow.evaluate((window) => window.isVisible())).toBe(true)
const createdTerminals: string[] = []
try {
const worktreeId = await orcaPage.evaluate(() => {