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
This commit is contained in:
Brennan Benson
2026-07-20 18:54:44 -07:00
committed by GitHub
parent dd20c11e9d
commit 9680b22e60
16 changed files with 680 additions and 21 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
View File
@@ -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({
+6 -3
View File
@@ -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.
@@ -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<AppliedPtySize | null> {
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
}
}
}
@@ -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(
+3
View File
@@ -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<IPtyProvider['getAppliedSize']>
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) => {
+18
View File
@@ -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({
+11
View File
@@ -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<string, unknown>
): 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<string, unknown>): Promise<void> {
const id = params.id as string
const immediate = params.immediate as boolean
@@ -1720,6 +1720,7 @@ export default function TerminalPane({
managerRef,
containerRef,
paneTransportsRef,
panePtyBindingsRef,
isActiveRef,
isVisibleRef,
toggleExpandPane
@@ -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) {
@@ -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<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
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(() => {
@@ -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)
@@ -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<PaneManager | null>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
}
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])
}
+159
View File
@@ -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<RendererGridState> {
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<void> {
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'
})
}
@@ -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<void> {
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)
}
})
})
@@ -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<GridSnapshot> {
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 })
})
})