mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(terminal): foreground preserved daemon PTYs
This commit is contained in:
@@ -4196,23 +4196,26 @@
|
||||
"snapshot-probe sequence gap recovery",
|
||||
"hidden remote worktree retention",
|
||||
"remote terminal reveal and input",
|
||||
"paired host relaunch with preserved daemon PTYs",
|
||||
"manual server disconnect"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["paired-runtime", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8652",
|
||||
"https://github.com/stablyai/orca/pull/10625"
|
||||
],
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
|
||||
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
@@ -4228,6 +4231,7 @@
|
||||
"src/main/ipc/runtime-environments.test.ts",
|
||||
"src/main/runtime/rpc/terminal-multiplex.test.ts",
|
||||
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
@@ -4248,9 +4252,24 @@
|
||||
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts",
|
||||
"tests/e2e/terminal-parked-memory.spec.ts"
|
||||
"tests/e2e/terminal-parked-memory.spec.ts",
|
||||
"tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"foregrounds a preserved daemon PTY after handler recreation loses sync memory exactly once"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts",
|
||||
"assertions": [
|
||||
"preserves daemon PID and target PTY identity across the paired host relaunch",
|
||||
"foregrounds the daemon-backgrounded target on its first post-relaunch remote observation",
|
||||
"delivers target and sibling terminal input and live output after relaunch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts",
|
||||
"assertions": ["replaces a stale paired stream when the PTY snapshot advanced"]
|
||||
@@ -4379,6 +4398,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12.8,
|
||||
"summary": "The isolated headed host and separate paired desktop client retained the daemon PID and PTY ID across host relaunch, emitted one target foreground transition, and delivered target plus sibling input/output. The byte-identical run with only the unknown-state fix reverted failed at the missing target foreground transition."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"runner": "local",
|
||||
|
||||
@@ -637,6 +637,7 @@ describe('registerPtyHandlers', () => {
|
||||
const write = vi.fn()
|
||||
const pauseProducer = vi.fn()
|
||||
const resumeProducer = vi.fn()
|
||||
const setPtyBackgrounded = vi.fn()
|
||||
const shutdown = vi.fn()
|
||||
let dataHandler: ((payload: { id: string; data: string }) => void) | null = null
|
||||
let exitHandler: ((payload: { id: string; code: number }) => void) | null = null
|
||||
@@ -650,6 +651,7 @@ describe('registerPtyHandlers', () => {
|
||||
resize: vi.fn(),
|
||||
pauseProducer,
|
||||
resumeProducer,
|
||||
setPtyBackgrounded,
|
||||
kill: vi.fn(),
|
||||
shutdown,
|
||||
sendSignal: vi.fn(),
|
||||
@@ -688,6 +690,7 @@ describe('registerPtyHandlers', () => {
|
||||
write,
|
||||
pauseProducer,
|
||||
resumeProducer,
|
||||
setPtyBackgrounded,
|
||||
shutdown,
|
||||
getBufferSnapshot,
|
||||
emitData: (id: string, data: string) => dataHandler?.({ id, data }),
|
||||
@@ -15608,6 +15611,49 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
|
||||
describe('hidden renderer delivery gate', () => {
|
||||
it('foregrounds a preserved daemon PTY after handler recreation loses sync memory', async () => {
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
const firstRuntime = {
|
||||
setPtyController: vi.fn(),
|
||||
hasRawTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn()
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, firstRuntime as never)
|
||||
const result = (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'daemon-session'
|
||||
})) as { id: string }
|
||||
|
||||
getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: false })
|
||||
expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, true)
|
||||
|
||||
daemon.setPtyBackgrounded.mockClear()
|
||||
handlers.clear()
|
||||
let rawSubscriberPresent = false
|
||||
const nextRuntime = {
|
||||
setPtyController: vi.fn(),
|
||||
hasRawTerminalViewSubscriber: vi.fn(() => rawSubscriberPresent),
|
||||
onRemoteTerminalViewPresenceChanged: null as ((id: string) => void) | null,
|
||||
registerRawTerminalViewSubscriber(id: string): void {
|
||||
rawSubscriberPresent = true
|
||||
this.onRemoteTerminalViewPresenceChanged?.(id)
|
||||
}
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, nextRuntime as never)
|
||||
|
||||
nextRuntime.registerRawTerminalViewSubscriber(result.id)
|
||||
nextRuntime.onRemoteTerminalViewPresenceChanged?.(result.id)
|
||||
|
||||
expect(daemon.setPtyBackgrounded).toHaveBeenCalledOnce()
|
||||
expect(daemon.setPtyBackgrounded).toHaveBeenCalledWith(result.id, false)
|
||||
})
|
||||
|
||||
it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => {
|
||||
vi.useFakeTimers()
|
||||
const runtime = {
|
||||
|
||||
+1
-1
@@ -2516,7 +2516,7 @@ export function registerPtyHandlers(
|
||||
function syncPtyBackgroundedDelivery(id: string, caller: string): void {
|
||||
const background =
|
||||
rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false)
|
||||
if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) {
|
||||
if (backgroundedDeliverySyncByPty.get(id) === background) {
|
||||
return
|
||||
}
|
||||
const provider = tryGetProviderForPty(id)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
|
||||
import {
|
||||
HOST_TERMINAL_SURFACE_SEPARATOR,
|
||||
toWebTerminalSurfaceTabId
|
||||
} from '../../src/shared/terminal-surface-id'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { TEST_REPO_PATH_FILE } from './global-setup'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { waitForTabParked } from './helpers/terminal-hidden-parking'
|
||||
|
||||
const PARK_DELAY_MS = 2_000
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-paired-host-restart-background-'))
|
||||
const fixturePath = path.join(scratch, 'paired-host-restart-terminal.mjs')
|
||||
const backlogPath = path.join(scratch, 'daemon-stream-backlog.jsonl')
|
||||
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
"import { appendFileSync } from 'node:fs'",
|
||||
'const sink = process.argv[2]',
|
||||
"process.stdout.write('READY\\r\\n')",
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
"let pending = ''",
|
||||
"process.stdin.on('data', (data) => {",
|
||||
' pending += data',
|
||||
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
|
||||
" pending = lines.pop() ?? ''",
|
||||
' for (const line of lines) {',
|
||||
' appendFileSync(sink, `${line}\\n`)',
|
||||
' process.stdout.write(`LIVE:${line}\\r\\n`)',
|
||||
' }',
|
||||
'})',
|
||||
'process.stdin.resume()'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
test.afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
function fixtureCommand(sinkPath: string): string {
|
||||
const command = [process.execPath, fixturePath, sinkPath]
|
||||
return process.platform === 'win32'
|
||||
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
|
||||
: command.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
function seededRepoPathOrSkip(): string {
|
||||
const repoPath = existsSync(TEST_REPO_PATH_FILE)
|
||||
? readFileSync(TEST_REPO_PATH_FILE, 'utf8').trim()
|
||||
: ''
|
||||
test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo')
|
||||
return repoPath
|
||||
}
|
||||
|
||||
function readDaemonPid(userDataDir: string): number {
|
||||
const value = JSON.parse(
|
||||
readFileSync(path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), 'utf8')
|
||||
) as { pid?: unknown }
|
||||
if (typeof value.pid !== 'number') {
|
||||
throw new Error('Daemon pid file did not contain a numeric pid')
|
||||
}
|
||||
return value.pid
|
||||
}
|
||||
|
||||
type BacklogEntry = {
|
||||
atMs?: number
|
||||
background?: boolean
|
||||
backgroundedSessionIdSuffixes?: string[]
|
||||
event?: string
|
||||
sessionIdSuffix?: string
|
||||
}
|
||||
|
||||
function readBacklogEntries(): BacklogEntry[] {
|
||||
try {
|
||||
return readFileSync(backlogPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as BacklogEntry]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function readText(filePath: string): string {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function callRuntime<TResult>(
|
||||
page: Page,
|
||||
environmentId: string,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<TResult> {
|
||||
return page.evaluate(
|
||||
async ({ environmentId, method, params }) => {
|
||||
const response = await window.api.runtimeEnvironments.call({
|
||||
selector: environmentId,
|
||||
method,
|
||||
params
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.error.code}: ${response.error.message}`)
|
||||
}
|
||||
return response.result
|
||||
},
|
||||
{ environmentId, method, params }
|
||||
) as Promise<TResult>
|
||||
}
|
||||
|
||||
type HostTerminal = {
|
||||
handle: string
|
||||
parentTabId: string
|
||||
ptyId: string
|
||||
sinkPath: string
|
||||
webTabId: string
|
||||
}
|
||||
|
||||
async function createHostTerminal(
|
||||
client: PairedElectronClient,
|
||||
worktreeId: string,
|
||||
name: string
|
||||
): Promise<HostTerminal> {
|
||||
const sinkPath = path.join(scratch, `${name}.log`)
|
||||
const created = await callRuntime<{
|
||||
tab: { id: string; parentTabId: string; terminal: string | null }
|
||||
}>(client.page, client.environmentId, 'session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
command: fixtureCommand(sinkPath),
|
||||
activate: false,
|
||||
select: false,
|
||||
navigation: 'caller'
|
||||
})
|
||||
if (!created.tab.terminal) {
|
||||
throw new Error('Host did not publish the fixture terminal')
|
||||
}
|
||||
const shown = await callRuntime<{ terminal: { ptyId: string | null } }>(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
'terminal.show',
|
||||
{ terminal: created.tab.terminal }
|
||||
)
|
||||
if (!shown.terminal.ptyId) {
|
||||
throw new Error('Host fixture terminal has no PTY')
|
||||
}
|
||||
const parentTabId =
|
||||
created.tab.parentTabId || created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]
|
||||
return {
|
||||
handle: created.tab.terminal,
|
||||
parentTabId,
|
||||
ptyId: shown.terminal.ptyId,
|
||||
sinkPath,
|
||||
webTabId: toWebTerminalSurfaceTabId(parentTabId)
|
||||
}
|
||||
}
|
||||
|
||||
async function findTerminalHandle(
|
||||
client: PairedElectronClient,
|
||||
worktreeId: string,
|
||||
parentTabId: string
|
||||
): Promise<string> {
|
||||
let terminal: string | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await callRuntime<{
|
||||
tabs: { type: string; parentTabId?: string; terminal?: string | null }[]
|
||||
}>(client.page, client.environmentId, 'session.tabs.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
terminal =
|
||||
snapshot.tabs.find((tab) => tab.type === 'terminal' && tab.parentTabId === parentTabId)
|
||||
?.terminal ?? null
|
||||
return terminal
|
||||
},
|
||||
{ timeout: 30_000, message: `Host did not republish terminal tab ${parentTabId}` }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (!terminal) {
|
||||
throw new Error(`Host did not republish terminal tab ${parentTabId}`)
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
async function openClientTab(page: Page, worktreeId: string, webTabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
({ worktreeId, webTabId }) =>
|
||||
(window.__store?.getState().tabsByWorktree[worktreeId] ?? []).some(
|
||||
(tab) => tab.id === webTabId
|
||||
),
|
||||
{ worktreeId, webTabId }
|
||||
),
|
||||
{ timeout: 60_000, message: `Client never mirrored host tab ${webTabId}` }
|
||||
)
|
||||
.toBe(true)
|
||||
await page.evaluate(
|
||||
({ worktreeId, webTabId }) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(worktreeId)
|
||||
state?.setActiveTab(webTabId)
|
||||
state?.setActiveTabType('terminal')
|
||||
},
|
||||
{ worktreeId, webTabId }
|
||||
)
|
||||
await expect
|
||||
.poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, webTabId), {
|
||||
timeout: 60_000,
|
||||
message: `Client pane for ${webTabId} did not mount`
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function readPaneContent(page: Page, webTabId: string): Promise<string> {
|
||||
return page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.serializeAddon?.serialize?.() ?? ''
|
||||
}, webTabId)
|
||||
}
|
||||
|
||||
async function waitForPaneConnected(page: Page, webTabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
return pane?.container.dataset.ptyRecoveryState ?? null
|
||||
}, webTabId),
|
||||
{ timeout: 30_000, message: `Pane ${webTabId} never completed transport recovery` }
|
||||
)
|
||||
.toBe('connected')
|
||||
}
|
||||
|
||||
async function expectTerminalInteractive(
|
||||
client: PairedElectronClient,
|
||||
target: HostTerminal,
|
||||
marker: string
|
||||
): Promise<void> {
|
||||
await client.page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error(`No pane mounted for ${id}`)
|
||||
}
|
||||
pane.terminal.focus()
|
||||
const textarea = pane.container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement
|
||||
textarea.focus()
|
||||
}, target.webTabId)
|
||||
await client.page.keyboard.type(marker)
|
||||
await client.page.keyboard.press('Enter')
|
||||
await expect.poll(() => readText(target.sinkPath), { timeout: 15_000 }).toContain(marker)
|
||||
await expect
|
||||
.poll(() => readPaneContent(client.page, target.webTabId), { timeout: 15_000 })
|
||||
.toContain(`LIVE:${marker}`)
|
||||
}
|
||||
|
||||
async function moveHostAwayFromWorktree(page: Page, targetWorktreeId: string): Promise<string> {
|
||||
const alternateWorktreeId = await page.evaluate((targetId) => {
|
||||
const state = window.__store?.getState()
|
||||
const alternate = state?.allWorktrees().find((worktree) => worktree.id !== targetId)
|
||||
if (!state || !alternate) {
|
||||
return null
|
||||
}
|
||||
state.setActiveView('editor')
|
||||
state.setActiveWorktree(alternate.id)
|
||||
return alternate.id
|
||||
}, targetWorktreeId)
|
||||
if (!alternateWorktreeId) {
|
||||
throw new Error('Host fixture needs a second worktree for inactive-workspace restart coverage')
|
||||
}
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__store?.getState().activeWorktreeId ?? null))
|
||||
.toBe(alternateWorktreeId)
|
||||
return alternateWorktreeId
|
||||
}
|
||||
|
||||
test('foregrounds a preserved daemon PTY after the paired host relaunches', async (// oxlint-disable-next-line no-empty-pattern -- This lifecycle test owns both host launches.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(360_000)
|
||||
const repoPath = seededRepoPathOrSkip()
|
||||
writeFileSync(backlogPath, '')
|
||||
const session = createRestartSession(testInfo, {
|
||||
ORCA_DAEMON_STREAM_BACKLOG_FILE: backlogPath
|
||||
})
|
||||
const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
|
||||
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
|
||||
let firstHost: ElectronApplication | null = null
|
||||
let secondHost: ElectronApplication | null = null
|
||||
let client: PairedElectronClient | null = null
|
||||
const terminals: HostTerminal[] = []
|
||||
try {
|
||||
const first = await session.launch()
|
||||
firstHost = first.app
|
||||
const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
|
||||
const daemonPid = readDaemonPid(session.userDataDir)
|
||||
client = await launchPairedElectronClient(
|
||||
await createRuntimeDesktopPairingOffer(first.page),
|
||||
testInfo,
|
||||
'host-restart-background-sync'
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client!.page.evaluate(
|
||||
(id) =>
|
||||
window.__store
|
||||
?.getState()
|
||||
.allWorktrees()
|
||||
.some((worktree) => worktree.id === id) ?? false,
|
||||
worktreeId
|
||||
),
|
||||
{ timeout: 60_000, message: 'Paired client never saw the host worktree' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const target = await createHostTerminal(client, worktreeId, 'target')
|
||||
const sibling = await createHostTerminal(client, worktreeId, 'sibling')
|
||||
const parkingDecoy = await createHostTerminal(client, worktreeId, 'parking-decoy')
|
||||
terminals.push(target, sibling, parkingDecoy)
|
||||
await openClientTab(client.page, worktreeId, target.webTabId)
|
||||
await expect
|
||||
.poll(() => readPaneContent(client!.page, target.webTabId), { timeout: 30_000 })
|
||||
.toContain('READY')
|
||||
await openClientTab(client.page, worktreeId, sibling.webTabId)
|
||||
await openClientTab(client.page, worktreeId, parkingDecoy.webTabId)
|
||||
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
|
||||
|
||||
const targetSuffix = target.ptyId.slice(-10)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
readBacklogEntries()
|
||||
.findLast((entry) => entry.backgroundedSessionIdSuffixes)
|
||||
?.backgroundedSessionIdSuffixes?.includes(targetSuffix) ?? false,
|
||||
{ timeout: 15_000, message: 'Daemon never retained the target background hint' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const alternateHostWorktreeId = await moveHostAwayFromWorktree(first.page, worktreeId)
|
||||
await session.close(firstHost)
|
||||
firstHost = null
|
||||
const foregroundEventStart = readBacklogEntries().length
|
||||
const second = await session.launch()
|
||||
secondHost = second.app
|
||||
await second.page.waitForFunction(
|
||||
(expectedId) =>
|
||||
window.__store?.getState().workspaceSessionReady === true &&
|
||||
window.__store?.getState().activeWorktreeId === expectedId,
|
||||
alternateHostWorktreeId,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
expect(readDaemonPid(session.userDataDir), 'daemon must survive the host relaunch').toBe(
|
||||
daemonPid
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client!.page.evaluate(async (selector) => {
|
||||
const response = await window.api.runtimeEnvironments.connect({ selector })
|
||||
return response.ok
|
||||
}, client!.environmentId),
|
||||
{ timeout: 60_000, message: 'Paired client never reconnected to the relaunched host' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId)
|
||||
const shown = await callRuntime<{ terminal: { ptyId: string | null } }>(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
'terminal.show',
|
||||
{ terminal: target.handle }
|
||||
)
|
||||
expect(shown.terminal.ptyId).toBe(target.ptyId)
|
||||
await openClientTab(client.page, worktreeId, target.webTabId)
|
||||
await waitForPaneConnected(client.page, target.webTabId)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
readBacklogEntries()
|
||||
.slice(foregroundEventStart)
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.event === 'setSessionBackground' &&
|
||||
entry.sessionIdSuffix === targetSuffix &&
|
||||
entry.background === false
|
||||
),
|
||||
{ timeout: 15_000, message: 'Relaunched host never foregrounded the preserved PTY' }
|
||||
)
|
||||
.toBe(true)
|
||||
await expectTerminalInteractive(client, target, `TARGET_AFTER_RESTART_${Date.now()}`)
|
||||
|
||||
sibling.handle = await findTerminalHandle(client, worktreeId, sibling.parentTabId)
|
||||
const shownSibling = await callRuntime<{ terminal: { ptyId: string | null } }>(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
'terminal.show',
|
||||
{ terminal: sibling.handle }
|
||||
)
|
||||
expect(shownSibling.terminal.ptyId).toBe(sibling.ptyId)
|
||||
await openClientTab(client.page, worktreeId, sibling.webTabId)
|
||||
await waitForPaneConnected(client.page, sibling.webTabId)
|
||||
await expectTerminalInteractive(client, sibling, `SIBLING_AFTER_RESTART_${Date.now()}`)
|
||||
} finally {
|
||||
if (client) {
|
||||
for (const terminal of terminals) {
|
||||
await callRuntime(client.page, client.environmentId, 'terminal.closeTab', {
|
||||
terminal: terminal.handle
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
await client.dispose()
|
||||
}
|
||||
if (secondHost) {
|
||||
await session.close(secondHost)
|
||||
}
|
||||
if (firstHost) {
|
||||
await session.close(firstHost)
|
||||
}
|
||||
await session.dispose()
|
||||
if (previousParkDelay === undefined) {
|
||||
delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
|
||||
} else {
|
||||
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user