mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): keep a cold-parked pane's runtime-graph leaf while its PTY lives (STA-2854) (#15514)
This commit is contained in:
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user