From fcdbcf85d0e4e7028410c3385a259aad622b853c Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:47:31 -0700 Subject: [PATCH] fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514) --- .../terminal-parked-pty-watcher.ts | 13 +- .../terminal-parked-tab-watchers.test.ts | 25 +- .../terminal-parked-tab-watchers.ts | 1 + .../terminal-parked-watcher-registry.ts | 54 +- .../sync-runtime-graph-parked-leaf.test.ts | 263 ++++++++++ .../src/runtime/sync-runtime-graph.ts | 38 +- ...t-cold-park-remote-subscriber.unit.test.ts | 494 ++++++++++++++++++ .../host-parked-pane-remote-viewer.spec.ts | 384 ++++++++++++++ 8 files changed, 1265 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts create mode 100644 tests/e2e/host-cold-park-remote-subscriber.unit.test.ts create mode 100644 tests/e2e/host-parked-pane-remote-viewer.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index fb0bf470004..b78a3a07e5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -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) || diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index a129f033176..cd0448180ba 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -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() + 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'] }, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 42de06cac37..d0c36961377 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index b2fb9b3de07..f0184b2b7aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -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 { + const ptyIds = new Set() + 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 { + return new Map(parkedWatchersByTabId.get(tabId)?.paneIdByPtyId ?? []) +} + export function disposeParkedTabWatchers(tabId: string): void { const entry = parkedWatchersByTabId.get(tabId) if (!entry) { diff --git a/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts b/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts new file mode 100644 index 00000000000..a716f578e79 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts @@ -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>() + 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 { + 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 = { [LEAF]: PARKED_PTY }, + activeLeafId: string = LEAF +): AppState { + const leafIds = Object.keys(ptyIdsByLeafId) + const root = leafIds.slice(1).reduce>( + (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 { + 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 { + 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 })) + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3b5f1f62066..888c9642508 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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 { } // 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 { 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 { }) }) 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 }) }) diff --git a/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts b/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts new file mode 100644 index 00000000000..46158689844 --- /dev/null +++ b/tests/e2e/host-cold-park-remote-subscriber.unit.test.ts @@ -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() + 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() + 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[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => 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) => { + binaryFrames.push(bytes) + return true + }, + registerBinaryStreamHandler: ( + streamId: number, + handler: (frame: NonNullable>) => void + ) => { + handlers.set(streamId, handler) + return () => { + if (handlers.get(streamId) === handler) { + handlers.delete(streamId) + } + } + } + } + ) + return { messages, binaryFrames, handlers, dispatchPromise } +} + +type Harness = ReturnType + +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 { + 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) + }) +}) diff --git a/tests/e2e/host-parked-pane-remote-viewer.spec.ts b/tests/e2e/host-parked-pane-remote-viewer.spec.ts new file mode 100644 index 00000000000..1f2c511f00a --- /dev/null +++ b/tests/e2e/host-parked-pane-remote-viewer.spec.ts @@ -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( + page: Page, + environmentId: string, + method: string, + params: unknown +): Promise { + 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 +} + +async function readPaneContent(page: Page, tabId: string): Promise { + 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 { + 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 => + 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() + } +})