mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(terminal): heal stale PTY grids on window wake
This commit is contained in:
@@ -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
@@ -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,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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user