fix(terminal): heal stale PTY grids on window wake

This commit is contained in:
Brennan Benson
2026-07-20 16:59:51 -07:00
parent bf7fbdd57b
commit 08f0c6432a
14 changed files with 583 additions and 6 deletions
+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,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<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)
})) 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' })
})
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
@@ -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,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)
@@ -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<PaneManager | null>
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
panePtyBindingsRef?: React.RefObject<Map<number, IDisposable>>
}
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])
}
+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 })
})
})