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 96a3aef7e75..18c198f1d34 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -332,6 +332,7 @@ type MockTransport = { resize: ReturnType getPtyId: ReturnType getConnectionId: ReturnType + getRecoveryState?: ReturnType serializeBuffer?: ReturnType } @@ -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' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 6b4d65acde8..6b5bcf7e890 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts index ad25839c9b5..46d9f38d6ea 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts index fe96201dec6..048894b557c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 0f9918525a9..bfc1cecbdd4 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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 { diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 8604a0fd6dc..c1aa236c652 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -10,7 +10,12 @@ type FakePaneManager = { refreshAllPanes: Mock<() => void> } -function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { +function createPane( + options: { + clearRenderModel?: ReturnType + 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() } }) diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index bfc4797aa52..2e7e84955e7 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -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 -const pendingRevealRepaints = new Set() +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() + const paneGetters = pendingRevealRepaints.splice(0) + const livePanes = new Map() - 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): void { - pendingRevealRepaints.add(getPanes) +export function schedulePaneRevealRepaint( + getPanes: () => Iterable, + options?: { invalidatePaneId?: number } +): void { + pendingRevealRepaints.push({ getPanes, invalidatePaneId: options?.invalidatePaneId }) if (revealRepaintScheduled) { return } diff --git a/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.test.ts new file mode 100644 index 00000000000..e5758d9f5eb --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.ts b/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.ts new file mode 100644 index 00000000000..0f4a37e56b5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-render-model-invalidation.ts @@ -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 + } +} diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts index c390118a082..ad0000e7204 100644 --- a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -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(() => {