fix(terminal): bound window-wake size recovery

This commit is contained in:
Brennan Benson
2026-07-20 17:23:37 -07:00
parent 08f0c6432a
commit 04221b5124
6 changed files with 113 additions and 31 deletions
+48 -15
View File
@@ -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."
],
+12 -3
View File
@@ -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 ||
+1 -1
View File
@@ -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 () => {
@@ -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) {
@@ -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
)
})
@@ -12,6 +12,10 @@ type UseTerminalWindowWakeRecoveryArgs = {
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
}
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