fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514)

This commit is contained in:
Jinwoo Hong
2026-08-19 15:47:31 -07:00
committed by GitHub
parent 7675da363e
commit fcdbcf85d0
8 changed files with 1265 additions and 7 deletions
@@ -4,7 +4,7 @@ import { useAppStore } from '@/store'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
import { subscribeToPtyExit } from './pty-dispatcher'
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { discardPreHandlerPtyState, hasPreHandlerPtyExit } from './pty-pre-handler-buffer'
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
import {
isParkRestorableTerminalPty,
@@ -34,6 +34,17 @@ export function startParkedPtyWatcher(args: {
const state = useAppStore.getState()
const ptyId = pane.ptyId
// Why: the tab model can change after the park decision, and legacy leaf ids make pane keys throw.
// Why: the pane's primary exit handler is gone from unmount and this sidecar
// arrives a passive effect later, so an exit landing in between is buffered
// and replayed to nobody. Registering would pin a dead PTY as a live parked
// owner, and the runtime graph publishes its leaf on exactly that claim.
if (ptyId && !entry.disposersByPtyId.has(ptyId) && hasPreHandlerPtyExit(ptyId)) {
// Mirror handlePtyExit's first act: no live pane will ever overwrite this
// slot, so leaving it would strand a dead pane's last title (a 'working'
// agent title pins worktree status) until reveal or close.
state.clearRuntimePaneTitle(tab.id, pane.paneId)
return
}
if (
!ptyId ||
entry.disposersByPtyId.has(ptyId) ||
@@ -48,9 +48,13 @@ vi.mock('./pty-dispatcher', () => ({
const consumePreHandlerPtyState = vi.fn()
vi.mock('./pty-pre-handler-buffer', () => ({
discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId)
discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId),
hasPreHandlerPtyExit: (ptyId: string) => unownedExitPtyIds.has(ptyId)
}))
/** PTYs whose exit was delivered with no owning handler — the park handoff gap. */
const unownedExitPtyIds = new Set<string>()
type CloseTerminalTabOptions = {
captureRecentlyClosed?: boolean
hostCloseReason?: string
@@ -110,6 +114,7 @@ import {
captureParkedTerminalPaneCandidates,
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
collectParkedTerminalWatcherPtyIds,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
@@ -164,6 +169,7 @@ describe('terminal-parked-tab-watchers', () => {
})
afterEach(() => {
unownedExitPtyIds.clear()
// Module-level registries persist across tests; clear them through the
// public prune path so each test starts from an empty parked state.
pruneParkedTerminalWatchers(new Set())
@@ -229,6 +235,23 @@ describe('terminal-parked-tab-watchers', () => {
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('never starts a watcher for a PTY that exited into the park handoff gap', () => {
// The pane's primary exit handler is gone from unmount and this sidecar
// arrives a passive effect later, so an exit landing between them is
// buffered and replayed to nobody. Registering anyway would make the
// registry claim a dead PTY is a live parked owner, and the runtime graph
// publishes its leaf on exactly that claim (STA-2854).
unownedExitPtyIds.add(PTY_ID)
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked({ tabs: [{ id: TAB_ID, ptyId: PTY_ID }] })
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
expect(collectParkedTerminalWatcherPtyIds().has(PTY_ID)).toBe(false)
// No live pane will ever overwrite this slot again, so a stranded
// 'working' title would pin worktree status forever.
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
})
it('starts a fact watcher for snapshot-capable paired PTYs', () => {
mockStoreState.runtimeStatusByEnvironmentId.set('env-1', {
status: { capabilities: ['terminal.paired-parking.v1'] },
@@ -33,6 +33,7 @@ import {
// Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle.
export {
captureParkedTerminalPaneCandidates,
collectParkedTerminalWatcherPtyIds,
disposeAllParkedTerminalWatchers,
disposeRemovedWorktreeParkedTerminalWatchers,
disposeParkedTerminalWatchersForPtyIds,
@@ -7,7 +7,8 @@
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
*/
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
import { discardPreHandlerPtyState, hasPreHandlerPtyExit } from './pty-pre-handler-buffer'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
export type ParkedTerminalPaneCapture = {
@@ -67,6 +68,57 @@ export function isTerminalTabParked(tabId: string): boolean {
return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0
}
/**
* PTYs a live parked watcher owns, and can therefore prove are still alive.
* The runtime graph needs this to keep publishing an unmounted pane's leaf
* (STA-2854: a dropped leaf invalidates the terminal handle every paired
* subscriber of that terminal is bound to).
*
* Built in one pass and reused for a whole publication: the caller checks it
* once per saved leaf across every tab of every worktree, so a per-PTY scan of
* the registry would be quadratic in a large workspace. Parked tabs are NOT
* bounded by the hot-retain limits — those bound what stays warm, not what
* parks — so this can legitimately hold thousands of entries.
*
* Reads `disposersByPtyId`, never `paneIdByPtyId`: exit and per-PTY disposal
* delete only the disposer and deliberately keep the pane-id slot so the dead
* leaf's runtime title can still be cleared.
*
* Excludes remote-runtime PTYs (detected through the store-free shared id
* module — this file must stay importable from a store slice). startParkedPtyWatcher installs no PTY-exit
* subscription for them, and the parked fact stream carries no exit fact, so
* their disposer outlives the terminal and proves nothing about liveness.
*
* Also excludes a PTY holding an unowned buffered exit. startParkedPtyWatcher
* refuses to register one, so this only catches an exit that raced an existing
* registration; keeping the check here costs one lookup per watched PTY.
*/
export function collectParkedTerminalWatcherPtyIds(): Set<string> {
const ptyIds = new Set<string>()
for (const entry of parkedWatchersByTabId.values()) {
for (const ptyId of entry.disposersByPtyId.keys()) {
if (parseRemoteRuntimePtyId(ptyId) === null && !hasPreHandlerPtyExit(ptyId)) {
ptyIds.add(ptyId)
}
}
}
return ptyIds
}
/**
* Pane ids the parked watchers are actually using, by PTY.
*
* Read from the watcher entry rather than the unmount capture: a parked tab
* whose layout gains a leaf gets a watcher from the layout-derived fallback,
* which the capture never learns about. The entry covers both, so a parked leaf
* publishes the identity main routes split/close and the paneKey fallback
* through, and indexes the runtime-title slot the watcher writes — instead of a
* fabricated ordinal that can name a pane PaneManager already retired.
*/
export function getParkedTerminalWatcherPaneIdsByPtyId(tabId: string): Map<string, number> {
return new Map(parkedWatchersByTabId.get(tabId)?.paneIdByPtyId ?? [])
}
export function disposeParkedTabWatchers(tabId: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
@@ -0,0 +1,263 @@
/**
* Cold-parked tabs keep their runtime-graph leaf while their PTY is alive.
*
* Why this matters beyond the host UI (STA-2854): the graph leaf is what mints
* the terminal handle a paired client's stream is bound to. Publishing only
* mounted panes meant a host that merely stopped *displaying* a terminal
* invalidated that handle, stalling a remote viewer who was actively driving it.
*
* The liveness proof is the parked watcher, which the park wiring starts on
* unmount and disposes on reveal, tab close, PTY exit, and worktree teardown —
* so a dead terminal still drops out of the graph.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
import type { AppState } from '../store/types'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
vi.mock('@/components/terminal-pane/pty-dispatcher', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, getEagerPtyBufferHandle: vi.fn(() => undefined) }
})
import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher'
import {
bufferPreHandlerPtyExit,
clearPreHandlerPtyState
} from '@/components/terminal-pane/pty-pre-handler-buffer'
import { parkedWatchersByTabId } from '@/components/terminal-pane/terminal-parked-watcher-registry'
import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled } from './sync-runtime-graph'
const LEAF = '22222222-2222-4222-8222-222222222222'
const PARKED_PTY = 'wt-1::/tmp/wt@@parked-pty'
const TAB_ID = 'parked-tab-1'
function makeState(overrides: Partial<AppState> = {}): AppState {
return {
tabsByWorktree: {},
terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'],
runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'],
groupsByWorktree: {},
activeGroupIdByWorktree: {},
layoutByWorktree: {},
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
...overrides
} as AppState
}
function parkedTab(): TerminalTab {
return {
id: TAB_ID,
ptyId: PARKED_PTY,
worktreeId: 'wt-1',
title: 'agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
} as TerminalTab
}
function parkedState(
ptyIdsByLeafId: Record<string, string> = { [LEAF]: PARKED_PTY },
activeLeafId: string = LEAF
): AppState {
const leafIds = Object.keys(ptyIdsByLeafId)
const root = leafIds.slice(1).reduce<Record<string, unknown>>(
(first, leafId) => ({
type: 'split',
direction: 'horizontal',
first,
second: { type: 'leaf', leafId }
}),
{ type: 'leaf', leafId: leafIds[0] }
)
return makeState({
tabsByWorktree: { 'wt-1': [parkedTab()] } as AppState['tabsByWorktree'],
terminalLayoutsByTabId: {
[TAB_ID]: {
root,
activeLeafId,
expandedLeafId: null,
ptyIdsByLeafId
}
} as unknown as AppState['terminalLayoutsByTabId']
})
}
/** Installs the exact registry state the park wiring leaves behind on unmount. */
function installParkedWatcher(ptyId: string, paneId = 1): void {
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: 'wt-1',
tabPtyId: ptyId,
paneIdByPtyId: new Map([[ptyId, paneId]]),
disposersByPtyId: new Map([[ptyId, () => {}]])
})
}
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
afterEach(() => {
setRuntimeGraphSyncEnabled(false)
setRuntimeGraphStoreStateGetter(null)
parkedWatchersByTabId.clear()
clearPreHandlerPtyState(PARKED_PTY)
vi.mocked(getEagerPtyBufferHandle).mockReturnValue(undefined)
vi.useRealTimers()
vi.unstubAllGlobals()
})
async function captureGraph(
options: { seedState?: boolean } = {}
): Promise<RuntimeSyncWindowGraph> {
vi.useFakeTimers()
const syncWindowGraph = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } })
vi.stubGlobal('HTMLElement', class HTMLElement {})
// Why opt-out: a case that needs a non-default layout installs its own getter
// before calling in, and must not have it overwritten here.
if (options.seedState !== false) {
setRuntimeGraphStoreStateGetter(() => parkedState())
}
setRuntimeGraphSyncEnabled(true)
await vi.advanceTimersByTimeAsync(20)
await flushMicrotasks()
expect(syncWindowGraph).toHaveBeenCalledTimes(1)
return syncWindowGraph.mock.calls[0]?.[0] as RuntimeSyncWindowGraph
}
describe('syncRuntimeGraph cold-parked tabs', () => {
it('publishes a parked tab leaf while a parked watcher still owns its PTY', async () => {
installParkedWatcher(PARKED_PTY)
const graph = await captureGraph()
expect(graph.leaves).toContainEqual(
expect.objectContaining({ tabId: TAB_ID, leafId: LEAF, ptyId: PARKED_PTY })
)
expect(graph.tabs).toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
it('drops the leaf once the watcher is disposed (reveal, close, PTY exit, teardown)', async () => {
const graph = await captureGraph()
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
expect(graph.tabs).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
// Why these two cases exist: `paneIdByPtyId` and `tabPtyId` both survive the
// states below, so a predicate reading either one would pass every other test
// here while publishing a dead terminal.
it('drops the leaf when per-PTY disposal leaves only the pane-id slot behind', async () => {
// Exactly what handlePtyExit and disposeParkedTerminalWatchersForPtyIds
// leave: the disposer is gone, the pane-id slot and tabPtyId remain.
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: 'wt-1',
tabPtyId: PARKED_PTY,
paneIdByPtyId: new Map([[PARKED_PTY, 1]]),
disposersByPtyId: new Map()
})
const graph = await captureGraph()
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
it('drops the leaf when the PTY exited into the park handoff gap', async () => {
// The watcher entry looks live, but the exit arrived while no handler owned
// the PTY, so it was buffered and the watcher's sidecar never saw it.
installParkedWatcher(PARKED_PTY)
bufferPreHandlerPtyExit(PARKED_PTY, 0)
const graph = await captureGraph()
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
it('does not publish a stale saved PTY that no watcher owns', async () => {
// The tab is parked, but the layout binding points at a PTY the park wiring
// never watched — ptyIdsByLeafId is merged and never pruned, so a stale
// binding must not resurrect a leaf.
installParkedWatcher('wt-1::/tmp/wt@@some-other-pty')
const graph = await captureGraph()
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
it('excludes a remote-runtime PTY, whose parked watcher never sees an exit', async () => {
// startParkedPtyWatcher installs no exit subscription for remote: PTYs and
// the parked fact stream carries no exit fact, so a surviving disposer is
// not evidence the remote terminal is still alive.
const remotePtyId = 'remote:env-1@@term_remote'
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: 'wt-1',
tabPtyId: remotePtyId,
paneIdByPtyId: new Map([[remotePtyId, 1]]),
disposersByPtyId: new Map([[remotePtyId, () => {}]])
})
setRuntimeGraphStoreStateGetter(() => parkedState({ [LEAF]: remotePtyId }))
const graph = await captureGraph({ seedState: false })
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ tabId: TAB_ID }))
})
it('publishes the pane id the watcher uses, not a positional ordinal', async () => {
// PaneManager retires a closed pane's id without renumbering, and main
// routes split/close and the paneKey fallback through this value. Sourced
// from the watcher entry, so a leaf the unmount capture never saw — one the
// layout gained while parked — is still named correctly.
installParkedWatcher(PARKED_PTY, 7)
const graph = await captureGraph()
expect(graph.leaves).toContainEqual(
expect.objectContaining({ tabId: TAB_ID, leafId: LEAF, paneRuntimeId: 7 })
)
})
it("publishes the parked pane's runtime title instead of discarding it", async () => {
// The parked byte watcher keeps writing this slot and main prefers
// leaf.paneTitle over its own older lastOscTitle, so publishing null pins a
// parked agent pane to a stale title for as long as it stays parked.
installParkedWatcher(PARKED_PTY, 7)
setRuntimeGraphStoreStateGetter(() => ({
...parkedState(),
runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'codex [working]' } }
}))
const graph = await captureGraph({ seedState: false })
expect(graph.leaves).toContainEqual(
expect.objectContaining({ leafId: LEAF, paneTitle: 'codex [working]' })
)
})
it('does not name an active leaf this publication is not sending', async () => {
// Partial coverage is legitimate: one leaf's watcher can be disposed while a
// sibling stays parked, and the saved activeLeafId may be the dropped one.
const otherLeaf = '33333333-3333-4333-8333-333333333333'
const otherPty = 'wt-1::/tmp/wt@@other-parked-pty'
installParkedWatcher(PARKED_PTY)
setRuntimeGraphStoreStateGetter(() =>
parkedState({ [LEAF]: PARKED_PTY, [otherLeaf]: otherPty }, otherLeaf)
)
const graph = await captureGraph({ seedState: false })
expect(graph.tabs).toContainEqual(
expect.objectContaining({ tabId: TAB_ID, activeLeafId: LEAF })
)
expect(graph.leaves).not.toContainEqual(expect.objectContaining({ leafId: otherLeaf }))
})
})
+34 -4
View File
@@ -6,6 +6,10 @@ import {
} from '@/components/terminal-pane/layout-serialization'
import { warnTerminalLifecycleAnomaly } from '@/components/terminal-pane/terminal-lifecycle-diagnostics'
import { getEagerPtyBufferHandle } from '@/components/terminal-pane/pty-dispatcher'
import {
collectParkedTerminalWatcherPtyIds,
getParkedTerminalWatcherPaneIdsByPtyId
} from '@/components/terminal-pane/terminal-parked-watcher-registry'
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution'
@@ -792,6 +796,13 @@ async function syncRuntimeGraph(): Promise<void> {
}
// Why: inactive automation tabs never mount a TerminalPane; publish their leaf+ptyId from persisted layout (gated on a live buffer) or the live PTY looks orphaned.
// Cold-parked tabs are the same shape with a different liveness proof: their
// pane is unmounted but a parked watcher still owns the PTY. Dropping their
// leaf invalidates the terminal handle every paired subscriber is bound to,
// stalling a remotely-driven terminal the host merely stopped displaying.
// Built once for the whole publication; a per-leaf registry scan would be
// quadratic across a many-worktree workspace.
const parkedWatcherPtyIds = collectParkedTerminalWatcherPtyIds()
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
for (const tab of tabs) {
const layout = state.terminalLayoutsByTabId[tab.id]
@@ -807,17 +818,27 @@ async function syncRuntimeGraph(): Promise<void> {
typeof ptyId === 'string' &&
ptyId.length > 0 &&
isTerminalLeafId(leafId) &&
Boolean(getEagerPtyBufferHandle(ptyId))
(Boolean(getEagerPtyBufferHandle(ptyId)) || parkedWatcherPtyIds.has(ptyId))
)
if (liveLeaves.length === 0) {
continue
}
const title = resolveRuntimeTerminalTitle(tab, generatedTitlesEnabled)
// Why: partial coverage is legitimate — one leaf's watcher can be disposed
// while its siblings stay parked — so a saved activeLeafId that did not
// survive the filter would name a leaf this publication never sends.
const publishedLeafIds = new Set(liveLeaves.map(([leafId]) => leafId))
const savedActiveLeafId = layout?.activeLeafId
const parkedPaneIdsByPtyId = getParkedTerminalWatcherPaneIdsByPtyId(tab.id)
const parkedPaneTitles = state.runtimePaneTitlesByTabId[tab.id] ?? {}
graph.tabs.push({
tabId: tab.id,
worktreeId,
title,
activeLeafId: layout?.activeLeafId ?? liveLeaves[0][0],
activeLeafId:
savedActiveLeafId && publishedLeafIds.has(savedActiveLeafId)
? savedActiveLeafId
: liveLeaves[0][0],
layout: resolveTerminalLayoutRoot({
authoritativeRoot: layout?.root,
leafIds: liveLeaves.map(([leafId]) => leafId),
@@ -828,13 +849,22 @@ async function syncRuntimeGraph(): Promise<void> {
})
})
liveLeaves.forEach(([leafId, ptyId], index) => {
// Why the watcher's id wins: PaneManager ids are allocated monotonically
// and closing a pane retires its id without renumbering, so an ordinal
// can name a pane that no longer exists — and main routes split/close and
// the paneKey fallback through this value.
const parkedPaneId = parkedPaneIdsByPtyId.get(ptyId)
graph.leaves.push({
tabId: tab.id,
worktreeId,
leafId,
paneRuntimeId: index + 1,
paneRuntimeId: parkedPaneId ?? index + 1,
ptyId,
paneTitle: null,
// Why not null: the parked byte watcher keeps writing this pane's
// runtime title, and main prefers leaf.paneTitle over its own older
// lastOscTitle. Dropping it would pin a parked agent pane to a stale
// title for as long as it stays parked.
paneTitle: (parkedPaneId === undefined ? null : parkedPaneTitles[parkedPaneId]) ?? null,
title
})
})
@@ -0,0 +1,494 @@
/**
* STA-2854 validation oracle (validation-only; no product change).
*
* Report: a host's renderer cold-parking policy ignores remote subscribers, so
* a pane a paired client is actively viewing is unmounted 30s after it goes
* locally hidden; reconnect remounts it without resetting the hidden clock, so
* it re-parks immediately and the client never recovers.
*
* The report is two separable claims, so this file asserts them separately:
*
* A. RENDERER POLICY — does host-local cold parking still ignore an active
* remote subscriber, and does a background (remote-subscribe-driven)
* mount still inherit a stale hidden clock?
* B. AUTHORITATIVE STREAM — does a parked host pane actually destroy the
* remote subscriber's stream, its input path, or its reconnect?
*
* B is modelled at the strictest possible park: a runtime with NO authoritative
* window at all. That is strictly more unmounted than a cold-parked pane, so a
* green B proves renderer parking cannot be the boundary that breaks the stream.
*/
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher'
import type { RpcRequest } from '../../src/main/runtime/rpc/core'
import { TERMINAL_METHODS } from '../../src/main/runtime/rpc/methods/terminal'
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
decodeTerminalStreamText,
encodeTerminalStreamFrame,
encodeTerminalStreamJson,
encodeTerminalStreamText
} from '../../src/shared/terminal-stream-protocol'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
TERMINAL_TAB_HOT_RETAIN_LIMIT,
canParkTerminalTabRenderer,
selectColdParkedTerminalTabs
} from '../../src/renderer/src/components/terminal-pane/terminal-hidden-view-parking'
import { buildTerminalTabColdParkCandidates } from '../../src/renderer/src/components/terminal-pane/terminal-tab-park-candidates'
import { createTerminalTabActivationOrder } from '../../src/renderer/src/components/terminal-pane/terminal-tab-activation-order'
import type { TerminalTab } from '../../src/shared/terminal-tab-types'
const WORKTREE_ID = 'repo-1::/tmp/wt'
/** Host-owned, snapshot-backed local daemon PTY: the exact shape the report's
* host had (a local pane a paired client was watching). */
const ptyIdFor = (n: number): string => `${WORKTREE_ID}@@aaaaaaa${n}`
const SUBSCRIBED_TAB_ID = 'tab-remote-viewed'
const MARKER_BEFORE_PARK = 'STA2854-BEFORE-PARK'
const MARKER_AFTER_PARK = 'STA2854-AFTER-PARK'
const MARKER_AFTER_RECONNECT = 'STA2854-AFTER-RECONNECT'
const CLIENT_INPUT = 'echo STA2854-INPUT\r'
// ---------------------------------------------------------------------------
// A. Host renderer cold-park policy (pure, explicit clock — no timers)
// ---------------------------------------------------------------------------
function tabModel(id: string, ptyId: string): TerminalTab {
return { id, ptyId, generation: 1 } as unknown as TerminalTab
}
/** The report's topology, shrunk to the smallest set that still evicts:
* hot-retain limit + 1 hidden host tabs, one of them remotely subscribed. */
function hiddenHostTabs(): TerminalTab[] {
const tabs: TerminalTab[] = [tabModel(SUBSCRIBED_TAB_ID, ptyIdFor(0))]
for (let i = 1; i <= TERMINAL_TAB_HOT_RETAIN_LIMIT + 1; i += 1) {
tabs.push(tabModel(`tab-${i}`, ptyIdFor(i)))
}
return tabs
}
describe('STA-2854 A: host cold-park policy vs an active remote subscriber', () => {
it('parks a locally hidden host pane that a remote client is actively viewing', () => {
const tabs = hiddenHostTabs()
const hiddenSinceByTabId = new Map<string, number>()
const activationOrder = createTerminalTabActivationOrder()
const t0 = 1_000_000
// Pass 1: the host user switches away — every tab starts its hidden clock.
buildTerminalTabColdParkCandidates({
terminalTabs: tabs,
assignments: new Map(),
isWorktreeActive: false,
activeTerminalTabId: null,
portalTabIds: new Set(),
shouldMeasureHiddenWorktree: false,
hiddenSinceByTabId,
activationOrder,
nowMs: t0
})
expect(hiddenSinceByTabId.get(SUBSCRIBED_TAB_ID)).toBe(t0)
// Pass 2: exactly the cold-park hysteresis later. The remote client has
// been driving this tab the whole time; the policy has no input for that.
const nowMs = t0 + TERMINAL_TAB_COLD_PARK_DELAY_MS
const candidates = buildTerminalTabColdParkCandidates({
terminalTabs: tabs,
assignments: new Map(),
isWorktreeActive: false,
activeTerminalTabId: null,
portalTabIds: new Set(),
shouldMeasureHiddenWorktree: false,
hiddenSinceByTabId,
activationOrder,
nowMs
})
const subscribedCandidate = candidates.find((c) => c.id === SUBSCRIBED_TAB_ID)!
expect(
canParkTerminalTabRenderer({
worktreeId: WORKTREE_ID,
terminalTab: subscribedCandidate,
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs
})
).toBe(true)
const parked = selectColdParkedTerminalTabs({
worktreeId: WORKTREE_ID,
terminalTabs: candidates,
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs
})
// Eligible AND actually evicted: it is outside the hot-retain set.
expect(parked.has(SUBSCRIBED_TAB_ID)).toBe(true)
// Negative control: local visibility is the only thing that saves it.
const visibleCandidate = { ...subscribedCandidate, isVisible: true, hiddenSinceMs: null }
expect(
canParkTerminalTabRenderer({
worktreeId: WORKTREE_ID,
terminalTab: visibleCandidate,
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs
})
).toBe(false)
})
it('does not reset the hidden clock when a remote subscribe background-mounts the tab', () => {
const tabs = hiddenHostTabs()
const hiddenSinceByTabId = new Map<string, number>()
const activationOrder = createTerminalTabActivationOrder()
const t0 = 1_000_000
buildTerminalTabColdParkCandidates({
terminalTabs: tabs,
assignments: new Map(),
isWorktreeActive: false,
activeTerminalTabId: null,
portalTabIds: new Set(),
shouldMeasureHiddenWorktree: false,
hiddenSinceByTabId,
activationOrder,
nowMs: t0
})
// Hours pass with the host untouched, then the client reconnects and the
// host renderer background-mounts the tab. A background mount never makes
// the tab locally visible, so it re-enters the candidate list with its
// original hidden stamp — already past the hysteresis on the first pass.
const remountNowMs = t0 + 4 * 60 * 60_000
const candidates = buildTerminalTabColdParkCandidates({
terminalTabs: tabs,
assignments: new Map(),
isWorktreeActive: false,
activeTerminalTabId: null,
portalTabIds: new Set(),
shouldMeasureHiddenWorktree: false,
hiddenSinceByTabId,
activationOrder,
nowMs: remountNowMs
})
const subscribedCandidate = candidates.find((c) => c.id === SUBSCRIBED_TAB_ID)!
expect(subscribedCandidate.hiddenSinceMs).toBe(t0)
expect(remountNowMs - subscribedCandidate.hiddenSinceMs!).toBeGreaterThan(
TERMINAL_TAB_COLD_PARK_DELAY_MS
)
expect(
selectColdParkedTerminalTabs({
worktreeId: WORKTREE_ID,
terminalTabs: candidates,
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs: remountNowMs
}).has(SUBSCRIBED_TAB_ID)
).toBe(true)
})
})
// ---------------------------------------------------------------------------
// B. Authoritative host stream across the strictest possible park
// ---------------------------------------------------------------------------
type RuntimeInternals = {
recordPtyWorktree: (
ptyId: string,
worktreeId: string,
state?: { connected?: boolean; connectionId?: string | null }
) => unknown
issuePtyHandle: (pty: unknown) => string
}
function internals(runtime: OrcaRuntimeService): RuntimeInternals {
return runtime as unknown as RuntimeInternals
}
/** Host-side provider: a local daemon session whose data events are gated on
* attach, exactly like the real daemon boundary. Never renderer-gated. */
function createHostProvider() {
const ptyId = ptyIdFor(0)
// Faithful to the report: the host pane WAS mounted (provider attached),
// then went locally hidden and cold-parked.
const session = { cols: 120, rows: 40, attached: true, screen: '' }
const attachCalls: string[] = []
const writes: [string, string][] = []
let runtime: OrcaRuntimeService | null = null
const controller = {
write: (id: string, text: string) => {
writes.push([id, text])
return true
},
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: ptyId, cwd: '/tmp/wt', worktreeId: WORKTREE_ID, title: '', cols: 120, rows: 40 }
],
// A parked pane has no renderer serializer — this is the park, expressed
// at the only boundary main can observe it from.
hasRendererSerializer: () => false,
getSize: () => ({ cols: session.cols, rows: session.rows }),
resize: () => true,
attach: async (id: string) => {
attachCalls.push(id)
session.attached = true
return true
},
serializeProviderBuffer: async () => ({
data: session.screen,
cols: session.cols,
rows: session.rows,
seq: 0,
source: 'headless' as const
})
}
return {
ptyId,
controller,
attachCalls,
writes,
session,
bind(target: OrcaRuntimeService) {
runtime = target
},
emitData(data: string): boolean {
if (!session.attached) {
return false
}
session.screen += data
runtime?.onPtyData(ptyId, data, Date.now())
return true
}
}
}
function startMultiplex(runtime: OrcaRuntimeService, connectionId: string) {
const messages: { result?: { type?: string; streamId?: number | null } }[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const request: RpcRequest = {
id: `req-${connectionId}`,
authToken: 'tok',
method: 'terminal.multiplex',
params: {}
}
const dispatchPromise = dispatcher.dispatchStreaming(
request,
(msg) => {
messages.push(JSON.parse(msg))
},
{
connectionId,
sendBinary: (bytes: Uint8Array<ArrayBufferLike>) => {
binaryFrames.push(bytes)
return true
},
registerBinaryStreamHandler: (
streamId: number,
handler: (frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
) => {
handlers.set(streamId, handler)
return () => {
if (handlers.get(streamId) === handler) {
handlers.delete(streamId)
}
}
}
}
)
return { messages, binaryFrames, handlers, dispatchPromise }
}
type Harness = ReturnType<typeof startMultiplex>
function sendSubscribe(harness: Harness, streamId: number, terminal: string, clientId: string) {
harness.handlers.get(0)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Subscribe,
streamId,
seq: 1,
payload: encodeTerminalStreamJson({
streamId,
terminal,
client: { id: clientId, type: 'desktop' }
})
})
)!
)
}
function sendInput(harness: Harness, streamId: number, text: string) {
harness.handlers.get(streamId)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
streamId,
seq: 2,
payload: encodeTerminalStreamText(text)
})
)!
)
}
async function waitForSubscribed(harness: Harness, streamId: number): Promise<void> {
await vi.waitFor(() =>
expect(
harness.messages.some(
(m) => m.result?.type === 'subscribed' && m.result?.streamId === streamId
)
).toBe(true)
)
}
function outputText(harness: Harness): string {
return harness.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output)
.map((frame) => decodeTerminalStreamText(frame!.payload))
.join('')
}
function errorFrames(harness: Harness): number {
return harness.binaryFrames
.map(decodeTerminalStreamFrame)
.filter((frame) => frame?.opcode === TerminalStreamOpcode.Error).length
}
describe('STA-2854 B: remote subscriber survives a fully unmounted host renderer', () => {
it('streams, accepts input, and re-subscribes with no host renderer pane at all', async () => {
const runtime = new OrcaRuntimeService()
const provider = createHostProvider()
provider.bind(runtime)
runtime.setPtyController(provider.controller as never)
const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, {
connected: true
})
const handle = internals(runtime).issuePtyHandle(record)
// The park, at its strictest: no authoritative window exists, so a renderer
// mount request is impossible — more unmounted than a cold-parked pane.
const mountSpy = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false)
// The pane produced bytes while it was mounted, so main already owns model
// state for it — no subscriber-driven (re)attach is in play.
expect(provider.emitData('host pane was live\r\n')).toBe(true)
const harness = startMultiplex(runtime, 'conn-client-b')
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
sendSubscribe(harness, 1, handle, 'client-b')
await waitForSubscribed(harness, 1)
// Two independent signals: authoritative subscriber presence AND bytes.
expect(runtime.hasRemoteTerminalViewSubscriber(provider.ptyId)).toBe(true)
// This is the exact input `syncPtyBackgroundedDelivery` reads to veto
// daemon keep-tail thinning for a host-locally-hidden PTY.
expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(true)
expect(provider.emitData(`${MARKER_BEFORE_PARK}\r\n`)).toBe(true)
await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_BEFORE_PARK))
// --- host cold-park episode: nothing about the pane exists on the host ---
expect(provider.emitData(`${MARKER_AFTER_PARK}\r\n`)).toBe(true)
await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_AFTER_PARK))
// Input still reaches the authoritative PTY while fully unmounted.
sendInput(harness, 1, CLIENT_INPUT)
await vi.waitFor(() => expect(provider.writes).toContainEqual([provider.ptyId, CLIENT_INPUT]))
// PTY identity never changed and the stream never errored.
expect(provider.attachCalls).toEqual([])
expect(errorFrames(harness)).toBe(0)
expect(harness.messages.some((m) => m.result?.type === 'error')).toBe(false)
// --- reconnect: a fresh connection re-subscribes with no mount available ---
const reconnected = startMultiplex(runtime, 'conn-client-b-2')
await vi.waitFor(() => expect(reconnected.handlers.has(0)).toBe(true))
sendSubscribe(reconnected, 1, handle, 'client-b')
await waitForSubscribed(reconnected, 1)
expect(provider.emitData(`${MARKER_AFTER_RECONNECT}\r\n`)).toBe(true)
await vi.waitFor(() => expect(outputText(reconnected)).toContain(MARKER_AFTER_RECONNECT))
// Stronger than expected: subscribe and reconnect never even ASK for a
// renderer mount once the leaf owns a PTY, so a re-park cannot cycle them.
expect(mountSpy).not.toHaveBeenCalled()
expect(provider.attachCalls).toEqual([])
expect(errorFrames(reconnected)).toBe(0)
})
it('counterfactual: losing every subscriber is what clears the thinning veto', async () => {
const runtime = new OrcaRuntimeService()
const provider = createHostProvider()
provider.bind(runtime)
runtime.setPtyController(provider.controller as never)
const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, {
connected: true
})
const handle = internals(runtime).issuePtyHandle(record)
vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false)
const harness = startMultiplex(runtime, 'conn-counterfactual')
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
sendSubscribe(harness, 1, handle, 'client-b')
await waitForSubscribed(harness, 1)
expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(true)
harness.handlers.get(1)?.(
decodeTerminalStreamFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Unsubscribe,
streamId: 1,
seq: 3,
payload: encodeTerminalStreamJson({})
})
)!
)
await vi.waitFor(() => expect(runtime.hasRawTerminalViewSubscriber(provider.ptyId)).toBe(false))
// The PTY itself is untouched: parking/unsubscribing never kills it.
expect(provider.session.attached).toBe(true)
expect(provider.emitData('post-unsubscribe\r\n')).toBe(true)
})
})
// ---------------------------------------------------------------------------
// C. The path host parking really can starve: a host PTY main never attached
// (never-activated or parked-from-birth tab) while no renderer can mount it.
// ---------------------------------------------------------------------------
describe('STA-2854 C: an unattached host PTY with no mountable renderer pane', () => {
it('still streams to the remote subscriber without any renderer mount', async () => {
const runtime = new OrcaRuntimeService()
const provider = createHostProvider()
// Parked/never-activated: no pane ever attached this daemon session, so the
// provider is not emitting and only main can start it.
provider.session.attached = false
provider.bind(runtime)
runtime.setPtyController(provider.controller as never)
const record = internals(runtime).recordPtyWorktree(provider.ptyId, WORKTREE_ID, {
connected: true
})
const handle = internals(runtime).issuePtyHandle(record)
const mountSpy = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false)
const harness = startMultiplex(runtime, 'conn-client-c')
await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true))
// Baseline: the daemon refuses to emit for an unattached session.
expect(provider.emitData('pre-subscribe\r\n')).toBe(false)
sendSubscribe(harness, 1, handle, 'client-c')
await waitForSubscribed(harness, 1)
// Main itself must attach: the renderer cannot help while parked.
await vi.waitFor(() => expect(provider.attachCalls).toEqual([provider.ptyId]))
expect(provider.emitData(`${MARKER_AFTER_PARK}\r\n`)).toBe(true)
await vi.waitFor(() => expect(outputText(harness)).toContain(MARKER_AFTER_PARK))
expect(mountSpy).not.toHaveBeenCalled()
expect(errorFrames(harness)).toBe(0)
})
})
@@ -0,0 +1,384 @@
/**
* STA-2854 live validation: a HOST pane that cold-parks while a paired client
* is actively viewing it must keep serving that client.
*
* Topology: headed Orca desktop host (remote server) + a separate paired Orca
* desktop client. The reported shape is the inverse of every existing paired
* parking spec: those park on the CLIENT, this parks on the HOST while the
* client watches. The host user never touches the tab again after parking it.
*
* Two independent signals, as required for an end-to-end claim:
* 1. host-side: the fixture process's own sink file records the typed line —
* the bytes reached the authoritative host PTY;
* 2. client-side: the client's xterm paints the echo — output crossed back.
* Plus PTY identity: the sink must still hold exactly one READY line, so the
* park never respawned or replaced the process.
*
* Before the fix, `H±` = host pane mounted, phase = client transport, 2s samples:
* H-/recovering x8 -> H-/connected x37, input-reached=false
* The host park dropped the paired client into the "Reconnecting to remote
* runtime" state for ~16s; it reconnected on its own, but the line typed at
* park time was silently discarded forever by recoveryBlocksIo(). Reproduced
* identically at the production 30s cold-park hysteresis, so the shortened test
* override was not the cause.
*
* After the fix (parked panes keep publishing their runtime-graph leaf):
* H-/connected x45, the typed line reaches the host PTY AND its echo paints
* back in the client's own xterm within one 2s sample.
*
* Run:
* pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts \
* --config tests/playwright.config.ts --project electron-headless --workers=1
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import {
HOST_TERMINAL_SURFACE_SEPARATOR,
toWebTerminalSurfaceTabId
} from '../../src/shared/terminal-surface-id'
import { expect, test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient
} from './helpers/paired-electron-client'
import { focusActiveTerminalInput } from './helpers/terminal'
import { waitForTabParked } from './helpers/terminal-hidden-parking'
const PARK_DELAY_MS = 30_000
const PAINT_BUDGET_MS = 20_000
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-host-park-viewer-'))
const fixturePath = path.join(scratch, 'host-park-viewer-terminal.mjs')
writeFileSync(
fixturePath,
[
"import { appendFileSync } from 'node:fs'",
'const sink = process.argv[2]',
'const record = (line) => appendFileSync(sink, `${line}\\n`)',
'record(`READY:${process.pid}`)',
'process.stdout.write(`READY:${process.pid}\\r\\n`)',
"process.stdin.setEncoding('utf8')",
"let pending = ''",
"process.stdin.on('data', (data) => {",
' pending += data',
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
" pending = lines.pop() ?? ''",
' for (const line of lines) {',
' record(`LINE:${line}`)',
' process.stdout.write(`LINE:${line}\\r\\n`)',
' }',
'})',
'process.stdin.resume()'
].join('\n')
)
test.afterAll(() => {
rmSync(scratch, { recursive: true, force: true })
})
// Why: the HOST must park quickly too, so its launch env carries the override.
test.use({ orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) } })
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function fixtureCommand(sinkPath: string): string {
const command = [process.execPath, fixturePath, sinkPath]
return process.platform === 'win32'
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
: command.map(shellQuote).join(' ')
}
function readSink(sinkPath: string): string {
try {
return readFileSync(sinkPath, 'utf8')
} catch {
return ''
}
}
async function callEnvironment<TResult>(
page: Page,
environmentId: string,
method: string,
params: unknown
): Promise<TResult> {
return page.evaluate(
async ({ environmentId, method, params }) => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method,
params
})
if (!response.ok) {
throw new Error(`${response.error.code}: ${response.error.message}`)
}
return response.result
},
{ environmentId, method, params }
) as Promise<TResult>
}
async function readPaneContent(page: Page, tabId: string): Promise<string> {
return page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.serializeAddon?.serialize?.() ?? ''
}, tabId)
}
async function waitForPaneMarker(
page: Page,
tabId: string,
marker: string,
budgetMs: number
): Promise<boolean> {
const deadline = Date.now() + budgetMs
while (Date.now() < deadline) {
if ((await readPaneContent(page, tabId)).includes(marker)) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
test('a cold-parked host pane keeps serving its paired remote viewer', async ({
orcaPage
}, testInfo) => {
test.setTimeout(600_000)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
const client = await launchPairedElectronClient(offer, testInfo, 'host-park-viewer')
const createdTerminals: string[] = []
const sinkPath = path.join(scratch, `sink-${randomUUID()}.log`)
try {
const worktreeId = await orcaPage.evaluate(() => {
const id = window.__store?.getState().activeWorktreeId
if (!id) {
throw new Error('headed host has no active worktree')
}
return id
})
await expect
.poll(
() =>
client.page.evaluate(
(id) =>
window.__store
?.getState()
.allWorktrees()
.some((worktree) => worktree.id === id) ?? false,
worktreeId
),
{ timeout: 60_000, message: 'paired client never saw the host worktree' }
)
.toBe(true)
// Host-owned terminal, created through the host runtime.
const created = await callEnvironment<{ tab: { id: string; terminal: string | null } }>(
client.page,
client.environmentId,
'session.tabs.createTerminal',
{
worktree: `id:${worktreeId}`,
command: fixtureCommand(sinkPath),
activate: false,
select: false,
navigation: 'caller'
}
)
if (!created.tab.terminal) {
throw new Error('host session terminal was not created')
}
createdTerminals.push(created.tab.terminal)
const hostTabId = created.tab.id.split(HOST_TERMINAL_SURFACE_SEPARATOR)[0]
const webTabId = toWebTerminalSurfaceTabId(hostTabId)
// 1. Host mounts the pane (the ordinary "someone looked at it" state).
await orcaPage.evaluate(
({ worktreeId, tabId }) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
state?.setActiveTab(tabId)
state?.setActiveTabType('terminal')
},
{ worktreeId, tabId: hostTabId }
)
await expect
.poll(() => orcaPage.evaluate((id) => window.__paneManagers?.has(id) ?? false, hostTabId), {
timeout: 60_000,
message: 'host never mounted its own terminal pane'
})
.toBe(true)
// 2. Client subscribes and is actively viewing it.
await expect
.poll(
() =>
client.page.evaluate(
(id) => (window.__store?.getState().tabsByWorktree[id] ?? []).map((tab) => tab.id),
worktreeId
),
{ timeout: 60_000, message: 'client never mirrored the host tab' }
)
.toContain(webTabId)
await client.page.evaluate(
({ webTabId, worktreeId }) => {
const state = window.__store?.getState()
state?.setActiveView('terminal')
state?.setActiveWorktree(worktreeId)
state?.setActiveTab(webTabId)
state?.setActiveTabType('terminal')
},
{ webTabId, worktreeId }
)
expect(
await waitForPaneMarker(client.page, webTabId, 'READY:', PAINT_BUDGET_MS),
'client never painted the host terminal before the host parked it'
).toBe(true)
// 2b. CONTROL: the same typed path must work BEFORE the host parks. Without
// this, a focus/keyboard artifact would masquerade as the park bug.
const controlToken = `sta2854-control-${randomUUID().slice(0, 8)}`
await focusActiveTerminalInput(client.page)
await client.page.keyboard.type(controlToken)
await client.page.keyboard.press('Enter')
const controlReached = await (async () => {
const deadline = Date.now() + PAINT_BUDGET_MS
while (Date.now() < deadline) {
if (readSink(sinkPath).includes(`LINE:${controlToken}`)) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
})()
console.log(
`[sta2854] control-reached=${controlReached} sink=${JSON.stringify(readSink(sinkPath))}`
)
expect(
controlReached,
'control: client input did not reach the host PTY even before any park'
).toBe(true)
// 3. The host cold-parks the pane the client is watching. Nobody touches
// it on the host again.
// Two decoy tabs on the host: one to age the target, one to take the
// most-recently-hidden exemption. Sampled before and after the park lands
// so decoy churn cannot be mistaken for the park itself.
for (let i = 0; i < 2; i += 1) {
await orcaPage.evaluate((id) => {
const state = window.__store?.getState()
const tab = state?.createTab(id, undefined, undefined, { activate: true })
if (tab) {
state?.setActiveTab(tab.id)
state?.setActiveTabType('terminal')
}
}, worktreeId)
}
const readClientState = async (): Promise<unknown> =>
client.page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return {
mounted: Boolean(manager),
ptyId: pane?.container?.dataset?.ptyId ?? null,
recoveryState: pane?.container?.dataset?.ptyRecoveryState ?? null
}
}, webTabId)
console.log(`[sta2854] decoys-created client=${JSON.stringify(await readClientState())}`)
await waitForTabParked(orcaPage, hostTabId, { parkDelayMs: PARK_DELAY_MS })
console.log(`[sta2854] post-park client=${JSON.stringify(await readClientState())}`)
// Direct probe: is the host-minted terminal handle still resolvable once
// its pane is parked? (It is — so the boundary is not handle liveness.)
const handleProbe = await client.page.evaluate(
async ({ environmentId, terminal }) => {
const response = await window.api.runtimeEnvironments.call({
selector: environmentId,
method: 'terminal.read',
params: { terminal }
})
return response.ok
? { ok: true as const }
: { ok: false as const, code: response.error.code, message: response.error.message }
},
{ environmentId: client.environmentId, terminal: created.tab.terminal }
)
console.log(`[sta2854] handle-probe=${JSON.stringify(handleProbe)}`)
// Type the moment the host parks — a user driving the pane does not wait
// for a banner. Input accepted here must reach the host PTY.
const token = `sta2854-${randomUUID().slice(0, 8)}`
await focusActiveTerminalInput(client.page)
await client.page.keyboard.type(token)
await client.page.keyboard.press('Enter')
// Timeline: host pane mount state vs client transport phase. Long enough to
// catch a second park cycle if the recovery remounts the host pane.
const timeline: string[] = []
const timelineDeadline = Date.now() + 90_000
let inputReached = false
let clientEchoed = false
while (Date.now() < timelineDeadline) {
const hostMounted = await orcaPage.evaluate(
(id) => window.__paneManagers?.has(id) ?? false,
hostTabId
)
const clientPhase = await client.page.evaluate((id) => {
const manager = window.__paneManagers?.get(id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
return pane?.container?.dataset?.ptyRecoveryState ?? 'unmounted'
}, webTabId)
inputReached ||= readSink(sinkPath).includes(`LINE:${token}`)
// Signal 2, and the reason this is not merely a host-side test: the echo
// has to come back out to the client's own xterm.
clientEchoed ||= (await readPaneContent(client.page, webTabId)).includes(`LINE:${token}`)
timeline.push(
`${hostMounted ? 'H+' : 'H-'}/${clientPhase}${inputReached ? '/in' : ''}${clientEchoed ? '/echo' : ''}`
)
await new Promise((resolve) => setTimeout(resolve, 2_000))
}
console.log(`[sta2854] timeline=${JSON.stringify(timeline)}`)
console.log(
`[sta2854] input-reached=${inputReached} client-echoed=${clientEchoed} sink=${JSON.stringify(readSink(sinkPath))}`
)
const disruptedSamples = timeline.filter((entry) => !entry.includes('/connected'))
expect(
{ disrupted: disruptedSamples.length, timeline },
'host-local cold parking disrupted the paired client transport'
).toEqual({ disrupted: 0, timeline })
expect(
{ inputReached, clientEchoed },
'the parked host pane did not carry a full round trip for its remote viewer'
).toEqual({ inputReached: true, clientEchoed: true })
// 5. PTY identity: one process, never respawned across the park.
const readyLines = readSink(sinkPath)
.split('\n')
.filter((line) => line.startsWith('READY:'))
expect(readyLines, 'host PTY was replaced across the park').toHaveLength(1)
// Intentionally not asserted: whether the host pane stays parked is part of
// what the timeline above reports (a recovery-driven remount would show as
// H+ samples), not a precondition of the invariant.
} finally {
if (previousParkDelay === undefined) {
delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS
} else {
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay
}
for (const terminal of createdTerminals) {
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
terminal
}).catch(() => undefined)
}
await client.dispose()
}
})