From 04221b512498a84155c01eba6b9e2a575effc848 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:23:37 -0700 Subject: [PATCH] fix(terminal): bound window-wake size recovery --- config/reliability-gates.jsonc | 63 ++++++++++++++----- src/main/providers/ssh-pty-applied-size.ts | 15 ++++- src/main/providers/ssh-pty-provider.test.ts | 2 +- .../terminal-pane/pty-connection.ts | 4 ++ .../use-terminal-window-wake-recovery.test.ts | 39 ++++++++++-- .../use-terminal-window-wake-recovery.ts | 21 ++++--- 6 files changed, 113 insertions(+), 31 deletions(-) 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/providers/ssh-pty-applied-size.ts b/src/main/providers/ssh-pty-applied-size.ts index 6c7015e8e59..f725f7c2a09 100644 --- a/src/main/providers/ssh-pty-applied-size.ts +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -3,6 +3,8 @@ 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, @@ -14,9 +16,16 @@ export function createSshPtyAppliedSizeReader( return null } try { - const result = (await mux.request('pty.getSize', { - id: toRelaySshPtyId(connectionId, id) - })) as { cols?: unknown; rows?: unknown } | null + 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 || diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 79daee624f8..4a2bf02546a 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -474,7 +474,7 @@ describe('SshPtyProvider', () => { 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' }) + expect(mux.request).toHaveBeenCalledWith('pty.getSize', { id: 'pty-1' }, { timeoutMs: 1_000 }) }) it('caches only an old relay method-not-found response', async () => { 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-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index 2f4b5181c29..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 @@ -98,7 +98,7 @@ describe('useTerminalWindowWakeRecovery', () => { }) it('reasserts pane PTY sizes after the window-focus fit', () => { - const noteVisibilityResume = vi.fn() + const reassertPtySizeAfterWindowWake = vi.fn() renderHook(() => useTerminalWindowWakeRecovery({ isVisible: true, @@ -106,16 +106,47 @@ describe('useTerminalWindowWakeRecovery', () => { isActiveRef: { current: true }, isVisibleRef: { current: true }, panePtyBindingsRef: { - current: new Map([[1, { dispose: vi.fn(), noteVisibilityResume }]]) as never + current: new Map([[1, { dispose: vi.fn(), reassertPtySizeAfterWindowWake }]]) as never } }) ) window.dispatchEvent(new Event('focus')) - expect(noteVisibilityResume).toHaveBeenCalledTimes(1) + expect(reassertPtySizeAfterWindowWake).toHaveBeenCalledTimes(1) expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder[0]).toBeLessThan( - noteVisibilityResume.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + 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 ) }) 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 fab461def01..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 @@ -12,6 +12,10 @@ type UseTerminalWindowWakeRecoveryArgs = { panePtyBindingsRef?: React.RefObject> } +type WindowWakePtyBinding = IDisposable & { + reassertPtySizeAfterWindowWake?: () => void +} + export function useTerminalWindowWakeRecovery({ isVisible, managerRef, @@ -33,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' @@ -62,12 +73,8 @@ export function useTerminalWindowWakeRecovery({ isActive: isActiveRef.current, clearGlyphAtlases }) - for (const binding of panePtyBindingsRef?.current.values() ?? []) { - // Why: fit-only wake recovery cannot detect a dropped remote resize - // when xterm is already at the desired grid; verify after fitting. - ;(binding as IDisposable & { noteVisibilityResume?: () => void }).noteVisibilityResume?.() - } if (typeof requestAnimationFrame !== 'function') { + reassertPanePtySizes() return } settledClearGlyphAtlases = clearGlyphAtlases @@ -84,9 +91,7 @@ export function useTerminalWindowWakeRecovery({ isActive: isActiveRef.current, clearGlyphAtlases: clearGlyphAtlasesOnSettle }) - for (const binding of panePtyBindingsRef?.current.values() ?? []) { - ;(binding as IDisposable & { noteVisibilityResume?: () => void }).noteVisibilityResume?.() - } + reassertPanePtySizes() }) } // Why: plain refocus (alt-tab, devtools) is frequent and often lands while