From 9680b22e60ada5458b4656cea558c879bb8783e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:54:44 -0700 Subject: [PATCH] fix(terminal): heal stale PTY grids on window wake (#9626) * fix(terminal): heal stale PTY grids on window wake * fix(terminal): bound window-wake size recovery --- config/reliability-gates.jsonc | 63 ++++-- src/main/ipc/pty.test.ts | 12 ++ src/main/ipc/pty.ts | 9 +- src/main/providers/ssh-pty-applied-size.ts | 49 +++++ src/main/providers/ssh-pty-provider.test.ts | 27 +++ src/main/providers/ssh-pty-provider.ts | 3 + src/relay/pty-handler.test.ts | 18 ++ src/relay/pty-handler.ts | 11 + .../components/terminal-pane/TerminalPane.tsx | 1 + .../terminal-pane/pty-connection.ts | 4 + .../use-terminal-pane-global-effects.ts | 6 +- .../use-terminal-window-wake-recovery.test.ts | 53 +++++ .../use-terminal-window-wake-recovery.ts | 20 +- tests/e2e/ssh-terminal-stale-grid-probe.ts | 159 ++++++++++++++ ...minal-window-wake-stale-grid-repro.spec.ts | 195 ++++++++++++++++++ ...minal-window-wake-stale-grid-repro.spec.ts | 71 +++++++ 16 files changed, 680 insertions(+), 21 deletions(-) create mode 100644 src/main/providers/ssh-pty-applied-size.ts create mode 100644 tests/e2e/ssh-terminal-stale-grid-probe.ts create mode 100644 tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts create mode 100644 tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3c49afcb582..60ad659d376 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-19", + "updatedAt": "2026-07-20", "policy": { "maturityLevels": [ "experimental", @@ -1224,7 +1224,8 @@ "PTY sizing", "split layout", "restore", - "hidden-to-visible transitions" + "hidden-to-visible transitions", + "window wake" ], "platforms": [ "macos", @@ -1241,7 +1242,7 @@ "macos" ], "coveredProviders": [], - "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.", + "coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6644", "https://github.com/stablyai/orca/pull/6649", @@ -1252,15 +1253,19 @@ "https://github.com/stablyai/orca/pull/7192" ], "invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.", - "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", + "oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts", + "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/main/ipc/pty.test.ts", - "src/main/runtime/orca-runtime.test.ts" + "src/main/providers/ssh-pty-provider.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "src/relay/pty-handler.test.ts" ], "assertionRefs": [ { @@ -1271,6 +1276,13 @@ "applied-size readback divergence is re-forwarded before handoff" ] }, + { + "file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts", + "assertions": [ + "only one applied-size read stays in flight while the grid changes", + "stale in-flight targets never resize the PTY back to an older grid" + ] + }, { "file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts", "assertions": [ @@ -1279,11 +1291,26 @@ "hidden 0x0 background spawn is not forced to phantom desktop size" ] }, + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": [ + "window focus reasserts PTY size after fitting", + "the immediate and settled wake fits produce exactly one geometry-only reassertion" + ] + }, { "file": "src/main/ipc/pty.test.ts", "assertions": [ "accepted desktop resizes fan out to the runtime after provider resize", - "rejected desktop resizes do not fan out to the runtime" + "rejected desktop resizes do not fan out to the runtime", + "provider-owned null remains unverified instead of falling back to requested size" + ] + }, + { + "file": "src/main/providers/ssh-pty-provider.test.ts", + "assertions": [ + "SSH applied-size readback uses the relay with a one-second timeout", + "old-relay method-not-found is cached while transient failures remain retryable" ] }, { @@ -1292,17 +1319,24 @@ "the headless mirror resizes after an accepted desktop PTY resize (#7192)", "headless mirror resizes are ordered behind queued PTY writes (#7192)" ] + }, + { + "file": "src/relay/pty-handler.test.ts", + "assertions": [ + "relay readback reports the grid actually applied by node-pty", + "missing relay PTYs return an unverified null size" + ] } ], "evidenceRuns": [ { - "date": "2026-07-03", + "date": "2026-07-20", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts", "result": "passed", - "durationSeconds": 8.4, - "summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout." + "durationSeconds": 18.54, + "summary": "8 test files passed, 1,272 tests passed on the PR branch." } ], "runtimeBudget": { @@ -1319,16 +1353,15 @@ }, "performanceBudget": { "required": true, - "evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn." + "evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added." }, "promotionCriteria": [ "Build deterministic fault hooks before adding broad E2E coverage.", "Keep stress variants non-blocking until runtime and flake history are known." ], "knownGaps": [ - "No live Electron geometry command yet beyond the local live PTY resize slice.", - "The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.", - "Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.", + "Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.", + "The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.", "Does not yet prove Windows ConPTY geometry/readback.", "Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY." ], diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 85a84fe098b..5d147a9eebd 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4703,6 +4703,18 @@ describe('registerPtyHandlers', () => { expect(reported).toEqual({ cols: 80, rows: 24 }) }) + it('preserves provider-owned null so the renderer re-forwards an unverified size', async () => { + setupProviderWithAppliedSize({ applied: null, getAppliedSize: async () => null }) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const spawn = await handlers.get('pty:spawn')!(null, { cols: 100, rows: 30, env: {} }) + const id = (spawn as { id: string }).id + resizeListener()(mainWindowIpcEvent, { id, cols: 80, rows: 24 }) + + const reported = await handlers.get('pty:getSize')!(null, { id }) + expect(reported).toBeNull() + }) + it('falls back to the requested size when getAppliedSize throws', async () => { // A dead daemon/relay must never throw across the IPC boundary or block. setupProviderWithAppliedSize({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 573506367ea..c14ba872cf6 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -4814,10 +4814,13 @@ export function registerPtyHandlers( ipcMain.handle( 'pty:getSize', async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args.id) try { - const applied = await tryGetProviderForPty(args.id)?.getAppliedSize?.(args.id) - if (applied) { - return applied + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) } } catch { // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. diff --git a/src/main/providers/ssh-pty-applied-size.ts b/src/main/providers/ssh-pty-applied-size.ts new file mode 100644 index 00000000000..f725f7c2a09 --- /dev/null +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -0,0 +1,49 @@ +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { toRelaySshPtyId } from './ssh-pty-id' + +type AppliedPtySize = { cols: number; rows: number } +// Why: wake repair can safely re-forward on failure, so never inherit the generic 30-second RPC timeout. +const SSH_PTY_APPLIED_SIZE_TIMEOUT_MS = 1_000 + +export function createSshPtyAppliedSizeReader( + mux: SshChannelMultiplexer, + connectionId: string +): (id: string) => Promise { + let supported: boolean | null = null + return async (id) => { + if (supported === false) { + return null + } + try { + const result = (await mux.request( + 'pty.getSize', + { + id: toRelaySshPtyId(connectionId, id) + }, + { timeoutMs: SSH_PTY_APPLIED_SIZE_TIMEOUT_MS } + )) as { + cols?: unknown + rows?: unknown + } | null + supported = true + if ( + !result || + !Number.isInteger(result.cols) || + !Number.isInteger(result.rows) || + Number(result.cols) <= 0 || + Number(result.rows) <= 0 + ) { + return null + } + return { cols: Number(result.cols), rows: Number(result.rows) } + } catch (error) { + if ((error as { code?: unknown })?.code === JsonRpcErrorCode.MethodNotFound) { + // Why: old relays lack pty.getSize; remember that per SSH provider so + // each wake re-forwards once without repeatedly probing the same host. + supported = false + } + return null + } + } +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 209218fa1f0..4a2bf02546a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -470,6 +470,33 @@ describe('SshPtyProvider', () => { expect(mux.notify).toHaveBeenCalledWith('pty.resize', { id: 'pty-1', cols: 120, rows: 40 }) }) + it('reads the applied PTY size from the relay', async () => { + mux.request.mockResolvedValue({ cols: 120, rows: 40 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 120, rows: 40 }) + expect(mux.request).toHaveBeenCalledWith('pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 }) + }) + + it('caches only an old relay method-not-found response', async () => { + mux.request.mockRejectedValue(Object.assign(new Error('Method not found'), { code: -32601 })) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + expect(mux.request).toHaveBeenCalledTimes(1) + }) + + it('retries an applied-size read after a transient relay failure', async () => { + mux.request + .mockRejectedValueOnce( + Object.assign(new Error('connection lost'), { code: 'CONNECTION_LOST' }) + ) + .mockResolvedValueOnce({ cols: 100, rows: 30 }) + + await expect(provider.getAppliedSize(scopedPty1)).resolves.toBeNull() + await expect(provider.getAppliedSize(scopedPty1)).resolves.toEqual({ cols: 100, rows: 30 }) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + it('shutdown sends pty.shutdown request', async () => { await provider.shutdown(scopedPty1, { immediate: true }) expect(mux.request).toHaveBeenCalledWith( diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index df6d0540d47..48240e27e25 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -3,6 +3,7 @@ import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } fr import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id' import { seedPowerlevel10kWizardEnv } from '../pty/powerlevel10k-wizard-env' import { PTY_STARTUP_INGRESS_VERSION } from '../../shared/pty-startup-ingress' +import { createSshPtyAppliedSizeReader } from './ssh-pty-applied-size' type DataCallback = (payload: { id: string @@ -56,6 +57,7 @@ export class SshPtyProvider implements IPtyProvider { // multiplexer. Without this, notification callbacks keep firing after // the provider is torn down on disconnect, routing events to stale state. private unsubscribeNotifications: (() => void) | null = null + readonly getAppliedSize: NonNullable constructor( connectionId: string, @@ -64,6 +66,7 @@ export class SshPtyProvider implements IPtyProvider { ) { this.connectionId = connectionId this.mux = mux + this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) // Subscribe to relay notifications for PTY events this.unsubscribeNotifications = mux.onNotification((method, params) => { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 0c0b33d9b94..d7242234053 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1274,6 +1274,24 @@ describe('PtyHandler', () => { expect(mockResize).toHaveBeenCalledWith(120, 40) }) + it('reports the PTY grid actually applied by node-pty', async () => { + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + cols: 132, + rows: 43, + onData: vi.fn(), + onExit: vi.fn() + }) + + const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } + + await expect(dispatcher.callRequest('pty.getSize', { id: spawned.id })).resolves.toEqual({ + cols: 132, + rows: 43 + }) + await expect(dispatcher.callRequest('pty.getSize', { id: 'missing' })).resolves.toBeNull() + }) + it('kills PTY on shutdown with SIGTERM by default', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 28df63b7960..ebdb7cea9e0 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -575,6 +575,7 @@ export class PtyHandler { this.dispatcher.onRequest('pty.sendSignal', (p) => this.sendSignal(p)) this.dispatcher.onRequest('pty.getCwd', (p) => this.getCwd(p)) this.dispatcher.onRequest('pty.getInitialCwd', (p) => this.getInitialCwd(p)) + this.dispatcher.onRequest('pty.getSize', (p) => this.getSize(p)) this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p)) this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p)) this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p)) @@ -1021,6 +1022,16 @@ export class PtyHandler { } } + private async getSize( + params: Record + ): Promise<{ cols: number; rows: number } | null> { + const managed = this.ptys.get(params.id as string) + if (!managed || managed.disposed) { + return null + } + return { cols: managed.pty.cols, rows: managed.pty.rows } + } + private async shutdown(params: Record): Promise { const id = params.id as string const immediate = params.immediate as boolean diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 0997a11c0e7..e80119bbef5 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1720,6 +1720,7 @@ export default function TerminalPane({ managerRef, containerRef, paneTransportsRef, + panePtyBindingsRef, isActiveRef, isVisibleRef, toggleExpandPane diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8e81e9b645b..78b96cdafc9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -625,6 +625,7 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + reassertPtySizeAfterWindowWake: () => void /** Navigation-free hibernation wake: fires the armed cold-restore --resume * without the size-reassert/foreground-sample side effects of a real reveal. * Used by the mobile wake fanout so a hidden hibernated pane resumes with no @@ -8037,6 +8038,9 @@ export function connectPanePty( requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, + reassertPtySizeAfterWindowWake() { + ptySizeReassertion.request({ fit: false }) + }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. wakeHibernatedAgentIfArmed(claimedProviderSessions) { if (hibernatedWakeInFlightClaimKey) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 09033df6264..95f9198e7ea 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,6 +8,7 @@ import { } from '@/constants/terminal' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' +import type { IDisposable } from '@xterm/xterm' import { handleTerminalFileDrop } from './terminal-drop-handler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' @@ -38,6 +39,7 @@ type UseTerminalPaneGlobalEffectsArgs = { managerRef: React.RefObject containerRef: React.RefObject paneTransportsRef: React.RefObject> + panePtyBindingsRef?: React.RefObject> isActiveRef: React.RefObject isVisibleRef: React.RefObject toggleExpandPane: (paneId: number) => void @@ -70,6 +72,7 @@ export function useTerminalPaneGlobalEffects({ managerRef, containerRef, paneTransportsRef, + panePtyBindingsRef, isActiveRef, isVisibleRef, toggleExpandPane @@ -120,7 +123,8 @@ export function useTerminalPaneGlobalEffects({ isVisible: rendererVisible, managerRef, isActiveRef, - isVisibleRef + isVisibleRef, + panePtyBindingsRef }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index c83cdde9288..4862bc145e7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -97,6 +97,59 @@ describe('useTerminalWindowWakeRecovery', () => { ]) }) + it('reasserts pane PTY sizes after the window-focus fit', () => { + const reassertPtySizeAfterWindowWake = vi.fn() + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + panePtyBindingsRef: { + current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never + } + }) + ) + + window.dispatchEvent(new Event('focus')) + + expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder[0]).toBeLessThan( + reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('reasserts once after the settled fit when animation frames are available', () => { + const scheduled: { settle: FrameRequestCallback | null } = { settle: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduled.settle = callback + return 1 + }) + const reassertPtySizeAfterWindowWake = vi.fn() + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + panePtyBindingsRef: { + current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never + } + }) + ) + + window.dispatchEvent(new Event('focus')) + expect(reassertPtySizeAfterWindowWake).not.toHaveBeenCalled() + expect(scheduled.settle).not.toBeNull() + + scheduled.settle?.(performance.now()) + + expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder.at(-1)).toBeLessThan( + reassertPtySizeAfterWindowWake.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + it('unsubscribes from the system resume event on cleanup', () => { const { unmount } = renderWakeRecoveryHook() expect(onSystemResumed).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 839b6184696..7405f04345b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -2,19 +2,26 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import type { IDisposable } from '@xterm/xterm' type UseTerminalWindowWakeRecoveryArgs = { isVisible: boolean managerRef: React.RefObject isActiveRef: React.RefObject isVisibleRef: React.RefObject + panePtyBindingsRef?: React.RefObject> +} + +type WindowWakePtyBinding = IDisposable & { + reassertPtySizeAfterWindowWake?: () => void } export function useTerminalWindowWakeRecovery({ isVisible, managerRef, isActiveRef, - isVisibleRef + isVisibleRef, + panePtyBindingsRef }: UseTerminalWindowWakeRecoveryArgs): void { useEffect(() => { if (!isVisible) { @@ -30,6 +37,13 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } + const reassertPanePtySizes = (): void => { + for (const binding of panePtyBindingsRef?.current.values() ?? []) { + // Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize. + const windowWakeBinding = binding as WindowWakePtyBinding + windowWakeBinding.reassertPtySizeAfterWindowWake?.() + } + } const recoverVisibleWake = ( clearGlyphAtlases: boolean, source: 'focus' | 'visibilitychange' | 'system-resumed' @@ -60,6 +74,7 @@ export function useTerminalWindowWakeRecovery({ clearGlyphAtlases }) if (typeof requestAnimationFrame !== 'function') { + reassertPanePtySizes() return } settledClearGlyphAtlases = clearGlyphAtlases @@ -76,6 +91,7 @@ export function useTerminalWindowWakeRecovery({ isActive: isActiveRef.current, clearGlyphAtlases: clearGlyphAtlasesOnSettle }) + reassertPanePtySizes() }) } // Why: plain refocus (alt-tab, devtools) is frequent and often lands while @@ -115,5 +131,5 @@ export function useTerminalWindowWakeRecovery({ } unsubscribeSystemResumed?.() } - }, [isActiveRef, isVisible, isVisibleRef, managerRef]) + }, [isActiveRef, isVisible, isVisibleRef, managerRef, panePtyBindingsRef]) } diff --git a/tests/e2e/ssh-terminal-stale-grid-probe.ts b/tests/e2e/ssh-terminal-stale-grid-probe.ts new file mode 100644 index 00000000000..c246ab4a81d --- /dev/null +++ b/tests/e2e/ssh-terminal-stale-grid-probe.ts @@ -0,0 +1,159 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + type DockerSshRelayTarget, + writeDockerSshRelayTargetFile +} from './helpers/docker-ssh-relay-target' + +export const REMOTE_MONITOR_PATH = '/tmp/orca-idle-grid-monitor.mjs' +export const REMOTE_STATE_PATH = '/tmp/orca-idle-grid-state.json' + +export type Grid = { cols: number; rows: number } + +export type RemoteGridState = Grid & { + draws: number + pid: number + updatedAt: number + winches: number +} + +export type RendererGridState = { + applied: Grid | null + xterm: Grid | null +} + +export type ReproSample = { + cycle: number + elapsedMs: number + remote: RemoteGridState + renderer: RendererGridState +} + +function idleGridMonitorScript(): string { + return ` +import fs from 'node:fs' + +const statePath = process.argv[2] +let draws = 0 +let winches = 0 +let previousRow = null + +function readGrid() { + const [cols, rows] = process.stdout.getWindowSize() + return { cols, rows } +} + +function persist() { + const grid = readGrid() + const state = { ...grid, draws, pid: process.pid, updatedAt: Date.now(), winches } + const temporaryPath = statePath + '.' + process.pid + '.tmp' + fs.writeFileSync(temporaryPath, JSON.stringify(state)) + fs.renameSync(temporaryPath, statePath) + return grid +} + +function draw() { + draws += 1 + const { cols, rows } = persist() + const text = ('REMOTE_BOTTOM_BAR rows=' + rows + ' cols=' + cols + ' ' + '='.repeat(240)).slice(0, Math.max(1, cols - 1)) + const clearPrevious = previousRow && previousRow !== rows ? '\\x1b[' + previousRow + ';1H\\x1b[2K' : '' + process.stdout.write('\\x1b7' + clearPrevious + '\\x1b[' + rows + ';1H\\x1b[2K' + text + '\\x1b8') + previousRow = rows +} + +process.on('SIGWINCH', () => { + winches += 1 + draw() +}) + +draw() +setInterval(persist, 25) +setTimeout(() => process.exit(0), 600000) +` +} + +export function installIdleGridMonitor(target: DockerSshRelayTarget): void { + writeDockerSshRelayTargetFile(target, REMOTE_MONITOR_PATH, idleGridMonitorScript()) +} + +export function readRemoteGrid(target: DockerSshRelayTarget): RemoteGridState { + const json = execDockerSshRelayTargetCommand(target, `cat ${REMOTE_STATE_PATH}`) + return JSON.parse(json) as RemoteGridState +} + +export async function readRendererGrid(page: Page, ptyId: string): Promise { + return page.evaluate(async (id) => { + let xterm: Grid | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === id) { + xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows } + } + } + } + return { + applied: (await window.api.pty.getSize(id)) ?? null, + xterm + } + }, ptyId) +} + +export function actualGridMatchesXterm( + remote: RemoteGridState, + renderer: RendererGridState +): boolean { + return ( + renderer.xterm !== null && + renderer.xterm.cols > 0 && + renderer.xterm.rows > 0 && + remote.cols === renderer.xterm.cols && + remote.rows === renderer.xterm.rows + ) +} + +export async function sampleRemoteConvergence(args: { + cycle: number + page: Page + ptyId: string + target: DockerSshRelayTarget + timeoutMs?: number +}): Promise<{ last: ReproSample; stale: ReproSample[] }> { + const startedAt = Date.now() + const stale: ReproSample[] = [] + let last: ReproSample | null = null + while (Date.now() - startedAt < (args.timeoutMs ?? 6_000)) { + const remote = readRemoteGrid(args.target) + const renderer = await readRendererGrid(args.page, args.ptyId) + last = { + cycle: args.cycle, + elapsedMs: Date.now() - startedAt, + remote, + renderer + } + if (actualGridMatchesXterm(remote, renderer)) { + return { last, stale } + } + stale.push(last) + await args.page.waitForTimeout(100) + } + if (!last) { + throw new Error('Remote grid convergence sampling produced no samples') + } + return { last, stale } +} + +export async function attachStaleGridEvidence( + page: Page, + testInfo: TestInfo, + label: string, + samples: ReproSample[] +): Promise { + await testInfo.attach(`${label}.json`, { + body: Buffer.from(JSON.stringify(samples, null, 2)), + contentType: 'application/json' + }) + await testInfo.attach(`${label}.png`, { + body: await page.screenshot({ fullPage: true }), + contentType: 'image/png' + }) +} diff --git a/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts b/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts new file mode 100644 index 00000000000..dc0822e899b --- /dev/null +++ b/tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts @@ -0,0 +1,195 @@ +import type { Page } from '@stablyai/playwright-test' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + execDockerSshRelayTargetCommand, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { + actualGridMatchesXterm, + attachStaleGridEvidence, + installIdleGridMonitor, + readRemoteGrid, + readRendererGrid, + REMOTE_MONITOR_PATH, + REMOTE_STATE_PATH, + sampleRemoteConvergence, + type Grid +} from './ssh-terminal-stale-grid-probe' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const BASE_VIEWPORT = { width: 1160, height: 760 } + +async function startRemoteMonitor(page: Page, ptyId: string): Promise { + const marker = `ORCA_SSH_WAKE_READY_${Date.now()}` + await execInTerminal(page, ptyId, `printf '${marker}\\n'`) + await waitForTerminalOutput(page, marker, 20_000, 60_000) + await execInTerminal(page, ptyId, `node ${REMOTE_MONITOR_PATH} ${REMOTE_STATE_PATH}`) +} + +function chooseStaleGrid(current: Grid): Grid { + return { + cols: Math.max(40, current.cols - 19), + rows: Math.max(12, current.rows - 7) + } +} + +test.describe('SSH terminal window-wake stale PTY grid repro', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH repro.') + test.skip(process.platform === 'win32', 'Docker SSH repro uses POSIX SSH tooling.') + + test('window focus heals a remote PTY whose applied grid drifted from xterm', async ({ + orcaPage + }, testInfo) => { + test.setTimeout(240_000) + let target: DockerSshRelayTarget | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const pageErrors: string[] = [] + orcaPage.on('pageerror', (error) => pageErrors.push(error.message)) + installIdleGridMonitor(target) + await orcaPage.setViewportSize(BASE_VIEWPORT) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const identity = await orcaPage.evaluate(() => window.api.app.getIdentity()) + expect(identity.isDev).toBe(true) + expect(identity.devWorktreeName).toBe(path.basename(process.cwd())) + + await connectDockerSshRelayTarget(orcaPage, target, { relayGracePeriodSeconds: 300 }) + await ensureTerminalVisible(orcaPage, 60_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 60_000) + await startRemoteMonitor(orcaPage, ptyId) + await expect + .poll( + () => { + try { + return readRemoteGrid(target!).pid > 0 + } catch { + return false + } + }, + { timeout: 30_000, message: 'Idle remote grid monitor did not start' } + ) + .toBe(true) + + await expect + .poll( + async () => + actualGridMatchesXterm( + readRemoteGrid(target!), + await readRendererGrid(orcaPage, ptyId) + ), + { timeout: 15_000, message: 'Remote PTY and xterm did not establish a matching baseline' } + ) + .toBe(true) + + const baseline = await readRendererGrid(orcaPage, ptyId) + if (!baseline.xterm) { + throw new Error('Active xterm grid unavailable') + } + const staleGrid = chooseStaleGrid(baseline.xterm) + await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), { + id: ptyId, + grid: staleGrid + }) + await expect.poll(() => readRemoteGrid(target!).cols, { timeout: 5_000 }).toBe(staleGrid.cols) + await expect.poll(() => readRemoteGrid(target!).rows, { timeout: 5_000 }).toBe(staleGrid.rows) + + const drifted = await readRendererGrid(orcaPage, ptyId) + expect(drifted.xterm).toEqual(baseline.xterm) + expect(drifted.applied).toEqual(staleGrid) + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus'))) + const wakeResult = await sampleRemoteConvergence({ + cycle: 0, + page: orcaPage, + ptyId, + target, + timeoutMs: 3_000 + }) + + if (!actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer)) { + await attachStaleGridEvidence( + orcaPage, + testInfo, + 'ssh-window-focus-stale-grid', + wakeResult.stale + ) + // Manual resize is the field workaround and proves the remote channel + // can still deliver the corrective SIGWINCH. + await orcaPage.setViewportSize({ + width: BASE_VIEWPORT.width + 24, + height: BASE_VIEWPORT.height + 24 + }) + const manualResize = await sampleRemoteConvergence({ + cycle: 1, + page: orcaPage, + ptyId, + target, + timeoutMs: 6_000 + }) + expect(actualGridMatchesXterm(manualResize.last.remote, manualResize.last.renderer)).toBe( + true + ) + } + + expect( + actualGridMatchesXterm(wakeResult.last.remote, wakeResult.last.renderer), + `Window-focus recovery left the Linux PTY stale: ${JSON.stringify(wakeResult.last)}` + ).toBe(true) + expect(pageErrors).toEqual([]) + + const remoteGitStatus = execDockerSshRelayTargetCommand( + target, + `git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} status --short --branch` + ) + const remoteWorktrees = execDockerSshRelayTargetCommand( + target, + `git -C ${DOCKER_SSH_RELAY_REMOTE_REPO_PATH} worktree list --porcelain` + ) + expect(remoteGitStatus).toContain('## master') + expect(remoteWorktrees).toContain(DOCKER_SSH_RELAY_REMOTE_REPO_PATH) + expect( + execDockerSshRelayTargetCommand( + target, + `kill -0 ${wakeResult.last.remote.pid} && printf alive` + ) + ).toBe('alive') + + const evidence = { + identity, + target: { containerName: target.containerName, port: target.port }, + baseline, + injectedRemoteGrid: staleGrid, + recovered: wakeResult.last, + remoteGitStatus, + remoteWorktrees + } + console.log(`[ssh-window-wake-stale-grid] ${JSON.stringify(evidence)}`) + testInfo.annotations.push({ + type: 'ssh-window-wake-stale-grid', + description: JSON.stringify(evidence) + }) + const healedScreenshot = testInfo.outputPath('ssh-window-focus-healed.png') + await orcaPage.screenshot({ path: healedScreenshot, fullPage: true }) + await testInfo.attach('ssh-window-focus-healed.png', { + path: healedScreenshot, + contentType: 'image/png' + }) + } finally { + cleanupDockerSshRelayTarget(target) + } + }) +}) diff --git a/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts b/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts new file mode 100644 index 00000000000..d76e242132f --- /dev/null +++ b/tests/e2e/terminal-window-wake-stale-grid-repro.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +type Grid = { cols: number; rows: number } +type GridSnapshot = { applied: Grid | null; xterm: Grid | null } + +async function readGridSnapshot(page: Page, ptyId: string): Promise { + return page.evaluate(async (id) => { + let xterm: Grid | null = null + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === id) { + xterm = { cols: pane.terminal.cols, rows: pane.terminal.rows } + } + } + } + return { applied: (await window.api.pty.getSize(id)) ?? null, xterm } + }, ptyId) +} + +function chooseStaleGrid(current: Grid): Grid { + return { + cols: Math.max(40, current.cols - 19), + rows: Math.max(12, current.rows - 7) + } +} + +test.describe('terminal window-wake stale grid repro', () => { + test('window focus heals a local PTY whose applied grid drifted from xterm', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + + const baseline = await readGridSnapshot(orcaPage, ptyId) + expect(baseline.xterm).not.toBeNull() + expect(baseline.applied).toEqual(baseline.xterm) + const staleGrid = chooseStaleGrid(baseline.xterm!) + + // Why: model the field state directly—xterm is fitted, but the idle PTY + // still has an older grid and produces no output that could self-heal it. + await orcaPage.evaluate(({ id, grid }) => window.api.pty.resize(id, grid.cols, grid.rows), { + id: ptyId, + grid: staleGrid + }) + await expect + .poll(async () => (await readGridSnapshot(orcaPage, ptyId)).applied, { timeout: 10_000 }) + .toEqual(staleGrid) + expect((await readGridSnapshot(orcaPage, ptyId)).xterm).toEqual(baseline.xterm) + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('focus'))) + + await expect + .poll( + async () => { + const snapshot = await readGridSnapshot(orcaPage, ptyId) + return snapshot.applied && snapshot.xterm ? snapshot : null + }, + { timeout: 10_000, message: 'Window focus should converge the local PTY to xterm' } + ) + .toEqual({ applied: baseline.xterm, xterm: baseline.xterm }) + }) +})