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 0d0b79b6b68..97ec4549417 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..6c7015e8e59 --- /dev/null +++ b/src/main/providers/ssh-pty-applied-size.ts @@ -0,0 +1,40 @@ +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 } + +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) + })) 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..79daee624f8 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' }) + }) + + 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/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..2f4b5181c29 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,28 @@ describe('useTerminalWindowWakeRecovery', () => { ]) }) + it('reasserts pane PTY sizes after the window-focus fit', () => { + const noteVisibilityResume = vi.fn() + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true }, + panePtyBindingsRef: { + current: new Map([[1, { dispose: vi.fn(), noteVisibilityResume }]]) as never + } + }) + ) + + window.dispatchEvent(new Event('focus')) + + expect(noteVisibilityResume).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock.mock.invocationCallOrder[0]).toBeLessThan( + noteVisibilityResume.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..fab461def01 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,22 @@ 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> } export function useTerminalWindowWakeRecovery({ isVisible, managerRef, isActiveRef, - isVisibleRef + isVisibleRef, + panePtyBindingsRef }: UseTerminalWindowWakeRecoveryArgs): void { useEffect(() => { if (!isVisible) { @@ -59,6 +62,11 @@ 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') { return } @@ -76,6 +84,9 @@ export function useTerminalWindowWakeRecovery({ isActive: isActiveRef.current, clearGlyphAtlases: clearGlyphAtlasesOnSettle }) + for (const binding of panePtyBindingsRef?.current.values() ?? []) { + ;(binding as IDisposable & { noteVisibilityResume?: () => void }).noteVisibilityResume?.() + } }) } // Why: plain refocus (alt-tab, devtools) is frequent and often lands while @@ -115,5 +126,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 }) + }) +})