fix(remote-runtime): recover terminals after network loss (#9774)

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
OrcaWin 2026-07-21 18:18:45 -04:00 committed by GitHub
parent e986a7ba1a
commit 54c1ec5e7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2776 additions and 258 deletions

View File

@ -1330,7 +1330,7 @@
],
"evidenceRuns": [
{
"date": "2026-07-20",
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts",
@ -2385,7 +2385,7 @@
},
{
"id": "terminal-provider.ssh-remote-reattach-contract",
"title": "SSH and remote terminal restore never turn unknown liveness into destructive cleanup",
"title": "SSH restore and remote-runtime recovery preserve live terminal ownership",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-provider",
@ -2393,6 +2393,8 @@
"surfaces": [
"SSH deferred restore",
"remote-runtime mirror polling",
"remote-runtime network recovery",
"terminal create idempotency",
"provider listing",
"reattach",
"unknown liveness"
@ -2414,7 +2416,7 @@
"ssh",
"remote-runtime"
],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, remote or unresolved hosts cannot enter local-daemon cold activation deferral, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime web mirror replaces a stale handle without retiring its pane or accumulating obsolete PTY identities. Bounded polling falls back to accepted host snapshots without a global store subscription, and concurrent panes share each in-flight inventory request. A macOS Electron journey connects to a real Linux SSH relay in Docker, persists six terminals, reloads the renderer, and verifies all six original relay PTYs remount eagerly and still execute remote input. Linux and Windows desktop clients, WSL, and live remote-runtime restore remain gaps.",
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, stale cleanup cannot unregister a replacement runtime terminal, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Capability-gated create retries adopt a provider-owned PTY by stable terminal identity after an unknown outcome or runtime-process restart, stop retrying after one minute without a fatal error, and remain manually retryable without accepting stale create completions. A macOS Electron journey covers live SSH restore; a Windows remote-runtime smoke covers reachability and PTY round-trip. A live partition journey using patched Mac and Windows builds remains a gap.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
@ -2422,10 +2424,11 @@
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Snapshot-backed cold activation deferral is local-daemon-only; every restored remote terminal must mount a manager eagerly and preserve its provider PTY identity. A stale remote-runtime mirror handle must keep its pane mounted until a replacement or explicit terminal-removal fact arrives, and each replacement must atomically supersede rather than accumulate PTY identity.",
"oracle": "Deterministic tests assert startup publishes ssh.connect's authoritative state before terminal reconnect, only an explicit local execution host can defer cold activation, host ownership changes clear old restrictions, replacement runtime registrations survive stale cleanup, stale mirror polling is bounded, and replacement handles resubscribe without exit/disconnect callbacks or stale PTY index growth. Count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind. The live Docker SSH journey commits six terminal records to the relay, reloads Electron, requires the exact six manager ids with no parked tabs, compares all restored PTY ids to their pre-reload identities, and sends a marker through the restored terminal to a proof file on the Linux host.",
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Every restored remote terminal must preserve its provider PTY identity. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, and manual reconnect. They assert one unsubscribe per epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, one-minute create-retry cutoff, old-runtime no-retry behavior, authenticated client/worktree isolation, cross-process PTY adoption without rerunning startup, unavailable or legacy-incomplete inventory fail-closed behavior, and bounded in-flight coordination. Existing count tests prove concurrent panes share one in-flight inventory request per runtime/worktree and accepted-snapshot listeners are identity-scoped and released after rebind.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.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/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"pnpm exec electron-vite build --mode e2e",
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-cold-activation-deferral.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_SSH_DOCKER=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-cold-activation-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
@ -2438,9 +2441,19 @@
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.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/remote-runtime-pty-recovery-state.test.ts",
"src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx",
"src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts",
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-session-terminal-handle-events.test.ts",
"src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
"src/shared/remote-runtime-socket-liveness.test.ts",
"src/shared/remote-runtime-shared-control-connection.test.ts",
"src/shared/remote-runtime-shared-control-socket-generation.test.ts",
"src/shared/remote-runtime-client-error-classification.test.ts",
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts",
"src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"tests/e2e/terminal-cold-activation-deferral.spec.ts",
"tests/e2e/ssh-cold-activation-restore.spec.ts"
],
@ -2463,7 +2476,53 @@
"assertions": [
"a stale web-mirror handle polls until a different ready handle is published without resubscribing the stale handle",
"replacement does not emit pane exit or disconnect callbacks and explicit terminal exit still retires the mirror",
"replacement polling and each in-flight request share a 15-second deadline, then accepted snapshots own recovery without input re-arming polling"
"replacement polling and each in-flight request share a 15-second deadline, then accepted snapshots own recovery without input re-arming polling",
"cold restored-terminal subscription failure retries and resumes snapshot, output, and input without a fatal error",
"a canonical close before subscription readiness opens exactly one replacement stream without surfacing a fatal error",
"recovery exposes connecting, recovering, backoff, connected, and fatal-offline phases with fenced epochs",
"ten partition cycles retain one PTY identity, reject detached input, and unsubscribe each epoch exactly once",
"cached terminal pixels remain disconnected until authoritative replay completes",
"automatic retries stop after one minute with no fatal error, no further requests, and no stale input",
"manual reconnect starts a new epoch, resubscribes the same PTY exactly once, and becomes healthy only after its snapshot",
"unknown terminal-create recovery stops all request activity after one minute without a fatal error, clips post-probe RPC timeouts to the remaining budget, then manual retry re-probes capability and reconciles the same mutation",
"a stale create completion cannot replace or close a newer attachment, including when two runtimes use the same raw handle",
"an authoritative capability-probe failure replaces the stale connectivity error"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts",
"assertions": [
"a healthy replacement invalidates the prior recovery epoch so a slower failure cannot re-arm retry",
"the automatic-recovery deadline cancels pending backoff and a manual retry owns a new epoch",
"a caller-owned recovery cutoff cancels scheduled work and remains disconnected"
]
},
{
"file": "src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx",
"assertions": [
"automatic recovery renders as a quiet bounded status without a button",
"the disconnected state exposes one explicit Reconnect action"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts",
"assertions": [
"only actionable recovery phases retain UI state and pane closure removes disconnected state"
]
},
{
"file": "src/shared/remote-runtime-socket-liveness.test.ts",
"assertions": [
"a suspended client receives a fresh probe deadline after resume and post-resume activity clears it"
]
},
{
"file": "src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"assertions": [
"one authenticated client/worktree mutation derives a stable server-owned terminal handle",
"an unknown-outcome retry adopts the same provider PTY after runtime-process restart without rerunning startup",
"retry inventory failure, missing same-worktree identity metadata, or ownership mismatch fails closed without spawning",
"in-flight mutation coordination is bounded and releases capacity after settlement"
]
},
{
@ -2513,6 +2572,15 @@
"result": "passed",
"durationSeconds": 15,
"summary": "Ten provider-contract files and 566 tests passed, including a shared 15-second polling/RPC deadline, no input-triggered re-polling after the bound, post-timeout snapshot recovery, listener cleanup, in-flight inventory deduplication, and replacement-state migration."
},
{
"date": "2026-07-21",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
"result": "passed",
"durationSeconds": 5,
"summary": "Eleven fault-injection and recovery-UI files and 127 tests passed, covering suspended heartbeat clocks, stale socket, PTY, and create generations, canonical pre-ready close recovery with one replacement subscription, cold and repeated PTY reattachment, authoritative health, bounded PTY and terminal-create recovery, post-probe timeout clipping, manually retryable create cutoff, accurate capability-probe failures, same-PTY manual reconnect, pane-state cleanup, fatal error deduplication, stable create identity, cross-process PTY adoption, and fail-closed legacy inventory."
}
],
"runtimeBudget": {
@ -2525,11 +2593,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert SSH attach uses relay pty.attach for saved sessions, expired relay attach does not fresh-spawn in the provider, a failed SSH listProcesses observation does not clear previously learned ownership, a rejected SSH hasPty probe resolves as unknown liveness, deferred SSH passphrase cancellation does not auto-reconnect, deferred attach uses saved leaf/tab session ids once connected, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without clearing pane/tab bindings or consuming the saved restore id, disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn, and remote-runtime stale handles rotate in place without stale resubscription, pane retirement, or PTY index growth. Needs WSL, live remote-runtime/SSH, and saved intentional-break artifacts before promotion."
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. The bounded-recovery tests additionally failed before the policy change because PTY and terminal-create recovery remained active after one minute and a retry reused the stale epoch. Final review tests failed before lifecycle fencing because create cutoff emitted a fatal error with no manual path and a delayed create completion replaced a newer cross-runtime attachment. The fixed tests pass with resumed snapshot/output/input, one-minute cutoffs, new manual epochs, capability re-probing, stale-create rejection, and cross-process provider PTY adoption without rerunning startup. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, a patched live remote-runtime partition journey, and saved intentional-break artifacts before promotion."
},
"performanceBudget": {
"required": true,
"evidence": "Host eligibility is resolved from existing store metadata during activation. Remote-runtime resubscribe polling runs only after a transport close, reserves replacement-only matching for typed stale-handle evidence, backs off from 150 ms to 1 s, caps each request to the remaining 15-second reconnect deadline, and shares each in-flight inventory request across panes in the same runtime/worktree. Accepted host snapshots use an identity-scoped listener only while recovery is pending; after the bound there is no timer or global store subscriber, and stale input cannot re-arm polling. The listener is released on rebind, removal, detach, or destroy, and PTY state is atomically replaced instead of growing. The local journey preserves cold deferral, while SSH eagerly mounts six managers as required; no work is added to typing or terminal-output hot paths."
"evidence": "Remote-runtime recovery allocates at most one backoff timer and one one-minute deadline per detached pane, then stops all PTY retry work until explicit user action; regular PTYs and initial creates do not poll inventory. Each unknown-outcome create attempt performs one bounded provider inventory scan, coordinated per authenticated client/worktree mutation, and the renderer stops issuing attempts after one minute. Timers, accepted-snapshot listeners, stale streams, and pane UI entries are released on health, cutoff, rebind, removal, detach, or destroy; ten-cycle tests prove one unsubscribe per epoch and cutoff tests prove request counts stay fixed for five additional minutes. Client and server liveness each use one interval per socket/transport. At most 4,096 create promises are retained only while in flight, and capacity rejection happens before spawning. Common terminal input/output paths add only constant-time state checks; recovery UI updates only on deduplicated phase transitions."
},
"promotionCriteria": [
"Use deterministic fake providers for failure and unknown-liveness cases.",
@ -2540,7 +2608,8 @@
"Current command covers store wake-hint metadata, main-process SSH provider failure semantics, provider attach/expired-attach behavior, and renderer deferred SSH reconnect/transient-failure/expired-relay fallback with mocked transports.",
"The live SSH journey is environment-dependent and currently runs from a macOS Electron client against a Linux Docker host.",
"WSL restore remains inferred rather than directly covered.",
"Linux and Windows desktop-client journeys and a live remote-runtime reconnect soak are not yet collected."
"Linux and Windows desktop-client partition journeys using patched builds are not yet collected; the Windows smoke proves current reachability and PTY round-trip only.",
"Terminal-create recovery depends on providers authoritatively listing live terminal handles and worktree ownership; older runtimes do not advertise the capability and are never retried after an unknown outcome."
],
"demotionRule": "Cannot promote if provider failure can close panes or if the oracle is screenshot-only."
},

View File

@ -730,7 +730,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
describe('listProcesses', () => {
it('returns active sessions', async () => {
await adapter.spawn({ cols: 80, rows: 24, cwd: '/repo/owned-before-osc7' })
await adapter.spawn({
cols: 80,
rows: 24,
cwd: '/repo/owned-before-osc7',
worktreeId: 'repo::/repo/owned-before-osc7'
})
await adapter.spawn({ cols: 80, rows: 24 })
const procs = await adapter.listProcesses()
@ -739,6 +744,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(procs[0]).toHaveProperty('cwd')
expect(procs[0]).toHaveProperty('title')
expect(procs[0].cwd).toBe('/repo/owned-before-osc7')
expect(procs[0].worktreeId).toBe('repo::/repo/owned-before-osc7')
})
})

View File

@ -773,13 +773,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
)
return result.sessions
.filter((s) => s.isAlive)
.map((s) => ({
id: s.sessionId,
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
title: 'shell',
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}))
.map((s) => {
const { worktreeId } = parsePtySessionId(s.sessionId)
return {
id: s.sessionId,
// Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd.
cwd: s.cwd ?? this.initialCwds.get(s.sessionId) ?? '',
title: 'shell',
...(worktreeId ? { worktreeId } : {}),
...(s.terminalHandle ? { terminalHandle: s.terminalHandle } : {})
}
})
}
// Why keep both: the Manage Sessions panel needs full SessionInfo (pid/state/createdAt) that listProcesses drops for the IPtyProvider contract.

View File

@ -1758,7 +1758,12 @@ describe('LocalPtyProvider', () => {
describe('listProcesses', () => {
it('returns spawned PTYs', async () => {
const before = await provider.listProcesses()
await provider.spawn({ cols: 80, rows: 24, cwd: '/tmp/owned-cwd' })
await provider.spawn({
cols: 80,
rows: 24,
cwd: '/tmp/owned-cwd',
worktreeId: 'repo::/tmp/owned-cwd'
})
await provider.spawn({ cols: 80, rows: 24 })
const after = await provider.listProcesses()
expect(after.length - before.length).toBe(2)
@ -1766,6 +1771,7 @@ describe('LocalPtyProvider', () => {
expect(newEntries[0]).toHaveProperty('id')
expect(newEntries[0]).toHaveProperty('title', 'zsh')
expect(newEntries[0]).toHaveProperty('cwd', '/tmp/owned-cwd')
expect(newEntries[0]).toHaveProperty('worktreeId', 'repo::/tmp/owned-cwd')
})
})

View File

@ -97,6 +97,7 @@ const ptyAgentForegroundContextPaths = new Map<string, string[]>()
// Why: remember the last recognized agent foreground so a degraded scan doesn't report the shell and look like an exit.
const ptyLastRecognizedForeground = new Map<string, string>()
const ptyTerminalHandle = new Map<string, string>()
const ptyWorktreeId = new Map<string, string>()
const ptyInitialCwd = new Map<string, string>()
// Why: reattach carries current settings, not the live process's launch context; keep the first creator's WSL/native identity.
const ptyWslDistroById = new Map<string, string | null>()
@ -240,6 +241,7 @@ function clearPtyState(id: string): void {
ptyAgentForegroundContextPaths.delete(id)
ptyLastRecognizedForeground.delete(id)
ptyTerminalHandle.delete(id)
ptyWorktreeId.delete(id)
ptyInitialCwd.delete(id)
ptyWslDistroById.delete(id)
ptyLoadGeneration.delete(id)
@ -852,6 +854,9 @@ export class LocalPtyProvider implements IPtyProvider {
if (finalEnv.ORCA_TERMINAL_HANDLE) {
ptyTerminalHandle.set(id, finalEnv.ORCA_TERMINAL_HANDLE)
}
if (args.worktreeId) {
ptyWorktreeId.set(id, args.worktreeId)
}
ptyAgentForegroundContextPaths.set(
id,
getAgentForegroundContextPaths({ cwd: args.cwd, worktreeId: args.worktreeId })
@ -1306,6 +1311,7 @@ export class LocalPtyProvider implements IPtyProvider {
id,
cwd: ptyInitialCwd.get(id) ?? '',
title: proc.process || ptyShellName.get(id) || 'shell',
...(ptyWorktreeId.get(id) ? { worktreeId: ptyWorktreeId.get(id) } : {}),
...(ptyTerminalHandle.get(id) ? { terminalHandle: ptyTerminalHandle.get(id) } : {})
}))
}

View File

@ -0,0 +1,255 @@
import { describe, expect, it, vi } from 'vitest'
import type { PtyProcessInfo } from '../providers/types'
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
import { OrcaRuntimeService } from './orca-runtime'
import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity'
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
type CreateRun = (
canonicalWorktreeSelector: string | undefined,
preAllocatedHandle: string | undefined
) => Promise<RuntimeTerminalCreate>
function createRuntimeForDedupe(listProcesses = vi.fn(async (): Promise<PtyProcessInfo[]> => [])) {
const handleByPtyId = new Map<string, string>()
const runtime = Object.create(OrcaRuntimeService.prototype) as OrcaRuntimeService
Object.assign(runtime, {
terminalCreateIdempotency: new RemoteRuntimeTerminalCreateIdempotency(),
ptyController: { listProcesses },
resolveTerminalWorkspaceLaunchScope: vi.fn(async (selector: string) => ({
id: selector.startsWith('id:') ? selector.slice(3) : selector
})),
adoptControllerTerminalHandle: vi.fn((ptyId: string, handle: string) => {
handleByPtyId.set(ptyId, handle)
}),
recordPtyWorktree: vi.fn((ptyId: string, worktreeId: string, state: { title?: string }) => ({
ptyId,
worktreeId,
title: state.title ?? null
})),
issuePtyHandle: vi.fn((pty: { ptyId: string }) => handleByPtyId.get(pty.ptyId))
})
return { runtime, listProcesses }
}
function createdTerminal(handle: string, worktreeId = 'worktree-1'): RuntimeTerminalCreate {
return { handle, worktreeId, title: null }
}
describe('terminal create idempotency', () => {
it('derives a stable handle isolated by authenticated client and canonical worktree', () => {
const first = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
expect(deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')).toBe(
first
)
expect(
deriveRemoteRuntimeTerminalCreateHandle('device-b', 'worktree-1', 'mutation-1')
).not.toBe(first)
expect(
deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-2', 'mutation-1')
).not.toBe(first)
expect(first).toMatch(/^term_[0-9a-f]{32}$/)
})
it('shares an in-flight create without scanning inventory on the initial request', async () => {
const { runtime, listProcesses } = createRuntimeForDedupe()
let resolveCreate: (value: RuntimeTerminalCreate) => void = () => {}
const pending = new Promise<RuntimeTerminalCreate>((resolve) => {
resolveCreate = resolve
})
const create = vi.fn<CreateRun>(() => pending)
const first = runtime.dedupeTerminalCreate(
'device-a',
'id:worktree-1',
'mutation-1',
false,
create
)
const retry = runtime.dedupeTerminalCreate(
'device-a',
'worktree-1',
'mutation-1',
false,
create
)
await vi.waitFor(() => expect(create).toHaveBeenCalledTimes(1))
const stableHandle = create.mock.calls[0][1]
resolveCreate(createdTerminal(stableHandle ?? 'missing'))
await expect(Promise.all([first, retry])).resolves.toEqual([
createdTerminal(stableHandle ?? 'missing'),
createdTerminal(stableHandle ?? 'missing')
])
expect(listProcesses).not.toHaveBeenCalled()
})
it('adopts the original PTY after a runtime-process restart without rerunning startup', async () => {
const liveSessions: PtyProcessInfo[] = []
const firstRuntime = createRuntimeForDedupe(vi.fn(async () => liveSessions)).runtime
const secondInventory = vi.fn(async () => liveSessions)
const secondRuntime = createRuntimeForDedupe(secondInventory).runtime
const startup = vi.fn<CreateRun>(async (_selector, handle) => {
liveSessions.push({
id: 'worktree-1@@session-a',
cwd: '/workspace',
title: 'pwsh',
worktreeId: 'worktree-1',
terminalHandle: handle
})
return createdTerminal(handle ?? 'missing')
})
const first = await firstRuntime.dedupeTerminalCreate(
'device-a',
'id:worktree-1',
'mutation-1',
false,
startup
)
const retrySpawn = vi.fn<CreateRun>()
const recovered = await secondRuntime.dedupeTerminalCreate(
'device-a',
'worktree-1',
'mutation-1',
true,
retrySpawn
)
expect(recovered.handle).toBe(first.handle)
expect(recovered.ptyId).toBe('worktree-1@@session-a')
expect(startup).toHaveBeenCalledTimes(1)
expect(retrySpawn).not.toHaveBeenCalled()
expect(secondInventory).toHaveBeenCalledTimes(1)
})
it('creates with the same stable handle after authoritative inventory proves absence', async () => {
const { runtime, listProcesses } = createRuntimeForDedupe()
const create = vi.fn<CreateRun>(async (_selector, handle) =>
createdTerminal(handle ?? 'missing')
)
const result = await runtime.dedupeTerminalCreate(
'device-a',
'id:worktree-1',
'mutation-1',
true,
create
)
expect(listProcesses).toHaveBeenCalledTimes(1)
expect(create).toHaveBeenCalledWith('id:worktree-1', result.handle)
})
it('fails safely without spawning when retry inventory is unavailable', async () => {
const listProcesses = vi.fn(async (): Promise<PtyProcessInfo[]> => {
throw new Error('controller offline')
})
const { runtime } = createRuntimeForDedupe(listProcesses)
const create = vi.fn<CreateRun>()
await expect(
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
).rejects.toThrow('runtime_unavailable')
expect(create).not.toHaveBeenCalled()
})
it('fails safely when an older provider omits identity for a live same-worktree PTY', async () => {
const { runtime } = createRuntimeForDedupe(
vi.fn(async () => [
{
id: 'worktree-1@@legacy-session',
cwd: '/workspace',
title: 'shell',
worktreeId: 'worktree-1'
}
])
)
const create = vi.fn<CreateRun>()
await expect(
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
).rejects.toThrow('runtime_unavailable')
expect(create).not.toHaveBeenCalled()
})
it('adopts an exact identity even when another legacy PTY lacks metadata', async () => {
const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
const { runtime } = createRuntimeForDedupe(
vi.fn(async () => [
{
id: 'worktree-1@@legacy-session',
cwd: '/workspace',
title: 'shell',
worktreeId: 'worktree-1'
},
{
id: 'worktree-1@@created-session',
cwd: '/workspace',
title: 'pwsh',
worktreeId: 'worktree-1',
terminalHandle: handle
}
])
)
await expect(
runtime.dedupeTerminalCreate(
'device-a',
'id:worktree-1',
'mutation-1',
true,
vi.fn<CreateRun>()
)
).resolves.toMatchObject({ handle, ptyId: 'worktree-1@@created-session' })
})
it('fails closed when a matching handle belongs to another worktree', async () => {
const handle = deriveRemoteRuntimeTerminalCreateHandle('device-a', 'worktree-1', 'mutation-1')
const { runtime } = createRuntimeForDedupe(
vi.fn(async () => [
{
id: 'worktree-2@@session-a',
cwd: '/other',
title: 'shell',
worktreeId: 'worktree-2',
terminalHandle: handle
}
])
)
const create = vi.fn<CreateRun>()
await expect(
runtime.dedupeTerminalCreate('device-a', 'id:worktree-1', 'mutation-1', true, create)
).rejects.toThrow('terminal_create_identity_conflict')
expect(create).not.toHaveBeenCalled()
})
it('bounds concurrent creates and releases capacity after settlement', async () => {
const idempotency = new RemoteRuntimeTerminalCreateIdempotency(1)
let resolveFirst: (value: RuntimeTerminalCreate) => void = () => {}
const first = idempotency.run(
'device-a',
'worktree-1',
'mutation-1',
() =>
new Promise((resolve) => {
resolveFirst = resolve
})
)
await expect(
idempotency.run('device-a', 'worktree-1', 'mutation-2', async () =>
createdTerminal('terminal-2')
)
).rejects.toThrow('Too many terminal creations')
resolveFirst(createdTerminal('terminal-1'))
await first
await expect(
idempotency.run('device-a', 'worktree-1', 'mutation-2', async () =>
createdTerminal('terminal-2')
)
).resolves.toEqual(createdTerminal('terminal-2'))
})
})

View File

@ -375,6 +375,8 @@ import type {
} from '../../shared/runtime-types'
import type { AutomationService } from '../automations/service'
import { RuntimeBrowserCommands } from './orca-runtime-browser'
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity'
import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout'
import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer'
import {
@ -1116,6 +1118,7 @@ type TerminalCreateOptions = {
leafId?: string
sessionId?: string
persistHostSessionBinding?: boolean
preAllocatedHandle?: string
// Why: the headless mobile-session create publishes its own authoritative
// snapshot (with the correct target group) right after spawn. Skip the
// intermediate pty-backed publish so the new tab doesn't briefly flash in
@ -2295,6 +2298,7 @@ export class OrcaRuntimeService {
string,
Promise<RuntimeMobileSessionCreateTerminalResult>
>()
private readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency()
// Why: idempotency map for worktree.create — a create interrupted by a mobile
// connection migration is retried with the same clientMutationId and returns
// the in-flight (or just-finished) operation instead of a duplicate worktree.
@ -19530,7 +19534,8 @@ export class OrcaRuntimeService {
const launchOpts = await this.resolveAgentTerminalCreateOptions(workspace, opts)
const cwd =
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const preAllocatedHandle =
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
// Why: mint tabId in main before spawn so paneKey is known at PTY env
// build time. Hook-based agent status (Claude/Codex/Cursor/Gemini) keys
// off `${tabId}:${leafId}` — without these vars set on the PTY, the
@ -19804,6 +19809,100 @@ export class OrcaRuntimeService {
}
}
async dedupeTerminalCreate(
clientIdentity: string,
worktreeSelector: string | undefined,
clientMutationId: string | undefined,
reconcileExisting: boolean,
run: (
canonicalWorktreeSelector: string | undefined,
preAllocatedHandle: string | undefined
) => Promise<RuntimeTerminalCreate>
): Promise<RuntimeTerminalCreate> {
if (!clientMutationId || !worktreeSelector) {
if (reconcileExisting) {
throw new Error('runtime_unavailable')
}
return await run(worktreeSelector, undefined)
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector)
const canonicalWorktreeSelector = `id:${workspace.id}`
const preAllocatedHandle = deriveRemoteRuntimeTerminalCreateHandle(
clientIdentity,
workspace.id,
clientMutationId
)
return this.terminalCreateIdempotency.run(
clientIdentity,
workspace.id,
clientMutationId,
async () => {
if (reconcileExisting) {
const adopted = await this.reconcileRemoteTerminalCreate(workspace.id, preAllocatedHandle)
if (adopted) {
return adopted
}
}
return await run(canonicalWorktreeSelector, preAllocatedHandle)
}
)
}
private async reconcileRemoteTerminalCreate(
worktreeId: string,
terminalHandle: string
): Promise<RuntimeTerminalCreate | null> {
if (!this.ptyController?.listProcesses) {
throw new Error('runtime_unavailable')
}
const listed = await withTimeoutResult(
this.ptyController.listProcesses(),
PTY_CONTROLLER_LIST_TIMEOUT_MS
)
if (!listed.ok) {
// Why: unknown inventory cannot prove the first create failed, so spawning could duplicate a live shell.
throw new Error('runtime_unavailable')
}
const matches = listed.value.filter((session) => session.terminalHandle === terminalHandle)
if (matches.length > 1) {
throw new Error('terminal_create_identity_conflict')
}
if (matches.length === 0) {
const sameWorktreeHasUnknownIdentity = listed.value.some(
(session) =>
(session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)) === worktreeId &&
!session.terminalHandle
)
if (sameWorktreeHasUnknownIdentity) {
// Why: older retained providers may list the first shell without its handle; absence is not authoritative in that shape.
throw new Error('runtime_unavailable')
}
return null
}
const session = matches[0]
const authoritativeWorktreeId = session.worktreeId ?? inferWorktreeIdFromPtyId(session.id)
if (authoritativeWorktreeId !== worktreeId) {
// Why: a reused address or forged provider record must never adopt a PTY from another workspace.
throw new Error('terminal_create_identity_conflict')
}
this.adoptControllerTerminalHandle(session.id, terminalHandle)
const pty = this.recordPtyWorktree(session.id, worktreeId, {
connected: true,
title: session.title
})
const adoptedHandle = this.issuePtyHandle(pty)
if (adoptedHandle !== terminalHandle) {
throw new Error('terminal_create_identity_conflict')
}
return {
handle: adoptedHandle,
ptyId: session.id,
worktreeId,
title: session.title || null,
surface: 'background'
}
}
async launchAgentTerminal(
worktreeSelector: string,
opts: { agent: TuiAgent; prompt: string; title?: string }

View File

@ -0,0 +1,37 @@
import type { RuntimeTerminalCreate } from '../../shared/runtime-types'
const DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES = 4_096
export class RemoteRuntimeTerminalCreateIdempotency {
private readonly inFlight = new Map<string, Promise<RuntimeTerminalCreate>>()
constructor(private readonly maxInFlight = DEFAULT_MAX_IN_FLIGHT_TERMINAL_CREATES) {}
run(
clientIdentity: string,
worktreeId: string,
clientMutationId: string,
create: () => Promise<RuntimeTerminalCreate>
): Promise<RuntimeTerminalCreate> {
const key = `${clientIdentity}\0${worktreeId}\0${clientMutationId}`
const existing = this.inFlight.get(key)
if (existing) {
return existing
}
if (this.inFlight.size >= this.maxInFlight) {
return Promise.reject(
new Error('Too many terminal creations are still pending; retry after they settle.')
)
}
const promise = create()
this.inFlight.set(key, promise)
const drop = (): void => {
if (this.inFlight.get(key) === promise) {
this.inFlight.delete(key)
}
}
void promise.then(drop, drop)
return promise
}
}

View File

@ -0,0 +1,17 @@
import { createHash } from 'node:crypto'
export function deriveRemoteRuntimeTerminalCreateHandle(
clientIdentity: string,
worktreeId: string,
clientMutationId: string
): string {
const digest = createHash('sha256')
.update('orca.remote-terminal-create.v2\0')
.update(clientIdentity)
.update('\0')
.update(worktreeId)
.update('\0')
.update(clientMutationId)
.digest('hex')
return `term_${digest.slice(0, 32)}`
}

View File

@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcContext } from '../core'
import { TERMINAL_METHODS } from './terminal'
describe('terminal.create RPC idempotency', () => {
it('scopes the mutation key to the authenticated paired device and worktree', async () => {
const terminal = { handle: 'terminal-1', worktreeId: 'worktree-1', title: null }
const createTerminal = vi.fn(async () => terminal)
const dedupeTerminalCreate = vi.fn(
async (
_clientIdentity: string,
_worktree: string | undefined,
_mutationId: string | undefined,
_reconcileExisting: boolean,
run: (worktree: string | undefined, handle: string | undefined) => Promise<typeof terminal>
) => run('id:worktree-1', 'term_stable')
)
const method = TERMINAL_METHODS.find((candidate) => candidate.name === 'terminal.create')
if (!method) {
throw new Error('terminal.create method missing')
}
const result = await method.handler(
{
worktree: 'id:worktree-1',
clientMutationId: 'mutation-1',
command: 'pwsh',
resumeProviderSession: {
key: 'session_id',
id: 'session-1',
transcriptPath: 'C:\\Users\\example\\.codex\\sessions\\rollout.jsonl'
}
},
{
runtime: { createTerminal, dedupeTerminalCreate },
pairedDeviceId: 'device-a',
clientId: 'bearer-token'
} as unknown as RpcContext,
vi.fn()
)
expect(dedupeTerminalCreate).toHaveBeenCalledWith(
'device-a',
'id:worktree-1',
'mutation-1',
false,
expect.any(Function)
)
expect(createTerminal).toHaveBeenCalledWith(
'id:worktree-1',
expect.objectContaining({
command: 'pwsh',
preAllocatedHandle: 'term_stable',
resumeProviderSession: {
key: 'session_id',
id: 'session-1',
transcriptPath: 'C:\\Users\\example\\.codex\\sessions\\rollout.jsonl'
}
})
)
expect(result).toEqual({ terminal })
})
})

View File

@ -910,6 +910,8 @@ const TerminalWait = TerminalHandle.extend({
const TerminalCreateParams = z.object({
worktree: OptionalString,
clientMutationId: z.string().min(1).max(128).optional(),
reconcileExisting: z.boolean().optional(),
command: OptionalString,
startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(),
env: z.record(z.string(), z.string()).optional(),
@ -1362,29 +1364,37 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'terminal.create',
params: TerminalCreateParams,
handler: async (params, { runtime }) => ({
terminal: await runtime.createTerminal(params.worktree, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,
activate: params.activate === true,
presentation: params.presentation,
tabId: params.tabId,
leafId: params.leafId
})
handler: async (params, { runtime, pairedDeviceId, clientId }) => ({
terminal: await runtime.dedupeTerminalCreate(
pairedDeviceId ?? clientId ?? 'local',
params.worktree,
params.clientMutationId,
params.reconcileExisting === true,
(canonicalWorktreeSelector, preAllocatedHandle) =>
runtime.createTerminal(canonicalWorktreeSelector, {
command: params.command,
startupCommandDelivery: params.startupCommandDelivery,
env: params.env,
envToDelete: params.envToDelete,
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
...(params.resumeProviderSession
? { resumeProviderSession: params.resumeProviderSession }
: {}),
...(params.launchToken ? { launchToken: params.launchToken } : {}),
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
...(params.terminalColorQueryReplies
? { terminalColorQueryReplies: params.terminalColorQueryReplies }
: {}),
title: params.title,
focus: params.focus === true,
rendererBacked: params.rendererBacked === true,
activate: params.activate === true,
presentation: params.presentation,
tabId: params.tabId,
leafId: params.leafId,
...(preAllocatedHandle ? { preAllocatedHandle } : {})
})
)
})
}),
defineMethod({

View File

@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WebSocket } from 'ws'
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
afterEach(() => {
vi.useRealTimers()
})
describe('RemoteRuntimeServerHeartbeat', () => {
it('grants clients a fresh probe after the server event loop resumes', async () => {
vi.useFakeTimers()
let now = 1_000
const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
heartbeat.noteAlive(socket)
heartbeat.start(() => [socket])
now += 100
await vi.advanceTimersByTimeAsync(100)
now += 3_600_000
await vi.advanceTimersByTimeAsync(100)
expect(socket.ping).toHaveBeenCalledTimes(2)
expect(socket.terminate).not.toHaveBeenCalled()
now += 100
await vi.advanceTimersByTimeAsync(100)
expect(socket.terminate).toHaveBeenCalledTimes(1)
heartbeat.stop()
})
})

View File

@ -0,0 +1,64 @@
import type { WebSocket } from 'ws'
export class RemoteRuntimeServerHeartbeat {
private timer: ReturnType<typeof setInterval> | null = null
private lastTickAt: number | null = null
private readonly alive = new WeakSet<WebSocket>()
constructor(
private readonly intervalMs: number,
private readonly now: () => number = Date.now,
private readonly warningClientCount = 128
) {}
noteAlive(socket: WebSocket): void {
this.alive.add(socket)
}
start(getClients: () => Iterable<WebSocket>): void {
if (this.timer) {
return
}
this.lastTickAt = this.now()
this.timer = setInterval(() => this.sweep(getClients()), this.intervalMs)
this.timer.unref?.()
}
stop(): void {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
this.lastTickAt = null
}
private sweep(clients: Iterable<WebSocket>): void {
const tickAt = this.now()
const elapsedMs = tickAt - (this.lastTickAt ?? tickAt)
this.lastTickAt = tickAt
const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5
let reaped = 0
let clientCount = 0
for (const socket of clients) {
clientCount += 1
if (resumedFromPause) {
// Why: a delayed server tick cannot infer that clients missed a probe they had no chance to answer.
this.alive.add(socket)
}
if (!this.alive.has(socket)) {
socket.terminate()
reaped += 1
continue
}
this.alive.delete(socket)
try {
socket.ping()
} catch {
// Why: a mid-teardown socket is finalized by its close/error listener.
}
}
if (reaped > 0 || clientCount >= this.warningClientCount) {
console.warn(`[ws-transport] heartbeat reaped ${reaped}; ${clientCount} tracked sockets`)
}
}
}

View File

@ -4,6 +4,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod
import { WebSocketServer, type WebSocket } from 'ws'
import type { RpcTransport } from './transport'
import { createStaticWebClientHandler } from './static-web-client-handler'
import { RemoteRuntimeServerHeartbeat } from './remote-runtime-server-heartbeat'
const MAX_WS_MESSAGE_BYTES = 1024 * 1024
// Why: one desktop remote-host client can hold many concurrent streams, so keep the cap high enough that stale streams don't starve control RPCs.
@ -30,6 +31,8 @@ export type WebSocketTransportOptions = {
tlsKey?: string
// Why: test-only override. Production uses HEARTBEAT_INTERVAL_MS.
heartbeatIntervalMs?: number
// Why: deterministic suspension tests must advance wall time independently from timer callbacks.
heartbeatNow?: () => number
// Why: test-only override. Production uses PRE_AUTH_TIMEOUT_MS.
preAuthTimeoutMs?: number
// Why: the pairing server can also serve the browser client, avoiding a second static server.
@ -45,16 +48,13 @@ export class WebSocketTransport implements RpcTransport {
private readonly port: number
private readonly tlsCert: string | undefined
private readonly tlsKey: string | undefined
private readonly heartbeatIntervalMs: number
private readonly heartbeat: RemoteRuntimeServerHeartbeat
private readonly preAuthTimeoutMs: number
private readonly staticRoot: string | undefined
private readonly fallbackPort: number | undefined
private readonly preferPinnedPort: boolean
private httpServer: HttpsServer | HttpServer | null = null
private wss: WebSocketServer | null = null
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
// Why: a socket absent from this set at the next heartbeat sweep is presumed dead and terminated.
private wsAlive = new WeakSet<WebSocket>()
private messageHandler: WebSocketMessageHandler | null = null
private connectionCloseHandler:
| ((clientId: string | null, ws: WebSocket, hasOtherConnections: boolean) => void)
@ -69,6 +69,7 @@ export class WebSocketTransport implements RpcTransport {
tlsCert,
tlsKey,
heartbeatIntervalMs,
heartbeatNow,
preAuthTimeoutMs,
staticRoot,
fallbackPort,
@ -78,7 +79,11 @@ export class WebSocketTransport implements RpcTransport {
this.port = port
this.tlsCert = tlsCert
this.tlsKey = tlsKey
this.heartbeatIntervalMs = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS
this.heartbeat = new RemoteRuntimeServerHeartbeat(
heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS,
heartbeatNow,
MAX_WS_CONNECTIONS
)
this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS
this.staticRoot = staticRoot
this.fallbackPort = fallbackPort
@ -194,7 +199,7 @@ export class WebSocketTransport implements RpcTransport {
this.httpServer = httpServer
this.wss = wss
this.startHeartbeat()
this.heartbeat.start(() => this.wss?.clients ?? [])
}
// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing.
@ -206,56 +211,12 @@ export class WebSocketTransport implements RpcTransport {
ws.once('close', () => clearTimeout(terminateTimer))
}
// Why: the only reliable reaper of half-open mobile sockets stranded by background suspension without a TCP FIN.
private startHeartbeat(): void {
if (this.heartbeatTimer) {
return
}
this.heartbeatTimer = setInterval(() => {
const wss = this.wss
if (!wss) {
return
}
let reaped = 0
for (const ws of wss.clients) {
if (!this.wsAlive.has(ws)) {
// Why: terminate() frees the slot immediately; close() on a dead socket can hang for the OS-level TCP timeout.
ws.terminate()
reaped++
continue
}
this.wsAlive.delete(ws)
try {
ws.ping()
} catch {
// Why: ping() can throw on a mid-teardown socket; the close handler runs regardless, so swallow it.
}
}
// Why: steady reaping or riding the cap are early overload signals; stay quiet on healthy ticks.
if (reaped > 0 || wss.clients.size >= MAX_WS_CONNECTIONS) {
console.warn(
`[ws-transport] heartbeat reaped ${reaped}; ${wss.clients.size} tracked sockets`
)
}
}, this.heartbeatIntervalMs)
if (typeof this.heartbeatTimer.unref === 'function') {
this.heartbeatTimer.unref()
}
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
async stop(): Promise<void> {
const wss = this.wss
const httpServer = this.httpServer
this.wss = null
this.httpServer = null
this.stopHeartbeat()
this.heartbeat.stop()
if (wss) {
for (const client of wss.clients) {
@ -282,11 +243,11 @@ export class WebSocketTransport implements RpcTransport {
private handleConnection(ws: WebSocket): void {
let finalized = false
const onPong = (): void => {
this.wsAlive.add(ws)
this.heartbeat.noteAlive(ws)
}
const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
// Why: any inbound frame counts as proof of life, so an actively-talking client isn't reaped mid-request.
this.wsAlive.add(ws)
this.heartbeat.noteAlive(ws)
const msg =
typeof data === 'string'
? data
@ -338,7 +299,7 @@ export class WebSocketTransport implements RpcTransport {
this.preAuthTimers.set(ws, preAuthTimer)
// Why: seed alive so the first heartbeat tick doesn't reap a fresh socket before its first pong.
this.wsAlive.add(ws)
this.heartbeat.noteAlive(ws)
ws.on('pong', onPong)
ws.on('message', onMessage)

View File

@ -22,6 +22,7 @@ import type {
} from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import type { PtyTransportRecoveryState } from './pty-transport-types'
import { fitPanes, isWindowsUserAgent } from './pane-helpers'
import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context'
import {
@ -155,7 +156,12 @@ import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-atlas-rec
import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore'
import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim'
import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay'
import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner'
import { selectTerminalTabAgentTypesByLeaf } from './terminal-tab-agent-type-index'
import {
updateTerminalRemoteRuntimeRecoveryUiState,
type VisiblePtyRecoveryState
} from './terminal-remote-runtime-recovery-ui-state'
const NATIVE_CHAT_ROOT_SELECTOR = '[data-native-chat-root="true"]'
@ -376,6 +382,9 @@ export default function TerminalPane({
const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft)
const [agentSessionFork, setAgentSessionFork] = useState<PreparedAgentSessionFork | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(null)
const [ptyRecoveryStatesByPaneId, setPtyRecoveryStatesByPaneId] = useState<
Record<number, VisiblePtyRecoveryState>
>({})
const [sessionStateSaveFailureOpen, setSessionStateSaveFailureOpen] = useState(false)
const daemonActions = useDaemonActions()
// Why: override state lives in a Map for perf; this counter forces a re-render on override change so the mobile-fit banner toggles.
@ -558,6 +567,13 @@ export default function TerminalPane({
}
setTerminalError((prev) => (prev ? `${prev}\n${message}` : message))
})
const onPtyRecoveryStateRef = useRef(
(paneId: number, state: PtyTransportRecoveryState | null) => {
setPtyRecoveryStatesByPaneId((previous) =>
updateTerminalRemoteRuntimeRecoveryUiState(previous, paneId, state)
)
}
)
const setTabPaneExpanded = useAppStore((store) => store.setTabPaneExpanded)
const setTabCanExpandPane = useAppStore((store) => store.setTabCanExpandPane)
@ -1406,6 +1422,7 @@ export default function TerminalPane({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore((store) => store.consumeSuppressedPtyExit),
updateTabTitle,
@ -1608,6 +1625,7 @@ export default function TerminalPane({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore.getState().consumeSuppressedPtyExit,
updateTabTitle,
@ -3014,6 +3032,25 @@ export default function TerminalPane({
onRenameCancel={handleRenameCancel}
onRenameBlur={handleRenameBlur}
/>
{!showSshReconnectOverlay
? managedPanes.map((pane) => {
const recoveryState = ptyRecoveryStatesByPaneId[pane.id]
if (!recoveryState) {
return null
}
return createPortal(
<TerminalRemoteRuntimeReconnectBanner
key={`remote-runtime-reconnect-${pane.id}-${recoveryState.epoch}`}
phase={recoveryState.phase}
onReconnect={() => {
paneTransportsRef.current.get(pane.id)?.retryRecovery?.()
}}
/>,
pane.container,
`remote-runtime-reconnect-${pane.id}`
)
})
: null}
{managedPanes.map((pane) => {
// Why: pane IDs collide across tabs, so key overlays by the transport's actual ptyId to avoid wrong-pane banners.
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId()

View File

@ -0,0 +1,33 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
afterEach(cleanup)
describe('TerminalRemoteRuntimeReconnectBanner', () => {
it('shows quiet bounded automatic recovery without a manual action', () => {
render(<TerminalRemoteRuntimeReconnectBanner phase="backoff" onReconnect={vi.fn()} />)
expect(screen.getByText('Reconnecting to remote runtime')).toBeInTheDocument()
expect(screen.getByText(/retry for up to one minute/)).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('offers one explicit reconnect action after automatic recovery stops', async () => {
const onReconnect = vi.fn()
const user = userEvent.setup()
render(<TerminalRemoteRuntimeReconnectBanner phase="disconnected" onReconnect={onReconnect} />)
expect(screen.getByText('Remote runtime disconnected')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reconnect' }))
expect(onReconnect).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,72 @@
import { Loader2, ServerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { PtyTransportRecoveryState } from './pty-transport-types'
type VisibleRecoveryPhase = Extract<
PtyTransportRecoveryState['phase'],
'recovering' | 'backoff' | 'disconnected'
>
export function TerminalRemoteRuntimeReconnectBanner({
phase,
onReconnect
}: {
phase: VisibleRecoveryPhase
onReconnect: () => void
}): React.JSX.Element {
const retrying = phase !== 'disconnected'
return (
<div
className="pointer-events-none absolute inset-x-3 bottom-3 z-30 flex justify-center"
data-terminal-remote-runtime-reconnect-banner={phase}
>
<div
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card/95 px-3 py-3 text-card-foreground shadow-xs backdrop-blur-[1px]"
role="status"
aria-live="polite"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted text-muted-foreground">
{retrying ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ServerOff className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">
{retrying
? translate(
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingTitle',
'Reconnecting to remote runtime'
)
: translate(
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedTitle',
'Remote runtime disconnected'
)}
</div>
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
{retrying
? translate(
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.retryingBody',
'Orca will retry for up to one minute. This terminal will resume if the connection returns.'
)
: translate(
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.disconnectedBody',
'Automatic retries stopped. Reconnect to resume this terminal session.'
)}
</div>
</div>
{!retrying ? (
<Button size="sm" onClick={onReconnect}>
{translate(
'auto.components.terminal.pane.TerminalRemoteRuntimeReconnectBanner.reconnectButton',
'Reconnect'
)}
</Button>
) : null}
</div>
</div>
)
}

View File

@ -11,6 +11,7 @@ import type {
SleepingAgentLaunchConfig
} from '../../../../shared/agent-session-resume'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import type { PtyTransportRecoveryState } from './pty-transport-types'
export type PtyConnectionDeps = {
tabId: string
@ -55,6 +56,9 @@ export type PtyConnectionDeps = {
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
onPtyRecoveryStateRef?: React.RefObject<
(paneId: number, state: PtyTransportRecoveryState | null) => void
>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void

View File

@ -27,6 +27,7 @@ import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate'
import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor'
import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply'
import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport'
import type { PtyTransportRecoveryState } from './pty-transport-types'
import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { getConnectionId } from '@/lib/connection-context'
@ -2233,6 +2234,7 @@ export function connectPanePty(
activePanePtyBinding = null
activePanePtyBindingBoundAt = null
delete pane.container.dataset.ptyId
delete pane.container.dataset.ptyRecoveryState
}
const agentCompletionCoordinator = createAgentCompletionCoordinator({
@ -5228,6 +5230,13 @@ export function connectPanePty(
if (isCurrent()) {
onError(message)
}
},
onRecoveryStateChange: (state: PtyTransportRecoveryState): void => {
if (isCurrent()) {
// Why: cached pixels remain visible while detached; expose transport truth for diagnostics and recovery UI.
pane.container.dataset.ptyRecoveryState = state.phase
deps.onPtyRecoveryStateRef?.current?.(pane.id, state)
}
}
}
}

View File

@ -72,6 +72,21 @@ type PtyCallbacks = {
onStatus?: (shell: string) => void
onError?: (message: string, errors?: string[]) => void
onExit?: (code: number) => void
onRecoveryStateChange?: (state: PtyTransportRecoveryState) => void
}
export type PtyTransportRecoveryState = {
phase:
| 'connecting'
| 'connected'
| 'recovering'
| 'backoff'
| 'disconnected'
| 'offline'
| 'ended'
| 'disposed'
epoch: number
attempt: number
}
export type PtyTransport = {
@ -125,6 +140,9 @@ export type PtyTransport = {
}
) => boolean
isConnected: () => boolean
getRecoveryState?: () => PtyTransportRecoveryState
/** Starts a fresh connection epoch while preserving the authoritative remote PTY identity. */
retryRecovery?: () => boolean
getPtyId: () => string | null
getConnectionId?: () => string | null | undefined
/** The runtime captured by this transport; legacy remote PTY ids do not

View File

@ -2005,6 +2005,7 @@ describe('createRemoteRuntimePtyTransport', () => {
method: 'terminal.create',
params: {
worktree: 'id:repo1::/remote/wt',
clientMutationId: expect.any(String),
command: 'claude',
env: { ORCA_TAB_ID: 'tab-1' },
tabId: 'tab-1',

View File

@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteRuntimePtyRecoveryState } from './remote-runtime-pty-recovery-state'
afterEach(() => {
vi.useRealTimers()
})
describe('RemoteRuntimePtyRecoveryState', () => {
it('cancels stale retry timers when a pane detaches', async () => {
vi.useFakeTimers()
const state = new RemoteRuntimePtyRecoveryState()
const retry = vi.fn()
const epoch = state.begin()
state.schedule(epoch, retry)
state.cancel()
await vi.runAllTimersAsync()
expect(retry).not.toHaveBeenCalled()
expect(state.isCurrent(epoch)).toBe(false)
expect(state.isActive).toBe(false)
})
it('keeps one epoch across retries and rejects it after disposal', async () => {
vi.useFakeTimers()
const state = new RemoteRuntimePtyRecoveryState()
const retry = vi.fn()
const epoch = state.begin()
state.schedule(epoch, retry)
await vi.advanceTimersByTimeAsync(250)
expect(retry).toHaveBeenCalledWith(epoch)
expect(state.begin()).toBe(epoch)
state.dispose()
expect(state.isCurrent(epoch)).toBe(false)
expect(state.isActive).toBe(false)
})
it('rejects a stale retry after a newer attachment becomes healthy', async () => {
vi.useFakeTimers()
const state = new RemoteRuntimePtyRecoveryState()
const retry = vi.fn()
const epoch = state.begin()
state.markHealthy()
expect(state.isCurrent(epoch)).toBe(false)
expect(state.schedule(epoch, retry)).toBe(false)
await vi.runAllTimersAsync()
expect(retry).not.toHaveBeenCalled()
})
it('stops automatic recovery after the bounded recovery window', async () => {
vi.useFakeTimers()
const onChange = vi.fn()
const state = new RemoteRuntimePtyRecoveryState(onChange)
const retry = vi.fn()
const epoch = state.begin()
state.schedule(epoch, retry)
await vi.advanceTimersByTimeAsync(60_000)
expect(state.currentPhase).toBe('disconnected')
expect(state.isActive).toBe(false)
expect(state.isCurrent(epoch)).toBe(false)
expect(onChange).toHaveBeenCalled()
})
it('starts a newly fenced recovery epoch after a manual retry', async () => {
vi.useFakeTimers()
const state = new RemoteRuntimePtyRecoveryState()
const firstEpoch = state.begin()
await vi.advanceTimersByTimeAsync(60_000)
const manualEpoch = state.begin()
expect(manualEpoch).toBe(firstEpoch + 1)
expect(state.currentPhase).toBe('recovering')
expect(state.isCurrent(firstEpoch)).toBe(false)
expect(state.isCurrent(manualEpoch)).toBe(true)
})
it('cancels scheduled work when a caller reaches its own recovery cutoff', async () => {
vi.useFakeTimers()
const state = new RemoteRuntimePtyRecoveryState()
const retry = vi.fn()
const epoch = state.begin()
state.schedule(epoch, retry)
state.markDisconnected()
await vi.runAllTimersAsync()
expect(state.currentPhase).toBe('disconnected')
expect(state.isCurrent(epoch)).toBe(false)
expect(retry).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,152 @@
const RECOVERY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const
export const REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS = 60_000
export type RemoteRuntimePtyRecoveryPhase =
| 'idle'
| 'recovering'
| 'backoff'
| 'disconnected'
| 'disposed'
export class RemoteRuntimePtyRecoveryState {
private phase: RemoteRuntimePtyRecoveryPhase = 'idle'
private epoch = 0
private attempt = 0
private retryTimer: ReturnType<typeof setTimeout> | null = null
private deadlineTimer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly onChange?: () => void) {}
get isActive(): boolean {
return this.phase === 'recovering' || this.phase === 'backoff'
}
get currentPhase(): RemoteRuntimePtyRecoveryPhase {
return this.phase
}
get currentEpoch(): number {
return this.epoch
}
get attemptCount(): number {
return this.attempt
}
begin(): number {
if (this.phase === 'disposed') {
return this.epoch
}
if (!this.isActive) {
this.epoch += 1
this.attempt = 0
this.armDeadline(this.epoch)
}
this.clearRetryTimer()
this.phase = 'recovering'
this.onChange?.()
return this.epoch
}
isCurrent(epoch: number): boolean {
return this.isActive && epoch === this.epoch
}
ownsEpoch(epoch: number): boolean {
return this.phase !== 'disposed' && epoch === this.epoch
}
schedule(epoch: number, retry: (epoch: number) => void): boolean {
if (!this.isCurrent(epoch)) {
return false
}
this.clearRetryTimer()
this.phase = 'backoff'
const delayMs = RECOVERY_DELAYS_MS[Math.min(this.attempt, RECOVERY_DELAYS_MS.length - 1)]
this.attempt += 1
this.onChange?.()
const timer = setTimeout(() => {
if (this.retryTimer !== timer || !this.isCurrent(epoch)) {
return
}
this.retryTimer = null
this.phase = 'recovering'
this.onChange?.()
retry(epoch)
}, delayMs)
timer.unref?.()
this.retryTimer = timer
return true
}
markHealthy(): void {
if (this.phase === 'disposed') {
return
}
this.clearTimers()
this.phase = 'idle'
this.attempt = 0
this.onChange?.()
}
markDisconnected(): void {
if (this.phase === 'disposed') {
return
}
this.clearTimers()
this.phase = 'disconnected'
this.onChange?.()
}
cancel(): void {
if (this.phase === 'disposed') {
return
}
this.epoch += 1
this.clearTimers()
this.phase = 'idle'
this.attempt = 0
this.onChange?.()
}
dispose(): void {
this.epoch += 1
this.clearTimers()
this.phase = 'disposed'
this.onChange?.()
}
private armDeadline(epoch: number): void {
this.clearDeadlineTimer()
const timer = setTimeout(() => {
if (this.deadlineTimer !== timer || !this.isCurrent(epoch)) {
return
}
this.deadlineTimer = null
this.clearRetryTimer()
this.phase = 'disconnected'
this.onChange?.()
}, REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS)
timer.unref?.()
this.deadlineTimer = timer
}
private clearTimers(): void {
this.clearRetryTimer()
this.clearDeadlineTimer()
}
private clearRetryTimer(): void {
if (this.retryTimer) {
clearTimeout(this.retryTimer)
this.retryTimer = null
}
}
private clearDeadlineTimer(): void {
if (this.deadlineTimer) {
clearTimeout(this.deadlineTimer)
this.deadlineTimer = null
}
}
}

View File

@ -14,6 +14,7 @@ import {
TERMINAL_INPUT_MAX_BYTES
} from '../../../../shared/terminal-input'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
describe('createRemoteRuntimePtyTransport', () => {
const runtimeCall = vi.fn()
@ -191,6 +192,363 @@ describe('createRemoteRuntimePtyTransport', () => {
})
})
it('does not report attachment health until the authoritative PTY snapshot arrives', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
transport.attach({ existingPtyId: 'remote:terminal-1', callbacks: {} })
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'authoritative state')
expect(transport.isConnected()).toBe(true)
transport.destroy?.()
})
it('recovers when the first restored-terminal subscription attempt is offline', async () => {
vi.useFakeTimers()
try {
let attempt = 0
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
attempt += 1
if (attempt === 1) {
throw Object.assign(new Error('Could not connect to the remote Orca runtime.'), {
code: 'remote_runtime_unavailable'
})
}
subscriptionCallbacks = callbacks
queueMicrotask(emitMultiplexReady)
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const onData = vi.fn()
const recoveryStates: { phase: string; epoch: number; attempt: number }[] = []
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
transport.attach({
existingPtyId: 'remote:terminal-1',
callbacks: {
onData,
onError,
onRecoveryStateChange: (state) => recoveryStates.push(state)
}
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1))
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1'))
const { streamId } = latestSubscribePayload()
emitSnapshot(streamId, 'restored')
emitOutput(streamId, 'resumed-output')
expect(onError).not.toHaveBeenCalled()
expect(onData).toHaveBeenCalledWith('resumed-output', expect.any(Object))
expect(transport.sendInputImmediate('resumed-input')).toBe(true)
expect(transport.isConnected()).toBe(true)
expect(transport.getRecoveryState?.().phase).toBe('connected')
expect(recoveryStates.map((state) => state.phase)).toEqual(
expect.arrayContaining(['connecting', 'recovering', 'connected'])
)
const recoveryEpochs = new Set(
recoveryStates
.filter((state) => state.phase === 'recovering' || state.phase === 'backoff')
.map((state) => state.epoch)
)
expect(recoveryEpochs.size).toBe(1)
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('recovers when the runtime closes before a restored subscription becomes ready', async () => {
vi.useFakeTimers()
try {
let attempt = 0
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
attempt += 1
subscriptionCallbacks = callbacks
if (attempt === 1) {
queueMicrotask(() => callbacks.onClose?.())
} else {
queueMicrotask(emitMultiplexReady)
}
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
transport.attach({
existingPtyId: 'remote:terminal-1',
callbacks: { onError }
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1))
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
const { streamId } = latestSubscribePayload()
emitSnapshot(streamId, 'restored')
expect(onError).not.toHaveBeenCalled()
expect(transport.isConnected()).toBe(true)
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('surfaces a fatal error during subscription setup exactly once', async () => {
const unsubscribe = vi.fn()
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
subscriptionCallbacks = callbacks
queueMicrotask(() =>
callbacks.onError?.({
code: 'unauthorized',
message: 'Remote Orca runtime rejected the pairing token.'
})
)
return { unsubscribe, sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
transport.attach({ existingPtyId: 'remote:terminal-1', callbacks: { onError } })
await vi.waitFor(() => expect(onError).toHaveBeenCalled())
await Promise.resolve()
expect(onError).toHaveBeenCalledTimes(1)
expect(transport.getRecoveryState?.().phase).toBe('offline')
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(runtimeSubscribe).toHaveBeenCalledTimes(1)
transport.destroy?.()
})
it('retries an unknown terminal-create outcome exactly once with the same mutation id', async () => {
let createCalls = 0
runtimeCall.mockImplementation(async (args: { method: string; params?: unknown }) => {
if (args.method === 'status.get') {
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
}
}
if (args.method === 'terminal.create') {
createCalls += 1
if (createCalls === 1) {
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
}
return { ok: true, result: { terminal: { handle: 'terminal-once' } } }
}
return { ok: true, result: {} }
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtySpawn = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1',
onPtySpawn
})
await transport.connect({ url: '', callbacks: {} })
const creates = runtimeCall.mock.calls
.map(
([args]) =>
args as {
method: string
params?: { clientMutationId?: string; reconcileExisting?: boolean }
}
)
.filter((args) => args.method === 'terminal.create')
expect(creates).toHaveLength(2)
expect(creates[0].params?.clientMutationId).toMatch(/\S+/)
expect(creates[1].params?.clientMutationId).toBe(creates[0].params?.clientMutationId)
expect(creates[0].params?.reconcileExisting).toBeUndefined()
expect(creates[1].params?.reconcileExisting).toBe(true)
expect(onPtySpawn).toHaveBeenCalledTimes(1)
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-once')
transport.destroy?.()
})
it('clips a reconciled create timeout to the budget left after a slow capability probe', async () => {
vi.useFakeTimers()
try {
const startedAt = Date.now()
let createCalls = 0
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'status.get') {
vi.setSystemTime(startedAt + 59_000)
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
}
}
if (args.method === 'terminal.create') {
createCalls += 1
if (createCalls === 1) {
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
}
return { ok: true, result: { terminal: { handle: 'terminal-reconciled' } } }
}
return { ok: true, result: {} }
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
const connect = transport.connect({ url: '', callbacks: {} })
await vi.advanceTimersByTimeAsync(250)
await connect
const createRequests = runtimeCall.mock.calls
.map(([args]) => args as { method: string; timeoutMs: number })
.filter((args) => args.method === 'terminal.create')
expect(createRequests).toHaveLength(2)
expect(createRequests[1].timeoutMs).toBe(1_000)
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('does not retry an unknown create outcome against an older runtime', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'status.get') {
return { ok: true, result: { capabilities: [] } }
}
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
await transport.connect({ url: '', callbacks: { onError } })
expect(
runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.create')
).toHaveLength(1)
expect(onError).toHaveBeenCalledTimes(1)
transport.destroy?.()
})
it('surfaces an authoritative capability-probe failure after an unknown create outcome', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) => {
if (args.method === 'status.get') {
throw Object.assign(new Error('Remote runtime pairing credentials expired.'), {
code: 'unauthorized'
})
}
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
await transport.connect({ url: '', callbacks: { onError } })
expect(onError).toHaveBeenCalledWith('Remote runtime pairing credentials expired.')
expect(
runtimeCall.mock.calls.filter(([args]) => args.method === 'terminal.create')
).toHaveLength(1)
transport.destroy?.()
})
it('stops unknown terminal-create recovery after one minute and remains manually retryable', async () => {
vi.useFakeTimers()
try {
let reachable = false
let statusTimesOut = false
runtimeCall.mockImplementation(async (args: { method: string; timeoutMs: number }) => {
if (args.method === 'status.get') {
if (statusTimesOut) {
return new Promise((_, reject) => {
setTimeout(() => {
reject(
Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
)
}, args.timeoutMs)
})
}
return {
ok: true,
result: { capabilities: [TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY] }
}
}
if (args.method === 'terminal.create' && reachable) {
return { ok: true, result: { terminal: { handle: 'terminal-recovered' } } }
}
throw Object.assign(new Error('Timed out waiting for the remote Orca runtime.'), {
code: 'runtime_timeout'
})
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const recoveryStates: string[] = []
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
const connect = transport.connect({
url: '',
callbacks: {
onError,
onRecoveryStateChange: (state) => recoveryStates.push(state.phase)
}
})
await vi.advanceTimersByTimeAsync(60_000)
await connect
const callsAtCutoff = runtimeCall.mock.calls.length
expect(onError).not.toHaveBeenCalled()
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
expect(recoveryStates).toContain('recovering')
expect(runtimeCall.mock.calls.some(([args]) => args.method === 'terminal.create')).toBe(true)
await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(runtimeCall).toHaveBeenCalledTimes(callsAtCutoff)
statusTimesOut = true
expect(transport.retryRecovery?.()).toBe(true)
await vi.advanceTimersByTimeAsync(60_000)
const callsAtManualCutoff = runtimeCall.mock.calls.length
expect(transport.getRecoveryState?.().phase).toBe('disconnected')
await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(runtimeCall).toHaveBeenCalledTimes(callsAtManualCutoff)
statusTimesOut = false
reachable = true
expect(transport.retryRecovery?.()).toBe(true)
await vi.waitFor(() => expect(transport.getPtyId()).toBe('remote:env-1@@terminal-recovered'))
const createRequests = runtimeCall.mock.calls
.map(([args]) => args as { method: string; params?: { reconcileExisting?: boolean } })
.filter((args) => args.method === 'terminal.create')
expect(createRequests[0].params?.reconcileExisting).toBeUndefined()
expect(createRequests.slice(1).every((args) => args.params?.reconcileExisting === true)).toBe(
true
)
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('scopes the same legacy handle independently for each runtime environment', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const first = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
@ -445,6 +803,7 @@ describe('createRemoteRuntimePtyTransport', () => {
transport.destroy?.()
expect(transport.getRecoveryState?.().phase).toBe('disposed')
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.close'
@ -671,7 +1030,8 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onError).not.toHaveBeenCalled()
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-stale')
expect(transport.isConnected()).toBe(true)
// Cached pixels and a known PTY id do not imply that input/output is attached.
expect(transport.isConnected()).toBe(false)
const handleEvents = await import('../../runtime/web-session-terminal-handle-events')
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1)
@ -718,6 +1078,9 @@ describe('createRemoteRuntimePtyTransport', () => {
)
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout')
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'reattached')
expect(transport.isConnected()).toBe(true)
expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(0)
const subscribedTerminals = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
@ -813,6 +1176,8 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(hostListCalls).toBe(1)
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected')
expect(transport.isConnected()).toBe(false)
emitSnapshot(latestSubscribePayload().streamId, 'reattached')
expect(transport.isConnected()).toBe(true)
})
@ -846,6 +1211,7 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-exited')
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
expect(transport.getRecoveryState?.().phase).toBe('ended')
})
it('ignores stale stream end after reattaching a newer remote terminal', async () => {
@ -880,12 +1246,14 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onPtyExit).not.toHaveBeenCalled()
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-new')
expect(transport.isConnected()).toBe(true)
expect(transport.isConnected()).toBe(false)
await vi.waitFor(() => {
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-new' })
})
const newStreamId = latestSubscribePayload().streamId
emitSnapshot(newStreamId, 'reattached')
expect(transport.isConnected()).toBe(true)
subscriptionCallbacks?.onResponse({
ok: true,
@ -960,7 +1328,10 @@ describe('createRemoteRuntimePtyTransport', () => {
oldSubscription.reject = reject
})
)
.mockResolvedValueOnce(newStream)
.mockImplementationOnce(async (args: { callbacks: { onSubscribed?: () => void } }) => {
args.callbacks.onSubscribed?.()
return newStream
})
vi.doMock('../../runtime/remote-runtime-terminal-multiplexer', () => ({
getRemoteRuntimeTerminalMultiplexer: vi.fn(() => ({ subscribeTerminal }))
}))
@ -1081,6 +1452,39 @@ describe('createRemoteRuntimePtyTransport', () => {
})
})
it('cannot let a stale create completion replace a newer attached terminal', async () => {
let resolveCreate: (value: unknown) => void = () => {}
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return new Promise((resolve) => {
resolveCreate = resolve
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onPtySpawn = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
onPtySpawn
})
const connect = transport.connect({ url: '', callbacks: {} })
transport.attach({ existingPtyId: 'remote:env-2@@terminal-late', callbacks: {} })
resolveCreate({ ok: true, result: { terminal: { handle: 'terminal-late' } } })
await connect
expect(transport.getPtyId()).toBe('remote:env-2@@terminal-late')
expect(onPtySpawn).not.toHaveBeenCalled()
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.close',
params: { terminal: 'terminal-late' },
timeoutMs: 15_000
})
transport.destroy?.()
})
it('passes activation intent when creating the remote runtime terminal', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
@ -1838,6 +2242,212 @@ describe('createRemoteRuntimePtyTransport', () => {
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(2))
})
it('keeps retrying when the first post-partition terminal reattach fails', async () => {
let subscribeAttempt = 0
const recoveryPhases: string[] = []
const transportCallbacks: NonNullable<typeof subscriptionCallbacks>[] = []
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
subscribeAttempt += 1
transportCallbacks.push(callbacks)
subscriptionCallbacks = callbacks
if (subscribeAttempt === 2) {
throw new Error('Could not connect to the remote Orca runtime.')
}
queueMicrotask(emitMultiplexReady)
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({
url: '',
callbacks: {
onError,
onRecoveryStateChange: (state) => recoveryPhases.push(state.phase)
}
})
transportCallbacks[0].onError?.({
code: 'remote_runtime_unavailable',
message: 'Remote Orca runtime stopped responding; the stream connection was reset.'
})
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(3))
expect(onError).not.toHaveBeenCalled()
expect(recoveryPhases).toContain('backoff')
transport.destroy?.()
})
it('surfaces fatal transport errors once without retrying or double-unsubscribing', async () => {
const unsubscribe = vi.fn()
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
subscriptionCallbacks = callbacks
queueMicrotask(emitMultiplexReady)
return { unsubscribe, sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
await transport.connect({ url: '', callbacks: { onError } })
subscriptionCallbacks?.onError?.({
code: 'unauthorized',
message: 'Remote Orca runtime rejected the pairing token.'
})
expect(onError).toHaveBeenCalledTimes(1)
expect(unsubscribe).toHaveBeenCalledTimes(1)
expect(runtimeSubscribe).toHaveBeenCalledTimes(1)
expect(transport.isConnected()).toBe(false)
transport.destroy?.()
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
it('recovers repeated partitions without changing PTY identity or accepting detached input', async () => {
const callbacksByEpoch: NonNullable<typeof subscriptionCallbacks>[] = []
const unsubscribeByEpoch: ReturnType<typeof vi.fn>[] = []
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
callbacksByEpoch.push(callbacks)
subscriptionCallbacks = callbacks
const unsubscribe = vi.fn()
unsubscribeByEpoch.push(unsubscribe)
queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } }))
return { unsubscribe, sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onData = vi.fn()
const onError = vi.fn()
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: { onData, onError } })
const ptyId = transport.getPtyId()
for (let cycle = 0; cycle < 10; cycle += 1) {
callbacksByEpoch.at(-1)?.onError?.({
code: 'remote_runtime_unavailable',
message: 'Remote runtime connection closed.'
})
expect(transport.isConnected()).toBe(false)
expect(transport.sendInput(`detached-${cycle}`)).toBe(false)
expect(unsubscribeByEpoch[cycle]).toHaveBeenCalledTimes(1)
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(cycle + 2))
await vi.waitFor(() => expect(latestSubscribePayload().terminal).toBe('terminal-1'))
const { streamId } = latestSubscribePayload()
expect(transport.isConnected()).toBe(false)
emitSnapshot(streamId, `snapshot-${cycle}`)
await vi.waitFor(() => expect(transport.isConnected()).toBe(true))
emitOutput(streamId, `output-${cycle}`)
expect(transport.sendInputImmediate(`input-${cycle}`)).toBe(true)
expect(transport.getPtyId()).toBe(ptyId)
expect(onData).toHaveBeenCalledWith(`output-${cycle}`, expect.any(Object))
expect(
decodeTerminalStreamText(
latestFrameForOpcode(TerminalStreamOpcode.Input)?.payload ?? new Uint8Array()
)
).toBe(`input-${cycle}`)
expect(callbacksByEpoch).toHaveLength(cycle + 2)
}
expect(onError).not.toHaveBeenCalled()
expect(runtimeSubscribe).toHaveBeenCalledTimes(11)
transport.destroy?.()
expect(unsubscribeByEpoch.every((unsubscribe) => unsubscribe.mock.calls.length === 1)).toBe(
true
)
})
it('stops automatic retries and manually reattaches the same PTY in a new epoch', async () => {
vi.useFakeTimers()
try {
let partitioned = false
const callbacksByConnection: NonNullable<typeof subscriptionCallbacks>[] = []
runtimeSubscribe.mockImplementation(
async (_args: unknown, callbacks: NonNullable<typeof subscriptionCallbacks>) => {
if (partitioned) {
throw Object.assign(new Error('Could not connect to the remote Orca runtime.'), {
code: 'remote_runtime_unavailable'
})
}
callbacksByConnection.push(callbacks)
subscriptionCallbacks = callbacks
queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } }))
return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()
const recoveryStates: { phase: string; epoch: number; attempt: number }[] = []
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
transport.attach({
existingPtyId: 'remote:env-1@@terminal-1',
callbacks: {
onError,
onRecoveryStateChange: (state) => recoveryStates.push(state)
}
})
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
emitSnapshot(latestSubscribePayload().streamId, 'before partition')
expect(transport.isConnected()).toBe(true)
partitioned = true
callbacksByConnection[0].onClose?.()
await vi.advanceTimersByTimeAsync(60_000)
const disconnectedState = transport.getRecoveryState?.()
const callsAtCutoff = runtimeSubscribe.mock.calls.length
expect(disconnectedState?.phase).toBe('disconnected')
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
expect(transport.isConnected()).toBe(false)
expect(transport.sendInput('must not reach a stale socket')).toBe(false)
expect(onError).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(5 * 60_000)
expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff)
partitioned = false
expect(transport.retryRecovery?.()).toBe(true)
expect(transport.retryRecovery?.()).toBe(false)
await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(callsAtCutoff + 1))
await vi.waitFor(() => {
const subscribeFrames = subscriptionSendBinary.mock.calls
.map((call) => decodeTerminalStreamFrame(call[0]))
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe)
expect(subscribeFrames).toHaveLength(2)
})
const manualStream = latestSubscribePayload()
expect(manualStream.terminal).toBe('terminal-1')
emitSnapshot(manualStream.streamId, 'after manual reconnect')
expect(transport.isConnected()).toBe(true)
expect(transport.getRecoveryState?.().phase).toBe('connected')
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-1')
expect(recoveryStates.at(-1)?.epoch).toBeGreaterThan(disconnectedState?.epoch ?? 0)
transport.destroy?.()
} finally {
vi.useRealTimers()
}
})
it('releases pending claimed input when reconnect subscription fails', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onError = vi.fn()

View File

@ -1,16 +1,27 @@
/* eslint-disable max-lines -- Why: remote PTY transport keeps lifecycle, JSON fallback, and binary stream wiring together so reconnect/destroy ordering stays testable as one behavior surface. */
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import {
isRecoverableRemoteRuntimeConnectionError,
toRemoteRuntimeClientErrorLike
} from '../../../../shared/remote-runtime-client-error-classification'
import type {
RuntimeMobileSessionTerminalClientTab,
RuntimeMobileSessionTabsResult,
RuntimeStatus,
RuntimeTerminalCreate,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
} from '../../../../shared/terminal-input'
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
import type {
IpcPtyTransportOptions,
PtyConnectResult,
PtyTransport,
PtyTransportRecoveryState
} from './pty-transport-types'
import { createPtyOutputProcessor } from './pty-transport'
import { unwrapRuntimeRpcResult } from '../../runtime/runtime-rpc-client'
import {
@ -32,6 +43,10 @@ import {
createRemoteRuntimePtyTextBatcher,
createRemoteRuntimeViewportBatcher
} from './remote-runtime-pty-batching'
import {
REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS,
RemoteRuntimePtyRecoveryState
} from './remote-runtime-pty-recovery-state'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { replaceFitOverridePtyId, setFitOverride } from '@/lib/pane-manager/mobile-fit-overrides'
import { replaceDriverPtyId, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state'
@ -44,6 +59,7 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
const HOST_SESSION_ATTACH_POLL_MS = 150
const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
const TERMINAL_CREATE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000] as const
function isRemoteTerminalStaleMessage(message: string): boolean {
return message.includes('terminal_handle_stale')
@ -87,7 +103,12 @@ export function createRemoteRuntimePtyTransport(
onAgentStatus
} = opts
let connected = false
let attachmentReady = false
let destroyed = false
let terminalEnded = false
let connecting = false
// Why: transport methods overlap during remounts; only the latest pane lifecycle may install a returned PTY.
let lifecycleEpoch = 0
let handle: string | null = null
let remotePtyId: string | null = null
let currentRuntimeEnvironmentId = runtimeEnvironmentId
@ -95,13 +116,32 @@ export function createRemoteRuntimePtyTransport(
let multiplexedStreamHandle: string | null = null
let desiredViewport: { cols: number; rows: number } | null = null
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
let resubscribing = false
let resubscribeEpoch: number | null = null
let resubscribeRequestedHandle: string | null = null
let resubscribeRequestedRequiresReplacement = false
let recoveryRequiresReplacement = false
let stopWaitingForPublishedHandle: (() => void) | null = null
let subscriptionGeneration = 0
const recovery = new RemoteRuntimePtyRecoveryState(() => {
if (recovery.currentPhase === 'disconnected') {
clearPublishedHandleWait()
// Why: cached pixels may remain, but no stream from the exhausted epoch may keep delivering or accepting terminal traffic.
subscriptionGeneration += 1
closeMultiplexedStream()
}
emitRecoveryState()
})
let lastRecoveryStateKey = ''
let pendingViewportClaim = false
let pendingClaimInput = ''
let terminalCreateRetryWait: {
timer: ReturnType<typeof setTimeout>
resolve: (continueRetrying: boolean) => void
} | null = null
// Why: after an unknown result, every later attempt must reconcile first so older runtimes cannot duplicate the PTY.
let terminalCreateNeedsReconciliation = false
let terminalCreateUnknownOutcomeError: unknown = null
let lastConnectOptions: Parameters<PtyTransport['connect']>[0] | null = null
const viewportClaimReadyWaiters = new Set<(ready: boolean) => void>()
const clearPendingViewportClaim = (): void => {
pendingViewportClaim = false
@ -113,6 +153,7 @@ export function createRemoteRuntimePtyTransport(
}
// Why: tab/leaf ids are shared by paired viewers; the instance suffix keeps one viewer's refresh off peer records.
const clientId = `desktop:${tabId ?? 'tab'}:${leafId ?? 'leaf'}:${createBrowserUuid()}`
const terminalCreateMutationId = createBrowserUuid()
const outputProcessor = createPtyOutputProcessor({
onTitleChange,
onBell,
@ -122,6 +163,39 @@ export function createRemoteRuntimePtyTransport(
onAgentStatus
})
function getRecoveryState(): PtyTransportRecoveryState {
const phase = destroyed
? 'disposed'
: terminalEnded
? 'ended'
: recovery.currentPhase === 'recovering'
? 'recovering'
: recovery.currentPhase === 'backoff'
? 'backoff'
: recovery.currentPhase === 'disconnected'
? 'disconnected'
: connecting
? 'connecting'
: connected && attachmentReady
? 'connected'
: 'offline'
return {
phase,
epoch: recovery.currentEpoch,
attempt: recovery.attemptCount
}
}
function emitRecoveryState(force = false): void {
const state = getRecoveryState()
const key = `${state.phase}:${state.epoch}:${state.attempt}`
if (!force && key === lastRecoveryStateKey) {
return
}
lastRecoveryStateKey = key
storedCallbacks.onRecoveryStateChange?.(state)
}
function findReadyHostSessionHandle(
snapshot: RuntimeMobileSessionTabsResult,
hostTabId: string
@ -299,7 +373,13 @@ export function createRemoteRuntimePtyTransport(
}
onPtySpawn?.(remotePtyId)
await subscribeToHandle()
try {
await subscribeToHandle()
} catch (error) {
if (!recoverAfterSubscribeFailure(error, hostHandle, remotePtyId)) {
throw error
}
}
if (destroyed || !connected || !remotePtyId) {
return undefined
}
@ -310,13 +390,14 @@ export function createRemoteRuntimePtyTransport(
} satisfies PtyConnectResult
}
async function callRuntime<TResult>(
async function callRuntimeForEnvironment<TResult>(
environmentId: string,
method: string,
params?: unknown,
timeoutMs = 15_000
): Promise<TResult> {
const response = await window.api.runtimeEnvironments.call({
selector: currentRuntimeEnvironmentId,
selector: environmentId,
method,
params,
timeoutMs
@ -324,28 +405,198 @@ export function createRemoteRuntimePtyTransport(
return unwrapRuntimeRpcResult(response as RuntimeRpcResponse<TResult>)
}
async function closeRemoteTerminal(handleOverride?: string): Promise<void> {
async function callRuntime<TResult>(
method: string,
params?: unknown,
timeoutMs = 15_000
): Promise<TResult> {
return callRuntimeForEnvironment(currentRuntimeEnvironmentId, method, params, timeoutMs)
}
function cancelTerminalCreateRetryWait(): void {
const waiting = terminalCreateRetryWait
terminalCreateRetryWait = null
if (waiting) {
clearTimeout(waiting.timer)
waiting.resolve(false)
}
}
function waitForTerminalCreateRetry(delayMs: number): Promise<boolean> {
if (destroyed) {
return Promise.resolve(false)
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (terminalCreateRetryWait?.timer === timer) {
terminalCreateRetryWait = null
}
resolve(!destroyed)
}, delayMs)
timer.unref?.()
terminalCreateRetryWait = { timer, resolve }
})
}
function terminalCreateRecoveryCutoffReached(): boolean {
return recovery.currentPhase === 'disconnected'
}
async function createTerminalWithUnknownOutcomeRecovery(
params: Record<string, unknown>,
environmentId: string,
expectedLifecycleEpoch: number
): Promise<{ terminal: RuntimeTerminalCreate } | null> {
let retryAttempt = 0
let idempotencySupported = false
let reconcileExisting = terminalCreateNeedsReconciliation
let recoveryDeadlineAt: number | null = recovery.isActive
? Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS
: null
let lastError: unknown =
terminalCreateUnknownOutcomeError ?? new Error('Remote terminal creation was cancelled.')
while (
!destroyed &&
lifecycleEpoch === expectedLifecycleEpoch &&
!terminalCreateRecoveryCutoffReached()
) {
if (recoveryDeadlineAt !== null && recoveryDeadlineAt - Date.now() <= 0) {
break
}
while (
reconcileExisting &&
!idempotencySupported &&
!destroyed &&
lifecycleEpoch === expectedLifecycleEpoch &&
!terminalCreateRecoveryCutoffReached()
) {
let status: RuntimeStatus
try {
const statusRemainingMs =
recoveryDeadlineAt === null ? 5_000 : recoveryDeadlineAt - Date.now()
if (statusRemainingMs <= 0) {
break
}
status = await callRuntimeForEnvironment<RuntimeStatus>(
environmentId,
'status.get',
undefined,
Math.min(5_000, statusRemainingMs)
)
} catch (statusError) {
const statusClientError = toRemoteRuntimeClientErrorLike(statusError)
if (!isRecoverableRemoteRuntimeConnectionError(statusClientError)) {
throw statusError
}
const startsRecovery = recoveryDeadlineAt === null
recoveryDeadlineAt ??= Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS
if (startsRecovery && !recovery.isActive) {
recovery.begin()
}
const statusDelayMs =
TERMINAL_CREATE_RETRY_DELAYS_MS[
Math.min(retryAttempt, TERMINAL_CREATE_RETRY_DELAYS_MS.length - 1)
]
retryAttempt += 1
const remainingMs = recoveryDeadlineAt - Date.now()
if (
remainingMs <= 0 ||
terminalCreateRecoveryCutoffReached() ||
!(await waitForTerminalCreateRetry(Math.min(statusDelayMs, remainingMs)))
) {
break
}
continue
}
if (!status.capabilities?.includes(TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY)) {
throw lastError
}
idempotencySupported = true
}
if (
destroyed ||
lifecycleEpoch !== expectedLifecycleEpoch ||
(recoveryDeadlineAt !== null && recoveryDeadlineAt - Date.now() <= 0)
) {
break
}
const createRemainingMs = recoveryDeadlineAt === null ? null : recoveryDeadlineAt - Date.now()
if (createRemainingMs !== null && createRemainingMs <= 0) {
break
}
try {
return await callRuntimeForEnvironment<{ terminal: RuntimeTerminalCreate }>(
environmentId,
'terminal.create',
{
...params,
...(reconcileExisting ? { reconcileExisting: true } : {})
},
Math.min(15_000, createRemainingMs ?? 15_000)
)
} catch (error) {
lastError = error
const clientError = toRemoteRuntimeClientErrorLike(error)
if (!isRecoverableRemoteRuntimeConnectionError(clientError)) {
throw error
}
terminalCreateNeedsReconciliation = true
terminalCreateUnknownOutcomeError ??= error
reconcileExisting = true
const startsRecovery = recoveryDeadlineAt === null
recoveryDeadlineAt ??= Date.now() + REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS
if (startsRecovery && !recovery.isActive) {
recovery.begin()
}
if (destroyed || lifecycleEpoch !== expectedLifecycleEpoch) {
break
}
const remainingMs = recoveryDeadlineAt - Date.now()
if (remainingMs <= 0 || terminalCreateRecoveryCutoffReached()) {
break
}
const delayMs =
TERMINAL_CREATE_RETRY_DELAYS_MS[
Math.min(retryAttempt, TERMINAL_CREATE_RETRY_DELAYS_MS.length - 1)
]
retryAttempt += 1
if (!(await waitForTerminalCreateRetry(Math.min(delayMs, remainingMs)))) {
break
}
}
}
return null
}
async function closeRemoteTerminal(
handleOverride?: string,
environmentId = currentRuntimeEnvironmentId
): Promise<void> {
const targetHandle = handleOverride ?? handle
if (!targetHandle) {
return
}
try {
await callRuntime('terminal.close', { terminal: targetHandle })
await callRuntimeForEnvironment(environmentId, 'terminal.close', { terminal: targetHandle })
} catch {
// Best-effort parity with local disconnect/kill.
}
}
function recoveryBlocksIo(): boolean {
return recovery.isActive || recovery.currentPhase === 'disconnected'
}
async function sendInputAcceptedToRuntime(data: string): Promise<boolean> {
const targetHandle = handle
if (!connected || !targetHandle) {
if (!connected || !targetHandle || recoveryBlocksIo()) {
return false
}
if (!data) {
return true
}
await inputBatcher.drain()
if (!connected || handle !== targetHandle) {
if (!connected || handle !== targetHandle || recoveryBlocksIo()) {
return false
}
if (pendingViewportClaim && !getCurrentMultiplexedStream(targetHandle)) {
@ -368,7 +619,7 @@ export function createRemoteRuntimePtyTransport(
}
try {
for (const chunk of iterateTerminalInputChunks(text)) {
if (!connected || handle !== targetHandle) {
if (!connected || handle !== targetHandle || recoveryBlocksIo()) {
return false
}
// Why: acknowledged sends order behind pending debounce text but must not collapse large paste back into one remote RPC.
@ -392,7 +643,7 @@ export function createRemoteRuntimePtyTransport(
const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => {
const targetHandle = handle
if (!connected || !targetHandle) {
if (!connected || !targetHandle || recoveryBlocksIo()) {
return
}
const stream = getCurrentMultiplexedStream(targetHandle)
@ -416,7 +667,7 @@ export function createRemoteRuntimePtyTransport(
function sendViewportUpdate(cols: number, rows: number, claim = false): void {
const targetHandle = handle
if (!connected || !targetHandle) {
if (!connected || !targetHandle || recoveryBlocksIo()) {
return
}
const stream = getCurrentMultiplexedStream(targetHandle)
@ -456,6 +707,7 @@ export function createRemoteRuntimePtyTransport(
multiplexedStream?.close()
multiplexedStream = null
multiplexedStreamHandle = null
attachmentReady = false
}
function clearPublishedHandleWait(): void {
@ -474,13 +726,18 @@ export function createRemoteRuntimePtyTransport(
}
function retireRemoteTerminalId(): void {
recovery.cancel()
recoveryRequiresReplacement = false
connected = false
connecting = false
terminalEnded = true
clearPublishedHandleWait()
clearPendingViewportClaim()
const stalePtyId = remotePtyId
handle = null
remotePtyId = null
closeMultiplexedStream()
emitRecoveryState()
if (stalePtyId) {
onPtyExit?.(stalePtyId)
}
@ -491,6 +748,7 @@ export function createRemoteRuntimePtyTransport(
const replacedPtyId = remotePtyId
handle = nextHandle
remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId)
attachmentReady = false
// Why: host handle rotation preserves the pane generation; only the store identity changes, not spawn/exit semantics.
if (replacedPtyId) {
replaceFitOverridePtyId(replacedPtyId, remotePtyId)
@ -527,7 +785,7 @@ export function createRemoteRuntimePtyTransport(
const reboundHandle = handle
const reboundPtyId = remotePtyId
void subscribeToHandle().catch((error) => {
if (reboundHandle && isCurrentRemoteTerminal(reboundHandle, reboundPtyId)) {
if (reboundHandle && !recoverAfterSubscribeFailure(error, reboundHandle, reboundPtyId)) {
handleRemoteTerminalError(error)
}
})
@ -556,13 +814,40 @@ export function createRemoteRuntimePtyTransport(
retireRemoteTerminalId()
return
}
if (isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) {
// Why: a partition is attachment state, not a terminal failure; keep the red error surface for actionable fatal errors.
scheduleResubscribeAfterTransportClose()
return
}
connecting = false
emitRecoveryState()
storedCallbacks.onError?.(message)
}
function recoverAfterSubscribeFailure(
error: unknown,
targetHandle: string,
targetPtyId: string | null
): boolean {
if (!isCurrentRemoteTerminal(targetHandle, targetPtyId)) {
return true
}
if (multiplexedStreamHandle !== targetHandle) {
closeMultiplexedStream()
}
clearPendingViewportClaim()
if (!isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error))) {
return false
}
scheduleResubscribeAfterTransportClose()
return true
}
// Why: after a transport drop the host may have re-minted this handle; re-derive from the snapshot so we don't mirror/type into whatever PTY now sits behind the stale one (#7718).
async function resubscribeAfterTransportClose(
previousHandle: string,
requireReplacement: boolean
requireReplacement: boolean,
recoveryEpoch: number
): Promise<void> {
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
const hostTabId = toHostSessionTabId(tabId)
@ -571,7 +856,12 @@ export function createRemoteRuntimePtyTransport(
previousHandle,
requireReplacement
)
if (destroyed || !connected || handle !== previousHandle) {
if (
destroyed ||
!connected ||
handle !== previousHandle ||
!recovery.isCurrent(recoveryEpoch)
) {
return
}
if (nextHandle === undefined) {
@ -587,18 +877,33 @@ export function createRemoteRuntimePtyTransport(
}
}
clearPublishedHandleWait()
await subscribeToHandle()
await subscribeToHandle(recoveryEpoch)
}
function scheduleResubscribeAfterTransportClose(requireReplacement = false): void {
function scheduleResubscribeAfterTransportClose(
requireReplacement = false,
requestedRecoveryEpoch?: number
): void {
if (destroyed || !connected || !handle) {
return
}
const recoveryWasActive = recovery.isActive
const recoveryEpoch = requestedRecoveryEpoch ?? recovery.begin()
if (!recovery.isCurrent(recoveryEpoch)) {
return
}
if (!recoveryWasActive) {
// Why: bytes queued before a partition have unknown delivery; never replay them on a replacement stream.
inputBatcher.clear()
viewportBatcher.clear()
clearPendingViewportClaim()
}
recoveryRequiresReplacement ||= requireReplacement
if (requireReplacement && stopWaitingForPublishedHandle) {
// Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs.
return
}
if (resubscribing) {
if (resubscribeEpoch === recoveryEpoch) {
// Why: concurrent stale errors belong to their own handle; don't carry an old handle's replacement requirement onto its successor.
if (resubscribeRequestedHandle !== handle) {
resubscribeRequestedHandle = handle
@ -614,39 +919,63 @@ export function createRemoteRuntimePtyTransport(
// Why: subscribe before polling so a fresh host snapshot can't land in the gap between the inventory loop and its event-driven fallback.
waitForPublishedHostSessionHandle(toHostSessionTabId(tabId), resubscribeHandle)
}
resubscribing = true
void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement)
resubscribeEpoch = recoveryEpoch
resubscribeRequestedHandle = null
resubscribeRequestedRequiresReplacement = false
let retryScheduled = false
void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement, recoveryEpoch)
.catch((error) => {
if (!destroyed && connected && handle) {
if (!destroyed && connected && handle && recovery.isCurrent(recoveryEpoch)) {
clearPendingViewportClaim()
handleRemoteTerminalError(error)
const clientError = toRemoteRuntimeClientErrorLike(error)
if (isRecoverableRemoteRuntimeConnectionError(clientError)) {
retryScheduled = recovery.schedule(recoveryEpoch, (nextEpoch) => {
scheduleResubscribeAfterTransportClose(requireReplacement, nextEpoch)
})
} else {
recovery.cancel()
handleRemoteTerminalError(error)
}
}
})
.finally(() => {
resubscribing = false
if (resubscribeEpoch !== recoveryEpoch) {
return
}
resubscribeEpoch = null
const pendingHandle = resubscribeRequestedHandle
const pendingRequiresReplacement = resubscribeRequestedRequiresReplacement
resubscribeRequestedHandle = null
resubscribeRequestedRequiresReplacement = false
if (!stopWaitingForPublishedHandle && pendingHandle && pendingHandle === handle) {
if (
!retryScheduled &&
recovery.isCurrent(recoveryEpoch) &&
!stopWaitingForPublishedHandle &&
pendingHandle &&
pendingHandle === handle &&
!getCurrentMultiplexedStream(pendingHandle)
) {
scheduleResubscribeAfterTransportClose(pendingRequiresReplacement)
}
})
}
async function subscribeToHandle(): Promise<void> {
async function subscribeToHandle(expectedRecoveryEpoch?: number): Promise<void> {
if (!handle) {
return
}
const subscribedHandle = handle
const subscribedPtyId = remotePtyId
const generation = ++subscriptionGeneration
attachmentReady = false
let transportClosed = false
let subscriptionAttached = false
// Why: viewport handed to subscribe; a resize during the round-trip falls back to the refresh-only one-shot RPC, replayed through the stream below once current.
const subscribedViewport = desiredViewport
const isCurrentSubscription = (): boolean =>
!transportClosed &&
generation === subscriptionGeneration &&
(expectedRecoveryEpoch === undefined || recovery.ownsEpoch(expectedRecoveryEpoch)) &&
isCurrentRemoteTerminal(subscribedHandle, subscribedPtyId)
const nextStream = await getRemoteRuntimeTerminalMultiplexer(
currentRuntimeEnvironmentId
@ -676,6 +1005,12 @@ export function createRemoteRuntimePtyTransport(
if (!isCurrentSubscription()) {
return
}
subscriptionAttached = true
attachmentReady = true
connecting = false
recoveryRequiresReplacement = false
recovery.markHealthy()
emitRecoveryState()
storedCallbacks.onConnect?.()
storedCallbacks.onStatus?.('shell')
},
@ -685,11 +1020,15 @@ export function createRemoteRuntimePtyTransport(
}
outputProcessor.clearAccumulatedState()
connected = false
connecting = false
handle = null
remotePtyId = null
multiplexedStream = null
multiplexedStreamHandle = null
attachmentReady = false
terminalEnded = true
clearPendingViewportClaim()
emitRecoveryState()
storedCallbacks.onExit?.(0)
storedCallbacks.onDisconnect?.()
if (subscribedPtyId) {
@ -711,7 +1050,7 @@ export function createRemoteRuntimePtyTransport(
setDriverForPty(subscribedPtyId, driver)
}
},
onTransportClose: () => {
onTransportClose: ({ recoverable }) => {
transportClosed = true
if (generation !== subscriptionGeneration) {
return
@ -724,13 +1063,21 @@ export function createRemoteRuntimePtyTransport(
}
multiplexedStream = null
multiplexedStreamHandle = null
scheduleResubscribeAfterTransportClose()
attachmentReady = false
if (recoverable) {
scheduleResubscribeAfterTransportClose()
} else {
connecting = false
recovery.cancel()
emitRecoveryState()
}
}
}
})
if (
transportClosed ||
generation !== subscriptionGeneration ||
(expectedRecoveryEpoch !== undefined && !recovery.ownsEpoch(expectedRecoveryEpoch)) ||
destroyed ||
!connected ||
handle !== subscribedHandle ||
@ -742,6 +1089,11 @@ export function createRemoteRuntimePtyTransport(
closeMultiplexedStream()
multiplexedStream = nextStream
multiplexedStreamHandle = subscribedHandle
attachmentReady = subscriptionAttached
if (subscriptionAttached) {
recoveryRequiresReplacement = false
recovery.markHealthy()
}
// Why: a viewport change during the subscribe round-trip hit the no-op one-shot fallback; replay the latest viewport so the PTY isn't stuck at subscribe-time size.
if (pendingViewportClaim && desiredViewport) {
nextStream.claimViewport(desiredViewport.cols, desiredViewport.rows)
@ -764,9 +1116,17 @@ export function createRemoteRuntimePtyTransport(
}
}
return {
const transport: PtyTransport = {
async connect(options) {
cancelTerminalCreateRetryWait()
const connectLifecycleEpoch = ++lifecycleEpoch
const createEnvironmentId = currentRuntimeEnvironmentId
lastConnectOptions = options
storedCallbacks = options.callbacks
recoveryRequiresReplacement = false
terminalEnded = false
connecting = true
emitRecoveryState(true)
if (destroyed || !worktreeId) {
return
}
@ -785,34 +1145,50 @@ export function createRemoteRuntimePtyTransport(
const resumeProviderSessionToSend = options.resumeProviderSession ?? resumeProviderSession
const launchTokenToSend = options.launchToken ?? launchToken
const launchAgentToSend = options.launchAgent ?? launchAgent
const created = await callRuntime<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
...(commandToSend !== undefined ? { command: commandToSend } : {}),
...(startupCommandDeliveryToSend !== undefined
? { startupCommandDelivery: startupCommandDeliveryToSend }
: {}),
...(envToSend !== undefined ? { env: envToSend } : {}),
...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}),
...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}),
...(resumeProviderSessionToSend !== undefined
? { resumeProviderSession: resumeProviderSessionToSend }
: {}),
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
tabId,
leafId,
focus: false,
// Why: transport backs an already-mounted pane; activation is local state, not permission for remote UI reveal.
presentation: 'background',
...(activate === true ? { activate: true } : {})
})
handle = created.terminal.handle
if (destroyed) {
// Why: cancelled launch, not a shared session; close the server PTY so rapid tab-open/close does not leak.
await closeRemoteTerminal(created.terminal.handle)
const created = await createTerminalWithUnknownOutcomeRecovery(
{
worktree: toRuntimeTerminalWorktreeSelector(worktreeId),
clientMutationId: terminalCreateMutationId,
...(commandToSend !== undefined ? { command: commandToSend } : {}),
...(startupCommandDeliveryToSend !== undefined
? { startupCommandDelivery: startupCommandDeliveryToSend }
: {}),
...(envToSend !== undefined ? { env: envToSend } : {}),
...(envToDeleteToSend !== undefined ? { envToDelete: envToDeleteToSend } : {}),
...(launchConfigToSend !== undefined ? { launchConfig: launchConfigToSend } : {}),
...(resumeProviderSessionToSend !== undefined
? { resumeProviderSession: resumeProviderSessionToSend }
: {}),
...(launchTokenToSend !== undefined ? { launchToken: launchTokenToSend } : {}),
...(launchAgentToSend !== undefined ? { launchAgent: launchAgentToSend } : {}),
...(terminalColorQueryReplies ? { terminalColorQueryReplies } : {}),
tabId,
leafId,
focus: false,
// Why: transport backs an already-mounted pane; activation is local state, not permission for remote UI reveal.
presentation: 'background',
...(activate === true ? { activate: true } : {})
},
createEnvironmentId,
connectLifecycleEpoch
)
if (!created) {
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
connecting = false
recovery.markDisconnected()
}
return
}
if (destroyed || lifecycleEpoch !== connectLifecycleEpoch) {
if (
created.terminal.handle !== handle ||
createEnvironmentId !== currentRuntimeEnvironmentId
) {
await closeRemoteTerminal(created.terminal.handle, createEnvironmentId)
}
return
}
handle = created.terminal.handle
remotePtyId = toRemoteRuntimePtyId(handle, currentRuntimeEnvironmentId)
connected = true
@ -821,8 +1197,15 @@ export function createRemoteRuntimePtyTransport(
rows: options.rows ?? 24
}
onPtySpawn?.(remotePtyId)
emitRecoveryState()
await subscribeToHandle()
try {
await subscribeToHandle()
} catch (error) {
if (!recoverAfterSubscribeFailure(error, handle, remotePtyId)) {
throw error
}
}
if (destroyed || !connected || !remotePtyId) {
return
}
@ -832,14 +1215,26 @@ export function createRemoteRuntimePtyTransport(
replay: ''
} satisfies PtyConnectResult
} catch (error) {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
if (!destroyed && lifecycleEpoch === connectLifecycleEpoch) {
connecting = false
recovery.cancel()
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
emitRecoveryState()
}
return undefined
}
},
attach(options) {
lifecycleEpoch += 1
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
clearPublishedHandleWait()
storedCallbacks = options.callbacks
terminalEnded = false
connecting = true
emitRecoveryState(true)
currentRuntimeEnvironmentId =
getRemoteRuntimePtyEnvironmentId(options.existingPtyId) ?? runtimeEnvironmentId
const previousHandle = handle
@ -851,8 +1246,10 @@ export function createRemoteRuntimePtyTransport(
handle = nextHandle
if (!handle) {
connected = false
connecting = false
remotePtyId = null
closeMultiplexedStream()
emitRecoveryState()
storedCallbacks.onError?.('Remote runtime terminal id is invalid.')
return
}
@ -865,19 +1262,19 @@ export function createRemoteRuntimePtyTransport(
}
const targetHandle = handle
const targetPtyId = remotePtyId
emitRecoveryState()
void subscribeToHandle().catch((error) => {
if (!isCurrentRemoteTerminal(targetHandle, targetPtyId)) {
return
if (!recoverAfterSubscribeFailure(error, targetHandle, targetPtyId)) {
handleRemoteTerminalError(error)
}
if (handle === targetHandle && multiplexedStreamHandle !== targetHandle) {
closeMultiplexedStream()
}
clearPendingViewportClaim()
handleRemoteTerminalError(error)
})
},
disconnect() {
lifecycleEpoch += 1
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
@ -887,11 +1284,14 @@ export function createRemoteRuntimePtyTransport(
return
}
connected = false
connecting = false
terminalEnded = true
clearPendingViewportClaim()
const id = remotePtyId
closeMultiplexedStream()
handle = null
remotePtyId = null
emitRecoveryState()
storedCallbacks.onDisconnect?.()
if (id) {
onPtyExit?.(id)
@ -899,19 +1299,25 @@ export function createRemoteRuntimePtyTransport(
},
detach() {
lifecycleEpoch += 1
cancelTerminalCreateRetryWait()
recovery.cancel()
recoveryRequiresReplacement = false
clearPublishedHandleWait()
inputBatcher.flush()
inputBatcher.clear()
viewportBatcher.flush()
outputProcessor.clearAccumulatedState()
connected = false
connecting = false
clearPendingViewportClaim()
closeMultiplexedStream()
emitRecoveryState()
storedCallbacks = {}
},
sendInput(data: string): boolean {
if (!connected || !handle) {
if (!connected || !handle || recoveryBlocksIo()) {
return false
}
if (!data) {
@ -924,7 +1330,7 @@ export function createRemoteRuntimePtyTransport(
// Why: query replies (CPR/DSR/DA/OSC) are read in raw mode with a short timeout; the 8ms debounce would miss it and echo the reply onto the prompt (#7329).
sendInputImmediate(data: string): boolean {
const targetHandle = handle
if (!connected || !targetHandle) {
if (!connected || !targetHandle || recoveryBlocksIo()) {
return false
}
if (!data) {
@ -964,6 +1370,9 @@ export function createRemoteRuntimePtyTransport(
return false
}
rememberViewport(cols, rows)
if (recoveryBlocksIo()) {
return true
}
viewportBatcher.clear()
sendViewportUpdate(cols, rows, true)
return true
@ -974,6 +1383,9 @@ export function createRemoteRuntimePtyTransport(
return false
}
rememberViewport(cols, rows)
if (recoveryBlocksIo()) {
return true
}
if (meta?.claim) {
viewportBatcher.clear()
sendViewportUpdate(cols, rows, true)
@ -985,7 +1397,43 @@ export function createRemoteRuntimePtyTransport(
},
isConnected() {
return connected
return (
connected &&
!recoveryBlocksIo() &&
attachmentReady &&
multiplexedStream !== null &&
multiplexedStreamHandle === handle
)
},
getRecoveryState,
retryRecovery() {
if (
!destroyed &&
!terminalEnded &&
!connected &&
!handle &&
terminalCreateNeedsReconciliation &&
lastConnectOptions &&
recovery.currentPhase === 'disconnected'
) {
recovery.begin()
void transport.connect(lastConnectOptions)
return true
}
if (
destroyed ||
terminalEnded ||
!connected ||
!handle ||
recovery.currentPhase !== 'disconnected'
) {
return false
}
const recoveryEpoch = recovery.begin()
scheduleResubscribeAfterTransportClose(recoveryRequiresReplacement, recoveryEpoch)
return true
},
getPtyId() {
@ -1010,8 +1458,10 @@ export function createRemoteRuntimePtyTransport(
destroy() {
destroyed = true
this.disconnect()
recovery.dispose()
inputBatcher.clear()
viewportBatcher.clear()
}
}
return transport
}

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { updateTerminalRemoteRuntimeRecoveryUiState } from './terminal-remote-runtime-recovery-ui-state'
describe('updateTerminalRemoteRuntimeRecoveryUiState', () => {
it('tracks only recovery phases that need pane UI', () => {
const recovering = updateTerminalRemoteRuntimeRecoveryUiState({}, 7, {
phase: 'recovering',
epoch: 2,
attempt: 1
})
expect(recovering[7]?.phase).toBe('recovering')
expect(
updateTerminalRemoteRuntimeRecoveryUiState(recovering, 7, {
phase: 'connected',
epoch: 2,
attempt: 0
})
).toEqual({})
})
it('drops disconnected state when the owning pane closes', () => {
const disconnected = updateTerminalRemoteRuntimeRecoveryUiState({}, 7, {
phase: 'disconnected',
epoch: 2,
attempt: 4
})
expect(updateTerminalRemoteRuntimeRecoveryUiState(disconnected, 7, null)).toEqual({})
})
})

View File

@ -0,0 +1,29 @@
import type { PtyTransportRecoveryState } from './pty-transport-types'
export type VisiblePtyRecoveryState = Omit<PtyTransportRecoveryState, 'phase'> & {
phase: Extract<PtyTransportRecoveryState['phase'], 'recovering' | 'backoff' | 'disconnected'>
}
function isVisiblePtyRecoveryState(
state: PtyTransportRecoveryState | null
): state is VisiblePtyRecoveryState {
return (
state?.phase === 'recovering' || state?.phase === 'backoff' || state?.phase === 'disconnected'
)
}
export function updateTerminalRemoteRuntimeRecoveryUiState(
previous: Record<number, VisiblePtyRecoveryState>,
paneId: number,
state: PtyTransportRecoveryState | null
): Record<number, VisiblePtyRecoveryState> {
if (isVisiblePtyRecoveryState(state)) {
return previous[paneId] === state ? previous : { ...previous, [paneId]: state }
}
if (!(paneId in previous)) {
return previous
}
const next = { ...previous }
delete next[paneId]
return next
}

View File

@ -100,6 +100,7 @@ import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-optio
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
import type { PtyTransport } from './pty-transport'
import type { PtyTransportRecoveryState } from './pty-transport-types'
import {
reconcileMissingSessions,
type ReconcilableBinding
@ -264,6 +265,9 @@ type UseTerminalPaneLifecycleDeps = {
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
onPtyRecoveryStateRef?: React.RefObject<
(paneId: number, state: PtyTransportRecoveryState | null) => void
>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
updateTabTitle: (tabId: string, title: string) => void
@ -519,6 +523,7 @@ export function useTerminalPaneLifecycle({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit,
updateTabTitle,
@ -731,6 +736,7 @@ export function useTerminalPaneLifecycle({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit,
updateTabTitle,
@ -1126,6 +1132,7 @@ export function useTerminalPaneLifecycle({
queueResizeAll(true)
},
onPaneClosed: (paneId, closedPane) => {
onPtyRecoveryStateRef?.current?.(paneId, null)
const isDetachedToTab = closedPane?.reason === 'detach'
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
if (linkProviderDisposable) {

View File

@ -2737,6 +2737,13 @@
"removedTitle": "SSH host removed",
"removedBody": "The SSH host for this workspace was removed, so it can no longer connect. Remove the workspace to clear it — remote files are left untouched.",
"removeWorkspaceButton": "Remove workspace"
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
}
}
},

View File

@ -2714,6 +2714,13 @@
"removedTitle": "Host SSH eliminado",
"removedBody": "El host SSH de este espacio de trabajo fue eliminado, por lo que ya no puede conectarse. Elimina el espacio de trabajo para limpiarlo — los archivos remotos permanecen intactos.",
"removeWorkspaceButton": "Eliminar espacio de trabajo"
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
}
}
},

View File

@ -2714,6 +2714,13 @@
"removedTitle": "SSHホストが削除されました",
"removedBody": "このワークスペースのSSHホストが削除されたため、接続できません。ワークスペースを削除してクリアしてください — リモートファイルはそのまま残ります。",
"removeWorkspaceButton": "ワークスペースを削除"
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
}
}
},

View File

@ -2714,6 +2714,13 @@
"removedTitle": "SSH 호스트가 제거됨",
"removedBody": "이 워크트리의 SSH 호스트가 제거되어 더 이상 연결할 수 없습니다. 워크트리를 제거하여 정리하세요 — 원격 파일은 그대로 남아 있습니다.",
"removeWorkspaceButton": "워크트리 제거"
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
}
}
},

View File

@ -2714,6 +2714,13 @@
"removedTitle": "SSH 主机已移除",
"removedBody": "此工作区的 SSH 主机已被移除,无法再连接。请移除工作区以清理 — 远程文件不会受影响。",
"removeWorkspaceButton": "移除工作区"
},
"TerminalRemoteRuntimeReconnectBanner": {
"retryingTitle": "Reconnecting to remote runtime",
"disconnectedTitle": "Remote runtime disconnected",
"retryingBody": "Orca will retry for up to one minute. This terminal will resume if the connection returns.",
"disconnectedBody": "Automatic retries stopped. Reconnect to resume this terminal session.",
"reconnectButton": "Reconnect"
}
}
},

View File

@ -1,5 +1,6 @@
/* eslint-disable max-lines -- Why: the remote terminal multiplexer owns one bridged subscription, stream lifecycle, binary frame parsing, and remote lock events as a single transport contract. */
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { isRecoverableRemoteRuntimeConnectionError } from '../../../shared/remote-runtime-client-error-classification'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
@ -50,7 +51,7 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
onDriverChanged?: (
driver: { kind: 'idle' } | { kind: 'desktop' } | { kind: 'mobile'; clientId: string }
) => void
onTransportClose?: () => void
onTransportClose?: (event: { recoverable: boolean }) => void
}
export type RemoteRuntimeMultiplexedTerminal = {
@ -72,6 +73,7 @@ type RemoteRuntimeMultiplexedTerminalState = {
streamId: number
terminal: string
callbacks: RemoteRuntimeMultiplexedTerminalCallbacks
subscriptionRequested: boolean
acknowledgeOutput: boolean
heldAckBytes: number
snapshotChunks: Uint8Array<ArrayBufferLike>[]
@ -223,6 +225,7 @@ class RemoteRuntimeTerminalMultiplexer {
streamId,
terminal: args.terminal,
callbacks: args.callbacks,
subscriptionRequested: false,
acknowledgeOutput: args.client.type === 'desktop',
heldAckBytes: 0,
snapshotChunks: [],
@ -295,6 +298,7 @@ class RemoteRuntimeTerminalMultiplexer {
if (!sent) {
throw new Error('Remote terminal stream is not connected.')
}
state.subscriptionRequested = true
} catch (error) {
const terminalError = error instanceof Error ? error : new Error(String(error))
if (this.streams.get(streamId) === state) {
@ -340,7 +344,13 @@ class RemoteRuntimeTerminalMultiplexer {
{
onResponse: (response) => this.handleResponse(response),
onBinary: (bytes) => this.handleBinary(bytes),
onError: (error) => this.failConnection(new Error(error.message)),
onError: (error) => {
if (isRecoverableRemoteRuntimeConnectionError(error)) {
this.handleClose(error.message)
} else {
this.failConnection(Object.assign(new Error(error.message), { code: error.code }))
}
},
onClose: () => this.handleClose('Remote Orca runtime closed the connection.')
}
)
@ -764,34 +774,36 @@ class RemoteRuntimeTerminalMultiplexer {
this.readyResolver = null
this.readyRejecter = null
for (const stream of this.streams.values()) {
stream.callbacks.onError?.(error.message)
// Why: a stream still awaiting ensureConnected receives this failure through its rejected promise.
if (stream.subscriptionRequested) {
stream.callbacks.onError?.(error.message)
}
}
this.subscription?.unsubscribe()
this.handleClose()
this.handleClose(undefined, false)
}
private handleClose(message?: string): void {
private handleClose(message?: string, recoverable = true): void {
const streams = Array.from(this.streams.values())
const closingSubscription = this.subscription
this.ready = false
this.connectPromise = null
this.readyRejecter?.(new Error(message ?? 'Remote runtime connection closed.'))
this.readyResolver = null
this.readyRejecter = null
this.subscription = null
closingSubscription?.unsubscribe()
this.streams.clear()
// Why: close callbacks may resubscribe synchronously; release first so every replacement shares the new environment multiplexer.
this.releaseIfCurrent(this.environmentId, this)
for (const stream of streams) {
clearSnapshot(stream)
rejectPendingSnapshotRequest(stream, message ?? 'Remote runtime connection closed.')
const canHandleClose = Boolean(stream.callbacks.onTransportClose)
stream.callbacks.onTransportClose?.()
stream.callbacks.onTransportClose?.({ recoverable })
if (message && !canHandleClose) {
stream.callbacks.onError?.(message)
}
}
// Why: a closed transport has no live streams or subscription; keeping it
// in the module map only retains callbacks for an environment that must
// reconnect through a fresh subscription anyway.
this.releaseIfCurrent(this.environmentId, this)
}
private closeIfIdle(): void {

View File

@ -51,6 +51,9 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
// replay ambiguous cutovers when the host advertises idempotent create support.
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'worktree.create-idempotency.v1' as const
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'terminal.create-idempotency.v2' as const
export const RUNTIME_CAPABILITIES = [
'runtime.status.compat.v1',
@ -69,7 +72,8 @@ export const RUNTIME_CAPABILITIES = [
AI_VAULT_RUNTIME_CAPABILITY,
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
isRecoverableRemoteRuntimeConnectionError,
toRemoteRuntimeClientErrorLike
} from './remote-runtime-client-error-classification'
describe('remote runtime client error classification', () => {
it.each(['remote_runtime_unavailable', 'runtime_timeout', 'runtime_unavailable', 'reconnecting'])(
'treats %s as recoverable',
(code) => {
expect(isRecoverableRemoteRuntimeConnectionError({ code, message: 'transport failed' })).toBe(
true
)
}
)
it('does not retry authentication or protocol failures', () => {
expect(
isRecoverableRemoteRuntimeConnectionError({ code: 'unauthorized', message: 'bad token' })
).toBe(false)
expect(
isRecoverableRemoteRuntimeConnectionError({
code: 'invalid_runtime_response',
message: 'bad frame'
})
).toBe(false)
})
it.each([
'Could not connect to the remote Orca runtime.',
'Remote Orca runtime closed the connection.',
'Remote Orca runtime connection closed.',
'Remote Orca runtime is not connected.',
'Remote runtime subscription closed before it started.'
])('normalizes unstructured connection failure: %s', (message) => {
const error = toRemoteRuntimeClientErrorLike(new Error(message))
expect(isRecoverableRemoteRuntimeConnectionError(error)).toBe(true)
})
})

View File

@ -0,0 +1,43 @@
export type RemoteRuntimeClientErrorLike = { code?: string; message: string }
const RECOVERABLE_CODES = new Set([
'remote_runtime_unavailable',
'runtime_timeout',
'runtime_unavailable',
'reconnecting',
'timeout'
])
const RECOVERABLE_MESSAGE_FRAGMENTS = [
'could not connect to the remote orca runtime',
'remote orca runtime closed the connection',
'remote orca runtime connection closed',
'remote orca runtime is not connected',
'remote runtime connection closed',
'remote runtime subscription closed before it started',
'remote terminal stream is not connected',
'timed out waiting for the remote orca runtime'
]
export function isRecoverableRemoteRuntimeConnectionError(
error: RemoteRuntimeClientErrorLike
): boolean {
if (error.code && RECOVERABLE_CODES.has(error.code)) {
return true
}
const message = error.message.toLowerCase()
return RECOVERABLE_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment))
}
export function toRemoteRuntimeClientErrorLike(error: unknown): RemoteRuntimeClientErrorLike {
if (error && typeof error === 'object') {
const candidate = error as { code?: unknown; message?: unknown }
if (typeof candidate.message === 'string') {
return {
...(typeof candidate.code === 'string' ? { code: candidate.code } : {}),
message: candidate.message
}
}
}
return { message: String(error) }
}

View File

@ -172,10 +172,11 @@ describe('RemoteRuntimeSharedControlConnection', () => {
const server = await createServer({ closeAfterFirstStreamingResponse: true })
const connection = new RemoteRuntimeSharedControlConnection(server.pairing)
const onClose = vi.fn()
const onError = vi.fn()
await connection.subscribe('runtime.clientEvents.subscribe', null, 1000, {
onResponse: vi.fn(),
onError: vi.fn(),
onError,
onClose
})
@ -186,22 +187,23 @@ describe('RemoteRuntimeSharedControlConnection', () => {
'runtime.clientEvents.subscribe'
])
)
expect(onError).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
connection.close()
})
it('emits one final close when reconnect attempts are exhausted', async () => {
it('keeps passive subscriptions alive after reaching the capped reconnect delay', async () => {
const server = await createServer()
const connection = new RemoteRuntimeSharedControlConnection(server.pairing)
const onClose = vi.fn()
const unsafe = connection as unknown as {
reconnectAttempt: number
reconnect: { attempt: number }
subscriptions: Map<string, unknown>
scheduleReconnect: () => void
}
unsafe.reconnectAttempt = 7
unsafe.reconnect.attempt = 7
unsafe.subscriptions.set('sub-1', {
requestId: 'sub-1',
method: 'runtime.clientEvents.subscribe',
@ -215,11 +217,11 @@ describe('RemoteRuntimeSharedControlConnection', () => {
unsafe.scheduleReconnect()
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
expect(connection.getDiagnostics()).toMatchObject({
state: 'closed',
reconnectAttempt: 7,
subscriptionCount: 0
state: 'reconnecting',
reconnectAttempt: 8,
subscriptionCount: 1
})
connection.close()
@ -234,7 +236,7 @@ describe('RemoteRuntimeSharedControlConnection', () => {
await expect(connection.request('worktree.ps', undefined, 1000)).resolves.toMatchObject({
ok: true
})
;(connection as unknown as { reconnectAttempt: number }).reconnectAttempt = 3
;(connection as unknown as { reconnect: { attempt: number } }).reconnect.attempt = 3
await vi.waitFor(() =>
expect(connection.getDiagnostics()).toMatchObject({ reconnectAttempt: 0 })

View File

@ -10,7 +10,7 @@ import {
isSharedControlReady,
waitForSharedControlReadyWithTimeout
} from './remote-runtime-shared-control-ready'
import { scheduleSharedControlReconnectOrFinish } from './remote-runtime-shared-control-reconnect'
import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect'
import { requestSharedControl } from './remote-runtime-shared-control-requests'
import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability'
import * as sharedControlState from './remote-runtime-shared-control-state'
@ -22,6 +22,7 @@ import { closeSharedControlSocket } from './remote-runtime-shared-control-socket
import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness'
import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions'
import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start'
import { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation'
import type {
RemoteRuntimeSharedConnectionDiagnostics,
RemoteRuntimeSharedSubscription,
@ -37,9 +38,8 @@ export class RemoteRuntimeSharedControlConnection {
private ws: WebSocket | null = null
private sharedKey: Uint8Array | null = null
private socketCleanup: (() => void) | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private readonly reconnect = new SharedControlReconnectScheduler()
private readonly readyStableReset: SharedControlReadyStableResetTimer
private reconnectAttempt = 0
private intentionallyClosed = false
private lastConnectedAt: number | null = null
private lastClose: { code: number; reason: string } | null = null
@ -48,6 +48,7 @@ export class RemoteRuntimeSharedControlConnection {
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
private readonly readyWaiters: SharedControlReadyWaiter[] = []
private everReady = false
private readonly socketGeneration = new SharedControlSocketGeneration()
constructor(
private readonly pairing: PairingOffer,
@ -97,7 +98,8 @@ export class RemoteRuntimeSharedControlConnection {
close(error?: Error): void {
this.intentionallyClosed = true
this.clearReconnectTimer()
this.socketGeneration.invalidate()
this.reconnect.clear()
for (const subscription of Array.from(this.subscriptions.values())) {
this.closeSubscription(subscription.requestId)
}
@ -107,10 +109,10 @@ export class RemoteRuntimeSharedControlConnection {
getDiagnostics(): RemoteRuntimeSharedConnectionDiagnostics {
return sharedControlState.buildSharedControlDiagnostics({
state: this.state,
reconnecting: this.reconnectTimer !== null,
reconnecting: this.reconnect.isScheduled,
pendingRequestCount: this.pendingRequests.size,
subscriptionCount: this.subscriptions.size,
reconnectAttempt: this.reconnectAttempt,
reconnectAttempt: this.reconnect.attemptCount,
lastConnectedAt: this.lastConnectedAt,
lastClose: this.lastClose,
lastError: this.lastError
@ -144,25 +146,25 @@ export class RemoteRuntimeSharedControlConnection {
)
return
}
this.clearReconnectTimer()
this.reconnect.clear()
const socketGeneration = this.socketGeneration.begin()
const opened = openSharedControlSocket(this.pairing, {
getCurrentSocket: () => this.ws,
onClose: (close, error) => {
this.lastClose = close
this.handleSocketClosed(error)
if (this.socketGeneration.isCurrent(socketGeneration)) {
this.lastClose = close
}
this.handleSocketClosed(error, socketGeneration)
},
onError: (error) => {
this.lastError = error.message
this.handleSocketClosed(error)
},
onTextFrame: (frame) => this.handleTextFrame(frame),
onError: (error) => this.handleSocketClosed(error, socketGeneration),
onTextFrame: (frame) => this.handleTextFrame(frame, socketGeneration),
liveness: {
options: this.options.liveness,
onDead: (error) => this.handleSocketClosed(error)
onDead: (error) => this.handleSocketClosed(error, socketGeneration)
}
})
if (!opened.ok) {
this.handleSocketClosed(opened.error)
this.handleSocketClosed(opened.error, socketGeneration)
return
}
this.ws = opened.socket.ws
@ -171,7 +173,10 @@ export class RemoteRuntimeSharedControlConnection {
this.state = 'awaiting_ready'
}
private handleTextFrame(frame: string): void {
private handleTextFrame(frame: string, socketGeneration: number): void {
if (!this.socketGeneration.isCurrent(socketGeneration)) {
return
}
handleSharedControlTextFrame({
frame,
state: this.state,
@ -184,7 +189,7 @@ export class RemoteRuntimeSharedControlConnection {
setState: (state) => {
this.state = state
},
handleSocketClosed: (error) => this.handleSocketClosed(error),
handleSocketClosed: (error) => this.handleSocketClosed(error, socketGeneration),
sendEncrypted: (payload) => this.sendEncrypted(payload),
markReady: () => {
this.lastConnectedAt = Date.now()
@ -236,6 +241,7 @@ export class RemoteRuntimeSharedControlConnection {
subscription,
request: (method, params) => this.sendSubscriptionCleanupRequest(method, params)
})
this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed')
}
private sendEncrypted(payload: unknown): boolean {
@ -256,9 +262,19 @@ export class RemoteRuntimeSharedControlConnection {
})
}
private handleSocketClosed(error: RemoteRuntimeClientError): void {
private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void {
if (
!this.socketGeneration.acceptClose({
generation: socketGeneration,
error,
everReady: this.everReady,
subscriptions: this.subscriptions,
closeSocket: () => this.closeSocket(error)
})
) {
return
}
this.lastError = error.message
this.closeSocket(error)
if (this.subscriptions.size > 0 && !this.intentionallyClosed) {
this.scheduleReconnect()
}
@ -286,35 +302,18 @@ export class RemoteRuntimeSharedControlConnection {
}
private scheduleReconnect(): void {
const scheduled = scheduleSharedControlReconnectOrFinish({
current: this.reconnectTimer,
this.reconnect.schedule({
intentionallyClosed: this.intentionallyClosed,
reconnectAttempt: this.reconnectAttempt,
delaysMs: [250, 500, 1000, 2000, 4000, 8000, 15_000],
subscriptions: this.subscriptions,
open: () => {
this.reconnectTimer = null
this.open()
}
delaysMs: [250, 500, 1000, 2000, 4000, 8000, 15_000, 30_000],
open: () => this.open()
})
this.reconnectTimer = scheduled.timer
this.reconnectAttempt = scheduled.reconnectAttempt
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
private scheduleReconnectAttemptReset(): void {
this.readyStableReset.schedule({
getState: () => this.state,
getSocket: () => this.ws,
reset: () => {
this.reconnectAttempt = 0
}
reset: () => this.reconnect.resetAttempt()
})
}
}

View File

@ -1,26 +1,50 @@
import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
import {
finishSharedControlSubscription,
scheduleSharedControlReconnect
} from './remote-runtime-shared-control-state'
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
import { scheduleSharedControlReconnect } from './remote-runtime-shared-control-state'
export function scheduleSharedControlReconnectOrFinish(args: {
current: ReturnType<typeof setTimeout> | null
intentionallyClosed: boolean
reconnectAttempt: number
delaysMs: readonly number[]
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
open: () => void
}): { timer: ReturnType<typeof setTimeout> | null; reconnectAttempt: number } {
if (args.reconnectAttempt >= args.delaysMs.length) {
const error = remoteRuntimeUnavailableError(
'Remote Orca runtime connection could not be restored.'
)
for (const subscription of Array.from(args.subscriptions.values())) {
finishSharedControlSubscription(args.subscriptions, subscription, true, error)
}
return { timer: null, reconnectAttempt: args.reconnectAttempt }
export class SharedControlReconnectScheduler {
private timer: ReturnType<typeof setTimeout> | null = null
private attempt = 0
get isScheduled(): boolean {
return this.timer !== null
}
get attemptCount(): number {
return this.attempt
}
schedule(args: {
intentionallyClosed: boolean
delaysMs: readonly number[]
open: () => void
}): void {
// Why: a passive subscription owns recovery until its caller closes it; roaming outages are unbounded.
const scheduled = scheduleSharedControlReconnect({
...args,
current: this.timer,
reconnectAttempt: this.attempt,
open: () => {
this.timer = null
args.open()
}
})
this.timer = scheduled.timer
this.attempt = scheduled.reconnectAttempt
}
clear(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
clearWhenIdle(isIdle: boolean): void {
if (isIdle) {
this.clear()
}
}
resetAttempt(): void {
this.attempt = 0
}
return scheduleSharedControlReconnect(args)
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest'
import { remoteRuntimeUnavailableError } from './remote-runtime-request-frames'
import { SharedControlSocketGeneration } from './remote-runtime-shared-control-socket-generation'
describe('SharedControlSocketGeneration', () => {
it('ignores stale close callbacks and accepts one close for the current socket', () => {
const generations = new SharedControlSocketGeneration()
const stale = generations.begin()
const current = generations.begin()
const closeSocket = vi.fn()
const onError = vi.fn()
const throwingOnError = vi.fn(() => {
throw new Error('consumer failed')
})
const subscriptions = new Map([
[
'subscription-1',
{
requestId: 'subscription-1',
method: 'session.tabs.subscribeAll',
params: null,
callbacks: { onResponse: vi.fn(), onError },
sent: true,
closed: false,
closeAfterReady: false,
remoteSubscriptionId: 'remote-1'
}
],
[
'subscription-2',
{
requestId: 'subscription-2',
method: 'runtime.clientEvents.subscribe',
params: null,
callbacks: { onResponse: vi.fn(), onError: throwingOnError },
sent: true,
closed: false,
closeAfterReady: false,
remoteSubscriptionId: 'remote-2'
}
]
])
const args = {
error: remoteRuntimeUnavailableError(),
everReady: true,
subscriptions,
closeSocket
}
expect(generations.acceptClose({ ...args, generation: stale })).toBe(false)
expect(generations.acceptClose({ ...args, generation: current })).toBe(true)
expect(generations.acceptClose({ ...args, generation: current })).toBe(false)
expect(closeSocket).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledTimes(1)
expect(throwingOnError).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,44 @@
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types'
export class SharedControlSocketGeneration {
private current = 0
isCurrent(generation: number): boolean {
return generation === this.current
}
begin(): number {
this.current += 1
return this.current
}
invalidate(): void {
this.current += 1
}
acceptClose(args: {
generation: number
error: RemoteRuntimeClientError
everReady: boolean
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
closeSocket: () => void
}): boolean {
if (!this.isCurrent(args.generation)) {
return false
}
// Why: error, close, and liveness callbacks can race for one socket; only its first callback owns recovery.
this.current += 1
args.closeSocket()
if (args.everReady) {
for (const subscription of Array.from(args.subscriptions.values())) {
try {
subscription.callbacks.onError(args.error)
} catch {
// Why: one consumer callback cannot block sibling notification or reconnect scheduling.
}
}
}
return true
}
}

View File

@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startRemoteRuntimeSocketLiveness } from './remote-runtime-socket-liveness'
describe('remote runtime socket liveness', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('grants a fresh probe window after a suspended client resumes', async () => {
let now = 1_000
const ping = vi.fn()
const onDead = vi.fn()
const monitor = startRemoteRuntimeSocketLiveness({
ping,
onDead,
options: { pingIntervalMs: 100, livenessTimeoutMs: 250 },
now: () => now
})
now += 3_600_000
await vi.advanceTimersByTimeAsync(100)
expect(ping).toHaveBeenCalledTimes(1)
expect(onDead).not.toHaveBeenCalled()
for (const delta of [100, 100]) {
now += delta
await vi.advanceTimersByTimeAsync(100)
}
expect(onDead).not.toHaveBeenCalled()
now += 100
await vi.advanceTimersByTimeAsync(100)
expect(onDead).toHaveBeenCalledTimes(1)
monitor.stop()
})
it('clears the resumed probe when the socket answers', async () => {
let now = 1_000
const ping = vi.fn()
const onDead = vi.fn()
const monitor = startRemoteRuntimeSocketLiveness({
ping,
onDead,
options: { pingIntervalMs: 100, livenessTimeoutMs: 250 },
now: () => now
})
now += 3_600_000
await vi.advanceTimersByTimeAsync(100)
monitor.noteActivity()
for (const delta of [100, 100, 100]) {
now += delta
await vi.advanceTimersByTimeAsync(100)
}
expect(onDead).not.toHaveBeenCalled()
monitor.stop()
})
})

View File

@ -33,23 +33,31 @@ export function startRemoteRuntimeSocketLiveness(args: {
const pingIntervalMs = args.options?.pingIntervalMs ?? REMOTE_RUNTIME_SOCKET_PING_INTERVAL_MS
const livenessTimeoutMs =
args.options?.livenessTimeoutMs ?? REMOTE_RUNTIME_SOCKET_LIVENESS_TIMEOUT_MS
let lastActivityAt = now()
let lastTickAt = now()
let probeSentAt: number | null = null
let stopped = false
const timer = setInterval(() => {
if (stopped) {
return
}
if (now() - lastActivityAt > livenessTimeoutMs) {
const tickAt = now()
const tickElapsedMs = tickAt - lastTickAt
lastTickAt = tickAt
// Why: sleep and background throttling age sockets without giving them a chance to answer.
if (tickElapsedMs < 0 || tickElapsedMs > pingIntervalMs * 1.5) {
probeSentAt = tickAt
tryPing()
return
}
if (probeSentAt !== null && tickAt - probeSentAt > livenessTimeoutMs) {
stop()
args.onDead()
return
}
try {
args.ping()
} catch {
// Why: ping() can throw while a socket is mid-teardown; the liveness
// window (or the close handler) settles the socket's fate either way.
if (probeSentAt === null) {
probeSentAt = tickAt
tryPing()
}
}, pingIntervalMs)
// Why: mobile typechecks shared code with DOM timer types where unref is absent.
@ -66,9 +74,17 @@ export function startRemoteRuntimeSocketLiveness(args: {
clearInterval(timer)
}
function tryPing(): void {
try {
args.ping()
} catch {
// Why: ping() can throw while a socket is mid-teardown; the probe deadline still settles it.
}
}
return {
noteActivity: () => {
lastActivityAt = now()
probeSentAt = null
},
stop
}