From e467b3ff7b1526a06a176cd4e198febcb989344f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:22:53 -0700 Subject: [PATCH] fix(remote): stabilize shared control and terminal parking (#11656) * fix(remote): stabilize shared control and terminal parking * fix(remote): harden parking review edge cases * fix(terminal): restore parked local floating buffer * fix(ci): drop superseded paired parking evidence * fix(terminal): preserve floating park watchers * fix(ci): include web client in paired e2e artifact * fix(ci): reuse renderer build for paired e2e --- .github/workflows/e2e.yml | 11 +- config/reliability-gates.jsonc | 20 +- .../release-e2e-dispatch-contract.test.mjs | 10 + .../remote-shared-control-retirement-probe.ts | 258 ++++++++++++++++ src/main/ipc/pty.test.ts | 11 + src/main/ipc/pty.ts | 7 +- .../providers/pty-process-inspection.test.ts | 26 +- src/main/providers/pty-process-inspection.ts | 14 + src/renderer/src/components/Terminal.tsx | 21 +- .../FloatingTerminalPanel.test.tsx | 73 ++++- .../FloatingTerminalPanel.tsx | 88 ++++-- .../terminal-pane/pty-connection.test.ts | 60 +++- .../terminal-pane/pty-connection.ts | 6 +- .../terminal-parked-tab-watchers.ts | 3 +- .../terminal-parked-watcher-registry.test.ts | 41 ++- .../terminal-parked-watcher-registry.ts | 6 + .../use-terminal-tab-cold-parking.test.ts | 29 ++ .../use-terminal-tab-cold-parking.ts | 12 +- ...background-terminal-worktree-mount.test.ts | 14 +- .../background-terminal-worktree-mount.ts | 15 +- ...emote-runtime-shared-control-connection.ts | 24 +- ...e-runtime-shared-control-frame-dispatch.ts | 19 +- ...te-runtime-shared-control-frame-handler.ts | 3 + .../remote-runtime-shared-control-requests.ts | 2 + ...shared-control-retired-request-ids.test.ts | 62 ++++ ...time-shared-control-retired-request-ids.ts | 53 ++++ ...-shared-control-subscription-close.test.ts | 59 ++++ ...ntime-shared-control-subscription-close.ts | 44 +++ ...te-runtime-shared-control-subscriptions.ts | 8 +- ...d-remote-terminal-retention-memory.spec.ts | 35 ++- .../helpers/headless-paired-runtime-host.ts | 96 +++++- .../headless-paired-runtime-host.unit.test.ts | 45 +++ ...ed-terminal-cold-activation-observation.ts | 55 ++++ .../paired-terminal-cold-activation-oracle.ts | 292 ++++++++++++++++++ 34 files changed, 1425 insertions(+), 97 deletions(-) create mode 100644 config/scripts/remote-shared-control-retirement-probe.ts create mode 100644 src/shared/remote-runtime-shared-control-retired-request-ids.test.ts create mode 100644 src/shared/remote-runtime-shared-control-retired-request-ids.ts create mode 100644 src/shared/remote-runtime-shared-control-subscription-close.test.ts create mode 100644 src/shared/remote-runtime-shared-control-subscription-close.ts create mode 100644 tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts create mode 100644 tests/e2e/helpers/paired-terminal-cold-activation-observation.ts create mode 100644 tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2ab1ed444..ac5fd76bc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -64,11 +64,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # Why: building once avoids five parallel electron-vite builds inside - # Playwright globalSetup, which otherwise contends for CPU/RAM on OSS - # runners before the sharded tests even start. + # Why: building here avoids parallel builds inside Playwright globalSetup; + # paired-browser specs also need the standalone web bundle. - name: Build Electron app for E2E - run: npx electron-vite build --mode e2e + env: + VITE_EXPOSE_STORE: 'true' + run: | + npx electron-vite build --mode e2e + pnpm run build:web-from-renderer - name: Upload E2E build output uses: actions/upload-artifact@v7 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 312f89c78..a243d500d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3105,7 +3105,7 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ @@ -3170,6 +3170,7 @@ "file": "tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts", "assertions": [ "ordinary-parks paired terminals against an isolated headless Orca host", + "cold-activates only visible paired terminals against an isolated headless host", "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" ] }, @@ -3314,13 +3315,13 @@ "summary": "An isolated `orca serve` host and paired web renderer exhausted one terminal stream, recovered it, and preserved the original PTY and tab without exposing readiness or pairing material." }, { - "date": "2026-07-29", + "date": "2026-07-30", "runner": "local", "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "passed", - "durationSeconds": 14.2, - "summary": "The isolated headless `orca serve` host passed the byte-identical six-PTY ordinary-parking, memory/lag, deep-history, same-PTY, parked-output, and post-reveal I/O oracle." + "durationSeconds": 23, + "summary": "The isolated headless `orca serve` host passed ordinary parking plus cold activation: deferred tabs stayed unmounted until activation, preserved PTY identity and parked output, and resumed live input." }, { "date": "2026-07-29", @@ -3348,15 +3349,6 @@ "result": "passed", "durationSeconds": 17.5, "summary": "The headed six-PTY oracle kept all warm-mounted hidden streams at zero renderer scheduler enqueues/drains under sustained output and under 500 ms timer lag, then restored the authoritative flood tail and parked/live markers exactly once." - }, - { - "date": "2026-07-30", - "runner": "local", - "platform": "macos", - "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", - "result": "passed", - "durationSeconds": 14.0, - "summary": "The isolated headless host passed the same six-stream zero-hidden-renderer-work, bounded-lag, exact authoritative restoration, same-PTY, and continued-I/O oracle." } ], "runtimeBudget": { diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index b3a88d08b..7256a76bf 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -34,4 +34,14 @@ describe('release E2E dispatch contract', () => { expect(refInput.type).toBe('string') expect(refInput.required).toBe(false) }) + + it('includes the paired-runtime web client in the shared E2E build artifact', () => { + const buildStep = e2eWorkflow.jobs.build.steps.find( + (step) => step.name === 'Build Electron app for E2E' + ) + + expect(buildStep.run).toContain('electron-vite build --mode e2e') + expect(buildStep.env.VITE_EXPOSE_STORE).toBe('true') + expect(buildStep.run).toContain('pnpm run build:web-from-renderer') + }) }) diff --git a/config/scripts/remote-shared-control-retirement-probe.ts b/config/scripts/remote-shared-control-retirement-probe.ts new file mode 100644 index 000000000..63abd01e3 --- /dev/null +++ b/config/scripts/remote-shared-control-retirement-probe.ts @@ -0,0 +1,258 @@ +import { getDefaultUserDataPath } from '../../src/cli/runtime/metadata' +import type { PairingOffer } from '../../src/shared/pairing' +import { RemoteRuntimeSharedControlConnection } from '../../src/shared/remote-runtime-shared-control-connection' +import { + resolveEnvironment, + resolveEnvironmentPairingOffer +} from '../../src/shared/runtime-environment-store' +import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types' + +async function main(): Promise { + const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME + if (!environmentName) { + throw new Error('ORCA_PROBE_ENVIRONMENT_NAME is required') + } + const userDataPath = getDefaultUserDataPath() + const environment = resolveEnvironment(userDataPath, environmentName) + const pairing = resolveEnvironmentPairingOffer(userDataPath, environment.id) + const cycles = readProbeInteger('ORCA_PROBE_CYCLES', 10, 100) + const concurrency = readProbeInteger('ORCA_PROBE_CONCURRENCY', 25, 200) + const settleMs = readProbeInteger('ORCA_PROBE_SETTLE_MS', 250, 5_000) + const cleanupTimeoutMs = readProbeInteger('ORCA_PROBE_CLEANUP_TIMEOUT_MS', 10_000, 30_000) + const unknownResponses = new Map() + const originalWarn = console.warn + console.warn = (message?: unknown, details?: unknown): void => { + if ( + message === '[remote-runtime.shared-control] unknown response id' && + typeof details === 'object' && + details !== null + ) { + const responseId = String((details as { responseId?: unknown }).responseId ?? 'unknown') + unknownResponses.set(responseId, (unknownResponses.get(responseId) ?? 0) + 1) + return + } + originalWarn(message, details) + } + try { + const startedAt = Date.now() + const before = await requestMemorySnapshot(pairing, environment.id) + let ok = 0 + let subscriptionResponses = 0 + let runtimeStatus: RuntimeStatus | null = null + const cleanupDurationsMs: number[] = [] + for (let cycle = 0; cycle < cycles; cycle += 1) { + const result = await runCycle({ + pairing, + environmentId: environment.id, + concurrency, + settleMs, + cleanupTimeoutMs + }) + ok += result.ok + subscriptionResponses += result.subscriptionResponses + runtimeStatus ??= result.runtimeStatus + cleanupDurationsMs.push(result.cleanupDurationMs) + } + await wait(settleMs) + const after = await requestMemorySnapshot(pairing, environment.id) + console.log( + JSON.stringify({ + environment: { id: environment.id, name: environment.name }, + runtime: runtimeStatus + ? { + appVersion: runtimeStatus.appVersion ?? null, + capabilities: runtimeStatus.capabilities ?? [], + hostPlatform: runtimeStatus.hostPlatform ?? null + } + : null, + cycles, + concurrency, + requests: cycles * concurrency, + ok, + subscriptionResponses, + cleanupDurationMs: { + average: Math.round( + cleanupDurationsMs.reduce((total, duration) => total + duration, 0) / + cleanupDurationsMs.length + ), + maximum: Math.max(...cleanupDurationsMs) + }, + unknownResponseFrames: Array.from(unknownResponses.values()).reduce( + (total, count) => total + count, + 0 + ), + unknownResponseIds: unknownResponses.size, + memory: { + before: summarizeMemory(before), + after: summarizeMemory(after), + appDelta: after.app.memory - before.app.memory + }, + elapsedMs: Date.now() - startedAt + }) + ) + } finally { + console.warn = originalWarn + } +} + +async function runCycle(args: { + pairing: PairingOffer + environmentId: string + concurrency: number + settleMs: number + cleanupTimeoutMs: number +}): Promise<{ + ok: number + subscriptionResponses: number + cleanupDurationMs: number + runtimeStatus: RuntimeStatus | null +}> { + const connection = new RemoteRuntimeSharedControlConnection(args.pairing, { + environmentId: args.environmentId + }) + try { + const responses = await Promise.all( + Array.from({ length: args.concurrency }, () => + connection.request('status.get', undefined, 10_000) + ) + ) + const runtimeStatus = responses.find((response) => response.ok) + let subscriptionResponses = 0 + const subscriptions = await Promise.all([ + connection.subscribe('runtime.clientEvents.subscribe', undefined, 10_000, { + onResponse: () => { + subscriptionResponses += 1 + }, + onError: () => {} + }), + connection.subscribe('session.tabs.subscribeAll', undefined, 10_000, { + onResponse: () => { + subscriptionResponses += 1 + }, + onError: () => {} + }) + ]) + await wait(args.settleMs) + for (const subscription of subscriptions) { + subscription.close() + } + const cleanupDurationMs = await waitForConnectionIdle(connection, args.cleanupTimeoutMs) + // Let cleanup replies reach the retirement cache before closing the socket. + await wait(args.settleMs) + return { + ok: responses.filter((response) => response.ok).length, + subscriptionResponses, + cleanupDurationMs, + runtimeStatus: runtimeStatus?.ok === true ? runtimeStatus.result : null + } + } finally { + connection.close() + } +} + +async function waitForConnectionIdle( + connection: RemoteRuntimeSharedControlConnection, + timeoutMs: number +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const diagnostics = connection.getDiagnostics() + if (diagnostics.pendingRequestCount === 0 && diagnostics.subscriptionCount === 0) { + return Date.now() - startedAt + } + await wait(25) + } + throw new Error(`Cycle did not settle: ${JSON.stringify(connection.getDiagnostics())}`) +} + +async function requestMemorySnapshot( + pairing: PairingOffer, + environmentId: string +): Promise { + const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId }) + try { + const response = await connection.request( + 'diagnostics.memory', + undefined, + 20_000 + ) + if (!response.ok) { + throw new Error(`Memory snapshot failed: ${response.error.message}`) + } + return response.result + } finally { + connection.close() + } +} + +function summarizeMemory(snapshot: MemorySnapshot): { + app: MemorySnapshot['app'] + host: MemorySnapshot['host'] + processMemoryMetric: MemorySnapshot['processMemoryMetric'] + totalCpu: number + totalMemory: number + worktreeCount: number + sessionCount: number + worktreeMemory: number + topWorktrees: { + worktreeName: string + repoName: string + cpu: number + memory: number + sessionCount: number + topSessions: { pid: number; cpu: number; memory: number }[] + }[] +} { + return { + app: snapshot.app, + host: snapshot.host, + processMemoryMetric: snapshot.processMemoryMetric, + totalCpu: snapshot.totalCpu, + totalMemory: snapshot.totalMemory, + worktreeCount: snapshot.worktrees.length, + sessionCount: snapshot.worktrees.reduce( + (total, worktree) => total + worktree.sessions.length, + 0 + ), + worktreeMemory: snapshot.worktrees.reduce((total, worktree) => total + worktree.memory, 0), + topWorktrees: [...snapshot.worktrees] + .sort((left, right) => right.memory - left.memory) + .slice(0, 10) + .map((worktree) => ({ + worktreeName: worktree.worktreeName, + repoName: worktree.repoName, + cpu: worktree.cpu, + memory: worktree.memory, + sessionCount: worktree.sessions.length, + topSessions: [...worktree.sessions] + .sort((left, right) => right.memory - left.memory) + .slice(0, 5) + .map((session) => ({ + pid: session.pid, + cpu: session.cpu, + memory: session.memory + })) + })) + } +} + +function readProbeInteger(name: string, fallback: number, maximum: number): number { + const value = process.env[name] + if (value === undefined) { + return fallback + } + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`${name} must be an integer from 1 through ${maximum}`) + } + return parsed +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +void main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c0798fe39..06d736088 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6736,6 +6736,17 @@ describe('registerPtyHandlers', () => { }) }) + it('settles a stale renderer process inspection as unavailable', async () => { + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ hasPty: vi.fn(() => false) } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id: 'gone-pty' })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + }) + // Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync). describe('pty:getSize reports applied size, not requested size', () => { function setupProviderWithAppliedSize(args: { diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 260f0d4d7..1567eca12 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -68,7 +68,10 @@ import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { inspectPtyProviderProcess } from '../providers/pty-process-inspection' +import { + inspectPtyProviderProcess, + inspectPtyProviderProcessForRenderer +} from '../providers/pty-process-inspection' import { PtyProcessListAdmission, visitPtyProcessListingsInBatches @@ -6178,7 +6181,7 @@ export function registerPtyHandlers( ) ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcess(getProviderForPty(args.id), args.id) + inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) ) ipcMain.handle( diff --git a/src/main/providers/pty-process-inspection.test.ts b/src/main/providers/pty-process-inspection.test.ts index b18214b33..ab6973112 100644 --- a/src/main/providers/pty-process-inspection.test.ts +++ b/src/main/providers/pty-process-inspection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { IPtyProvider } from './types' -import { inspectPtyProviderProcess } from './pty-process-inspection' +import { + inspectPtyProviderProcess, + inspectPtyProviderProcessForRenderer +} from './pty-process-inspection' describe('PTY provider process inspection', () => { it('rejects a missing provider PTY instead of returning idle evidence', async () => { @@ -25,6 +28,27 @@ describe('PTY provider process inspection', () => { expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') }) + it('returns unavailable to the renderer when a stale PTY is gone', async () => { + const provider = { + hasPty: vi.fn(() => false) + } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-missing')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + }) + + it('preserves non-stale renderer inspection failures', async () => { + const failure = new Error('daemon unavailable') + const provider = { + inspectProcess: vi.fn().mockRejectedValue(failure) + } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcessForRenderer(provider, 'pty-1')).rejects.toBe(failure) + }) + it('preserves an unavailable inspection result', async () => { const inspection = { foregroundProcess: null, diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 760095c2e..852960c6b 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -25,3 +25,17 @@ export async function inspectPtyProviderProcess( const hasChildProcesses = await provider.hasChildProcesses(ptyId) return { foregroundProcess, hasChildProcesses } } + +export async function inspectPtyProviderProcessForRenderer( + provider: IPtyProvider, + ptyId: string +): Promise { + try { + return await inspectPtyProviderProcess(provider, ptyId) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_gone') { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true } + } + throw error + } +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 5f9c333ea..561dd4e8c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -89,6 +89,7 @@ import { getTerminalWorktreeColdParkRecheckDelayMs } from './terminal-pane/termi import { TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, canParkTerminalWorktreeRenderers, + isParkRestorableTerminalPty, selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalWorktrees, type TerminalWorktreeColdParkCandidate @@ -112,7 +113,8 @@ import { disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers + syncParkedTerminalTabWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-pane/terminal-parked-tab-watchers' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' @@ -1204,8 +1206,15 @@ function Terminal(): React.JSX.Element | null { } } const activationHostSupportsDeferral = canDeferColdActivationTabsForHost({ - executionHostId: activeWorktreeDeferralHostId + executionHostId: activeWorktreeDeferralHostId, + pairedRuntimeParkingEnvironmentIds }) + const isColdActivationPtyEligible = (ptyId: string): boolean => + isRemoteRuntimePtyId(ptyId) + ? isParkRestorableTerminalPty(ptyId, renderedActiveWorktreeId, { + pairedRuntimeParkingEnvironmentIds + }) + : terminalProviderHasAuthoritativeSnapshot(ptyId) if (lastActivationWorktreeIdRef.current !== renderedActiveWorktreeId) { lastActivationWorktreeIdRef.current = renderedActiveWorktreeId const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab])) @@ -1226,7 +1235,7 @@ function Terminal(): React.JSX.Element | null { canWatcherCoverParkedTerminalTab( renderedActiveWorktreeId, tab, - terminalProviderHasAuthoritativeSnapshot + isColdActivationPtyEligible ) ) }, @@ -1243,7 +1252,7 @@ function Terminal(): React.JSX.Element | null { !canWatcherCoverParkedTerminalTab( renderedActiveWorktreeId, tab, - terminalProviderHasAuthoritativeSnapshot + isColdActivationPtyEligible ) ) { immediateTabIds.add(tab.id) @@ -1286,7 +1295,9 @@ function Terminal(): React.JSX.Element | null { ) // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. useEffect(() => { - pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + pruneParkedTerminalWatchers( + terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) + ) for (const workspace of workspaceSurfaces) { if ( anyMountedWorktreeHasLayout && diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index 29917c17f..d9f846fbb 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -137,6 +137,7 @@ const mocks = vi.hoisted(() => ({ setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), + shouldDeferParkedPtyExitTabClose: vi.fn(), useContextualTour: vi.fn() })) @@ -144,6 +145,10 @@ const saveDialogBox = vi.hoisted(() => ({ fileId: null as string | null })) +const parkingBox = vi.hoisted(() => ({ + parkedTabIds: new Set() +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -205,6 +210,14 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({ } })) +vi.mock('@/components/terminal-pane/use-terminal-tab-cold-parking', () => ({ + useTerminalTabColdParking: () => parkingBox.parkedTabIds +})) + +vi.mock('@/components/terminal-pane/terminal-parked-tab-watchers', () => ({ + shouldDeferParkedPtyExitTabClose: mocks.shouldDeferParkedPtyExitTabClose +})) + vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing })) @@ -574,6 +587,22 @@ function findByTypeName(node: unknown, typeName: string): ReactElementLike { return found } +function findAllByTypeName(node: unknown, typeName: string): ReactElementLike[] { + const found: ReactElementLike[] = [] + visit(node, (entry) => { + const candidate = + typeof entry.type === 'function' || typeof entry.type === 'object' + ? ((entry.type as { displayName?: string; name?: string }).displayName ?? + (entry.type as { displayName?: string; name?: string }).name ?? + '') + : entry.type + if (candidate === typeName) { + found.push(entry) + } + }) + return found +} + function findByProp(node: unknown, propName: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { @@ -780,6 +809,7 @@ describe('FloatingTerminalPanel close behavior', () => { hookRuntime.index = 0 hookRuntime.values = [] saveDialogBox.fileId = null + parkingBox.parkedTabIds = new Set() resetStore() // Why: the open-maximized intent is a module singleton; drain any leftover // from a prior test so it cannot bleed into an unrelated render. @@ -800,6 +830,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false) mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) + mocks.shouldDeferParkedPtyExitTabClose.mockReturnValue(false) const localStorage = { clear: vi.fn(), getItem: vi.fn(() => null), @@ -1477,6 +1508,20 @@ describe('FloatingTerminalPanel close behavior', () => { expect(openPane.props.isVisible).toBe(true) }) + it('does not mount floating terminal panes selected for cold parking', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) + parkingBox.parkedTabIds = new Set(['tab-2']) + + await renderPanel(true) + runEffects() + await Promise.resolve() + const element = await renderPanel(true) + + expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([ + 'tab-1' + ]) + }) + it('routes titlebar Cmd+T to the floating workspace', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) @@ -2483,11 +2528,16 @@ describe('FloatingTerminalPanel close behavior', () => { const element = await renderPanel(true, onOpenChange) const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onPtyExit as () => void)() - expect(mocks.closeTab).toHaveBeenCalledWith('tab-1', { reason: 'pty-exit' }) + ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1') + expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', { + lifecyclePtyId: 'pty-1', + reason: 'pty-exit' + }) + expect(mocks.closeTab).not.toHaveBeenCalled() expect(onOpenChange).not.toHaveBeenCalled() - mocks.closeTab.mockClear() + mocks.closeTerminalTab.mockClear() ;(terminalPane.props.onCloseTab as () => void)() // Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune. expect(mocks.closeTerminalTab).toHaveBeenCalledWith( @@ -2498,6 +2548,23 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) + it('preserves split siblings when a parked PTY exits during reveal', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true) + + await renderPanel(true) + runEffects() + await Promise.resolve() + const element = await renderPanel(true) + const terminalPane = findByTypeName(element, 'TerminalPane') + + ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty') + + expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty') + expect(mocks.closeTerminalTab).not.toHaveBeenCalled() + expect(mocks.closeTab).not.toHaveBeenCalled() + }) + it('renders and closes simulator tabs in the floating workspace', async () => { const tab = setFloatingSimulatorTab() diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 003ef188f..66c83ac06 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -12,6 +12,8 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane, { type TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane' +import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking' import { isTerminalPaneCloseChord } from '@/components/terminal-pane/terminal-shortcut-policy' import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { Button } from '@/components/ui/button' @@ -109,6 +111,7 @@ import { translate } from '@/i18n/i18n' import { consumeFloatingTerminalOpenMaximizedIntent } from '@/lib/floating-terminal' import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inputs' const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const +const NO_ACTIVITY_TERMINAL_PORTALS = [] const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) @@ -349,6 +352,27 @@ export function FloatingTerminalPanel({ ? activeTab.entityId : null const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) + const terminalAssignments = useMemo(() => { + const assignments = new Map() + for (const tab of unifiedTabs) { + if (tab.contentType === 'terminal') { + assignments.set(tab.entityId, { + groupId: tab.groupId, + isActiveInGroup: tab.entityId === activeTerminalId + }) + } + } + return assignments + }, [activeTerminalId, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + terminalTabs: tabs, + assignments: terminalAssignments, + isWorktreeActive: open, + coldParkTerminalPanes: false, + shouldMeasureHiddenWorktree: false, + activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS + }) const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>( () => groupTabs @@ -1817,33 +1841,43 @@ export function FloatingTerminalPanel({ } > {cwd - ? tabs.map((tab) => { - const isActive = tab.id === activeTerminalId - return ( -
- closeTab(tab.id, { reason: 'pty-exit' })} - onCloseTab={() => closeFloatingItemConfirmed(tab.id)} - /> -
- ) - }) + ? tabs + .filter((tab) => !parkedTerminalTabIds.has(tab.id)) + .map((tab) => { + const isActive = tab.id === activeTerminalId + return ( +
+ { + if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) { + return + } + closeTerminalTab(tab.id, { + reason: 'pty-exit', + lifecyclePtyId: ptyId + }) + }} + onCloseTab={() => closeFloatingItemConfirmed(tab.id)} + /> +
+ ) + }) : null} {browserTabs.map((tab) => { const isActive = tab.id === activeBrowserTab?.id diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e80c19f1a..261916779 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -55,10 +55,10 @@ async function drainFakeTimerWork(limit = 20): Promise { // Why: a reveal remount reads the still-live park watcher entry at connect time to // tell itself apart from an in-place reattach; the host disposes it a beat later. -async function parkTabForReveal(tabId: string, ptyId: string): Promise { +async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise { const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry') parkedWatchersByTabId.set(tabId, { - worktreeId: 'wt-1', + worktreeId, tabPtyId: ptyId, paneIdByPtyId: new Map([[ptyId, 1]]), disposersByPtyId: new Map([[ptyId, () => {}]]) @@ -20243,6 +20243,62 @@ describe('connectPanePty', () => { expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) }) + it('restores a local main-model snapshot after a contentless park reattach', async () => { + const { connectPanePty } = await import('./pty-connection') + const localPtyId = 'global-floating-terminal@@terminal-1' + const transport = createMockTransport(localPtyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + transport.getPtyId.mockReturnValue(localPtyId) + return sessionId ? { id: localPtyId, isReattach: true } : null + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) + getMainBufferSnapshot.mockResolvedValue({ + data: 'FLOATING-PARK-RESTORE-OK\r\n', + cols: 113, + rows: 32, + seq: 558, + source: 'headless' + }) + await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal') + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'global-floating-terminal': [{ id: 'tab-1', ptyId: localPtyId }] + }, + ptyIdsByTabId: { 'tab-1': [localPtyId] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: localPtyId } + } + } + } + + const pane = createPane(1) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + worktreeId: 'global-floating-terminal', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(getMainBufferSnapshot).toHaveBeenCalledWith(localPtyId, { scrollbackRows: 5000 }) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: localPtyId }) + ) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) + expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK') + }) + it('falls back to relay replay when the SSH model snapshot stalls', async () => { const { connectPanePty } = await import('./pty-connection') const { SSH_REATTACH_MODEL_SNAPSHOT_TIMEOUT_MS } = await import('./ssh-reattach-model-restore') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f4a4da3b9..dfa0a237e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7822,7 +7822,9 @@ export function connectPanePty( // the subscribe screen without keeping the old xterm mounted. let prefetchedParkModelSnapshot: PtyBufferSnapshot | null = null if (revealFollowsTerminalPark && (!hasStructuralReplay || isRemoteRuntimePtyId(ptyId))) { - if (isRemoteRuntimePtyId(ptyId)) { + if (parseAppSshPtyId(ptyId)) { + prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() + } else { try { prefetchedParkModelSnapshot = await serializeHiddenOutputSnapshot(ptyId, { scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) @@ -7830,8 +7832,6 @@ export function connectPanePty( } catch { prefetchedParkModelSnapshot = null } - } else { - prefetchedParkModelSnapshot = await fetchSshMainModelReattachSnapshot() } if (!isCurrentReattachPayload()) { return false 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 04456bd9f..59d1b90db 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 @@ -35,7 +35,8 @@ export { disposeParkedTerminalWatchersForPtyIds, disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' export { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index 8af5ca192..eb436c8e1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -8,16 +8,24 @@ import { hasPreHandlerPtyExit } from './pty-pre-handler-buffer' import { + capturedPanesByTabId, parkedWatchersByTabId, - pruneParkedTerminalWatchers + pruneParkedTerminalWatchers, + terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const TAB_ID = 'removed-parked-tab' const PTY_ID = 'removed-worktree@@parked-pty' +const FLOATING_TAB_ID = 'floating-parked-tab' +const FLOATING_PTY_ID = `${FLOATING_TERMINAL_WORKTREE_ID}@@parked-pty` describe('terminal parked watcher registry removal', () => { afterEach(() => { parkedWatchersByTabId.delete(TAB_ID) + parkedWatchersByTabId.delete(FLOATING_TAB_ID) + capturedPanesByTabId.delete(TAB_ID) + capturedPanesByTabId.delete(FLOATING_TAB_ID) clearPreHandlerPtyState(PTY_ID) }) @@ -51,4 +59,35 @@ describe('terminal parked watcher registry removal', () => { expect(exit).not.toHaveBeenCalled() expect(delayedData).not.toHaveBeenCalled() }) + + it('keeps the floating workspace while pruning a removed workspace', () => { + const removedDispose = vi.fn() + const floatingDispose = vi.fn() + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + tabPtyId: PTY_ID, + paneIdByPtyId: new Map([[PTY_ID, 1]]), + disposersByPtyId: new Map([[PTY_ID, removedDispose]]) + }) + parkedWatchersByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + tabPtyId: FLOATING_PTY_ID, + paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]), + disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]]) + }) + capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] }) + capturedPanesByTabId.set(FLOATING_TAB_ID, { + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + panes: [] + }) + + pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds([])) + + expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false) + expect(capturedPanesByTabId.has(TAB_ID)).toBe(false) + expect(removedDispose).toHaveBeenCalledOnce() + expect(parkedWatchersByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true) + expect(floatingDispose).not.toHaveBeenCalled() + }) }) 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 fd3439882..1f4b52204 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 @@ -8,6 +8,7 @@ * import cycle-free, mirroring how pty-dispatcher exports its handler maps. */ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' export type ParkedTerminalPaneCapture = { ptyId: string | null @@ -51,6 +52,11 @@ export function getParkedTerminalWatcherTabIds(): string[] { return Array.from(parkedWatchersByTabId.keys()) } +// Why: the floating workspace is synthetic, so repo/folder surface lists never include it. +export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): Set { + return new Set([...workspaceIds, FLOATING_TERMINAL_WORKTREE_ID]) +} + /** * Whether this tab is parked right now — the reveal remount's own mount effect * runs before the host effect that disposes the watcher (child effects first), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index e04f6c008..7138edb37 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -6,6 +6,7 @@ import type { TerminalTab } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ storeState: { pendingStartupByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), settings: {} as Record, terminalLayoutsByTabId: {} as Record }> }, @@ -75,6 +76,34 @@ describe('useTerminalTabColdParking measure-clock contract', () => { mocks.exemptTabIds = new Set() mocks.exemptSelectCalls = 0 mocks.storeState.terminalLayoutsByTabId = {} + mocks.storeState.runtimeStatusByEnvironmentId = new Map() + }) + + it('parks paired-runtime tabs only when their exact host advertises restore', () => { + const environmentId = 'paired-env' + const remoteArgs = { + ...hookArgs(false), + terminalTabs: [ + { ...terminalTab('tab-1'), ptyId: `remote:${environmentId}@@term-1` }, + { ...terminalTab('tab-2'), ptyId: `remote:${environmentId}@@term-2` } + ] + } + for (const [advertisedEnvironmentId, expected] of [ + [environmentId, new Set(['tab-2'])], + ['other-env', new Set()] + ] as const) { + mocks.storeState.runtimeStatusByEnvironmentId = new Map([ + [advertisedEnvironmentId, { status: { capabilities: ['terminal.paired-parking.v1'] } }] + ]) + const { result, unmount } = renderHook(() => useTerminalTabColdParking(remoteArgs)) + + act(() => { + vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) + }) + + expect(result.current).toEqual(expected) + unmount() + } }) // Why: the worktree layer preserves hiddenSince through a background-measure diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 85ea3e9e9..c339f00fe 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -16,6 +16,7 @@ import { import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' import { TERMINAL_TAB_COLD_PARK_DELAY_MS, + selectPairedRuntimeParkingEnvironmentIds, selectColdParkedTerminalTabs, type TerminalTabColdParkCandidate } from './terminal-hidden-view-parking' @@ -90,6 +91,11 @@ export function useTerminalTabColdParking(args: { const terminalSshParkingEnabled = useAppStore( (state) => state.settings?.terminalSshViewParking !== false ) + const runtimeStatusByEnvironmentId = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const pairedRuntimeParkingEnvironmentIds = useMemo( + () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), + [runtimeStatusByEnvironmentId] + ) const terminalTabHiddenSinceRef = useRef(new Map()) // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince // survives a background-measure window so per-tab park deadlines stay in @@ -189,7 +195,10 @@ export function useTerminalTabColdParking(args: { parkingEnabled: terminalParkingEnabled, nowMs, parkCooldownUntilMs: measureParkCooldownUntilRef.current, - restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled }, + restorePolicy: { + sshParkingEnabled: terminalSshParkingEnabled, + pairedRuntimeParkingEnvironmentIds + }, ...overrides }) // Why: a tab the byte watchers cannot cover (no capture, no layout @@ -238,6 +247,7 @@ export function useTerminalTabColdParking(args: { assignments, isWorktreeActive, pendingStartupByTabId, + pairedRuntimeParkingEnvironmentIds, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index f98e39cc3..72145641e 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -205,10 +205,22 @@ describe('cold activation tab deferral', () => { const tabIds = (count: number): string[] => Array.from({ length: count }, (_, index) => `tab-${index + 1}`) - it('enables deferral only for a positively resolved local execution host', () => { + it('enables deferral for local and snapshot-capable paired execution hosts', () => { expect(canDeferColdActivationTabsForHost({ executionHostId: 'local' })).toBe(true) expect(canDeferColdActivationTabsForHost({ executionHostId: 'ssh:ssh-1' })).toBe(false) expect(canDeferColdActivationTabsForHost({ executionHostId: 'runtime:runtime-1' })).toBe(false) + expect( + canDeferColdActivationTabsForHost({ + executionHostId: 'runtime:runtime-1', + pairedRuntimeParkingEnvironmentIds: new Set(['runtime-1']) + }) + ).toBe(true) + expect( + canDeferColdActivationTabsForHost({ + executionHostId: 'runtime:runtime-1', + pairedRuntimeParkingEnvironmentIds: new Set(['runtime-2']) + }) + ).toBe(false) expect(canDeferColdActivationTabsForHost({ executionHostId: null })).toBe(false) }) diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index ae8c677c2..c860046e3 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -2,6 +2,7 @@ import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { parseExecutionHostId } from '../../../../shared/execution-host' const pendingMounts = new Map() const requestListeners = new Set<() => void>() @@ -153,10 +154,18 @@ export function canMountTerminalWorkspaceForStartup(args: { export function canDeferColdActivationTabsForHost(args: { executionHostId: string | null + pairedRuntimeParkingEnvironmentIds?: ReadonlySet }): boolean { - // Why: restored identities can be transient or stale while remote ownership - // hydrates. Only positively confirmed local execution has daemon snapshots. - return args.executionHostId === 'local' + const host = parseExecutionHostId(args.executionHostId) + if (host?.kind === 'local') { + return true + } + // Why: remote ownership must match the exact host advertising bounded + // snapshots; stale runtime identities stay eager instead of losing output. + return ( + host?.kind === 'runtime' && + args.pairedRuntimeParkingEnvironmentIds?.has(host.environmentId) === true + ) } function replaceActivationDeferredMountTabs( diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 8ba1de1c5..524b66979 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -12,10 +12,12 @@ import { } from './remote-runtime-shared-control-ready' import { SharedControlReconnectScheduler } from './remote-runtime-shared-control-reconnect' import { requestSharedControl } from './remote-runtime-shared-control-requests' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { SharedControlReadyStableResetTimer } from './remote-runtime-shared-control-stability' import * as sharedControlState from './remote-runtime-shared-control-state' import * as sharedControlSend from './remote-runtime-shared-control-send' import { closeSharedControlSocket } from './remote-runtime-shared-control-socket-close' +import { closeSharedControlConnectionSubscription } from './remote-runtime-shared-control-subscription-close' import type { RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' import * as sharedControlSubscriptions from './remote-runtime-shared-control-subscriptions' import { startSharedControlSubscription } from './remote-runtime-shared-control-subscription-start' @@ -43,6 +45,7 @@ export class RemoteRuntimeSharedControlConnection { private lastError: string | null = null private readonly pendingRequests = new Map>() private readonly subscriptions = new Map>() + private readonly retiredRequestIds = new SharedControlRetiredRequestIds() private readonly readyWaiters: SharedControlReadyWaiter[] = [] private everReady = false private readonly socketGeneration = new SharedControlSocketGeneration() @@ -72,7 +75,8 @@ export class RemoteRuntimeSharedControlConnection { params, timeoutMs, ensureReady: () => this.ensureReadyWithTimeout(timeoutMs), - send: (requestId) => this.sendRequest(requestId) + send: (requestId) => this.sendRequest(requestId), + retireRequestId: (requestId) => this.retiredRequestIds.retire(requestId) }) } @@ -203,6 +207,7 @@ export class RemoteRuntimeSharedControlConnection { deviceToken: this.pairing.deviceToken, pendingRequests: this.pendingRequests, subscriptions: this.subscriptions, + retiredRequestIds: this.retiredRequestIds, readyWaiters: this.readyWaiters, setState: (state) => { this.state = state @@ -256,10 +261,12 @@ export class RemoteRuntimeSharedControlConnection { } private closeSubscription(requestId: string): void { - sharedControlSubscriptions.closeSharedControlLogicalSubscription({ + closeSharedControlConnectionSubscription({ subscriptions: this.subscriptions, - subscription: this.subscriptions.get(requestId), - request: (method, params) => this.sendSubscriptionCleanupRequest(method, params) + retiredRequestIds: this.retiredRequestIds, + requestId, + deviceToken: this.pairing.deviceToken, + send: (payload) => this.sendEncrypted(payload) }) this.reconnect.clearWhenIdle(this.subscriptions.size === 0 && this.state === 'closed') } @@ -273,15 +280,6 @@ export class RemoteRuntimeSharedControlConnection { }) } - private sendSubscriptionCleanupRequest(method: string, params: unknown): void { - sharedControlSubscriptions.sendSharedControlCleanupRequest({ - deviceToken: this.pairing.deviceToken, - method, - params, - send: (payload) => this.sendEncrypted(payload) - }) - } - private handleSocketClosed(error: RemoteRuntimeClientError, socketGeneration: number): void { if ( !this.socketGeneration.acceptClose({ diff --git a/src/shared/remote-runtime-shared-control-frame-dispatch.ts b/src/shared/remote-runtime-shared-control-frame-dispatch.ts index 8b8cc58d2..20c73b879 100644 --- a/src/shared/remote-runtime-shared-control-frame-dispatch.ts +++ b/src/shared/remote-runtime-shared-control-frame-dispatch.ts @@ -1,9 +1,8 @@ import type { parseRemoteRuntimeRpcFrame } from './remote-runtime-request-frames' import { logUnknownSharedControlResponse } from './remote-runtime-shared-control-diagnostics-log' -import { - handleSharedControlLogicalResponse, - sendSharedControlCleanupRequest -} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { sendRetiredSharedControlCleanupRequest } from './remote-runtime-shared-control-subscription-close' +import { handleSharedControlLogicalResponse } from './remote-runtime-shared-control-subscriptions' import { refreshSharedControlPendingRequestTimeouts, resolveSharedControlPendingResponse @@ -20,6 +19,7 @@ export function dispatchSharedControlFrame(args: { frame: SharedControlFrame pendingRequests: Map> subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds deviceToken: string send: (payload: unknown) => boolean }): void { @@ -36,18 +36,27 @@ export function dispatchSharedControlFrame(args: { subscription, response, request: (method, params) => - sendSharedControlCleanupRequest({ + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds: args.retiredRequestIds, deviceToken: args.deviceToken, method, params, send: args.send }) }) + if (!args.subscriptions.has(response.id)) { + args.retiredRequestIds.retire(response.id) + } return } if (args.pendingRequests.has(response.id)) { resolveSharedControlPendingResponse(args.pendingRequests, response.id, response) + args.retiredRequestIds.retire(response.id) + return + } + + if (args.retiredRequestIds.has(response.id)) { return } diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 3c08eb88f..3bf3c5cdc 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -3,6 +3,7 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch' import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol' +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' import { resolveSharedControlReadyWaiters } from './remote-runtime-shared-control-state' import type { SharedControlConnectionState, @@ -19,6 +20,7 @@ export function handleSharedControlTextFrame(args: { environmentId?: string pendingRequests: Map> subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds readyWaiters: SharedControlReadyWaiter[] setState: (state: SharedControlConnectionState) => void handleSocketClosed: (error: RemoteRuntimeClientError) => void @@ -65,6 +67,7 @@ export function handleSharedControlTextFrame(args: { frame: parsed.frame, pendingRequests: args.pendingRequests, subscriptions: args.subscriptions, + retiredRequestIds: args.retiredRequestIds, deviceToken: args.deviceToken, send: args.sendEncrypted }) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index f7a026f74..8379e53da 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -21,6 +21,7 @@ export function requestSharedControl(args: { timeoutMs: number ensureReady: () => Promise send: (requestId: string) => void + retireRequestId?: (requestId: string) => void // Why: default off — ordinary short RPCs keep an absolute deadline. Only // long-polls routed through this path opt in so keepalives extend them. refreshTimeoutOnKeepalive?: boolean @@ -48,6 +49,7 @@ export function requestSharedControl(args: { } pendingRequests.delete(requestId) releaseRemoteRuntimePreparedRequest(pending) + args.retireRequestId?.(requestId) // Why: one stalled method does not prove the shared socket is dead; // socket liveness owns connection-wide teardown so other RPCs survive. pending.reject(remoteRuntimeTimeoutError()) diff --git a/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts b/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts new file mode 100644 index 000000000..4b3b5a0ef --- /dev/null +++ b/src/shared/remote-runtime-shared-control-retired-request-ids.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' + +describe('SharedControlRetiredRequestIds', () => { + it('retains recent ids through repeated late frames and expires them', () => { + let now = 1_000 + const ids = new SharedControlRetiredRequestIds({ + ttlMs: 100, + now: () => now + }) + + ids.retire('request-1') + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-1')).toBe(true) + + now += 101 + expect(ids.has('request-1')).toBe(false) + expect(ids.size).toBe(0) + }) + + it('evicts the oldest ids at its configured bound', () => { + const ids = new SharedControlRetiredRequestIds({ maxIds: 2 }) + + ids.retire('request-1') + ids.retire('request-2') + ids.retire('request-3') + + expect(ids.size).toBe(2) + expect(ids.has('request-1')).toBe(false) + expect(ids.has('request-2')).toBe(true) + expect(ids.has('request-3')).toBe(true) + }) + + it('refreshes an existing id to the newest eviction rank', () => { + const ids = new SharedControlRetiredRequestIds({ maxIds: 2 }) + + ids.retire('request-1') + ids.retire('request-2') + ids.retire('request-1') + ids.retire('request-3') + + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-2')).toBe(false) + expect(ids.has('request-3')).toBe(true) + }) + + it('expires ids correctly after the clock moves backward', () => { + let now = 1_000 + const ids = new SharedControlRetiredRequestIds({ + ttlMs: 100, + now: () => now + }) + + ids.retire('request-1') + now = 900 + ids.retire('request-2') + now = 1_001 + + expect(ids.has('request-1')).toBe(true) + expect(ids.has('request-2')).toBe(false) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-retired-request-ids.ts b/src/shared/remote-runtime-shared-control-retired-request-ids.ts new file mode 100644 index 000000000..37341d361 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-retired-request-ids.ts @@ -0,0 +1,53 @@ +const DEFAULT_MAX_RETIRED_REQUEST_IDS = 2_048 +const DEFAULT_RETIRED_REQUEST_ID_TTL_MS = 60_000 + +export class SharedControlRetiredRequestIds { + private readonly ids = new Map() + private readonly maxIds: number + private readonly ttlMs: number + private readonly now: () => number + + constructor( + options: { + maxIds?: number + ttlMs?: number + now?: () => number + } = {} + ) { + this.maxIds = Math.max(1, options.maxIds ?? DEFAULT_MAX_RETIRED_REQUEST_IDS) + this.ttlMs = Math.max(1, options.ttlMs ?? DEFAULT_RETIRED_REQUEST_ID_TTL_MS) + this.now = options.now ?? Date.now + } + + retire(requestId: string): void { + const now = this.now() + this.pruneExpired(now) + this.ids.delete(requestId) + this.ids.set(requestId, now + this.ttlMs) + while (this.ids.size > this.maxIds) { + const oldestId = this.ids.keys().next().value + if (oldestId === undefined) { + return + } + this.ids.delete(oldestId) + } + } + + has(requestId: string): boolean { + this.pruneExpired(this.now()) + return this.ids.has(requestId) + } + + get size(): number { + this.pruneExpired(this.now()) + return this.ids.size + } + + private pruneExpired(now: number): void { + for (const [requestId, expiresAt] of this.ids) { + if (expiresAt <= now) { + this.ids.delete(requestId) + } + } + } +} diff --git a/src/shared/remote-runtime-shared-control-subscription-close.test.ts b/src/shared/remote-runtime-shared-control-subscription-close.test.ts new file mode 100644 index 000000000..8020dfb6d --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscription-close.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { + closeSharedControlConnectionSubscription, + sendRetiredSharedControlCleanupRequest +} from './remote-runtime-shared-control-subscription-close' +import { createSharedControlSubscription } from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +describe('shared-control subscription retirement', () => { + it('retires a closed subscription and its cleanup request', () => { + const subscriptions = new Map>() + const subscription = createSharedControlSubscription({ + requestId: 'request-1', + method: 'runtime.clientEvents.subscribe', + params: null, + retainedParamsBytes: 0, + callbacks: { onResponse: vi.fn(), onError: vi.fn() } + }) + subscription.sent = true + subscription.remoteSubscriptionId = 'subscription-1' + subscriptions.set(subscription.requestId, subscription) + const retiredRequestIds = new SharedControlRetiredRequestIds() + let cleanupRequestId = '' + + closeSharedControlConnectionSubscription({ + subscriptions, + retiredRequestIds, + requestId: subscription.requestId, + deviceToken: 'device-token', + send: (payload) => { + cleanupRequestId = (payload as { id: string }).id + return true + } + }) + + expect(subscriptions.size).toBe(0) + expect(retiredRequestIds.has(subscription.requestId)).toBe(true) + expect(retiredRequestIds.has(cleanupRequestId)).toBe(true) + }) + + it('does not retire an unsent cleanup request', () => { + const retiredRequestIds = new SharedControlRetiredRequestIds() + let cleanupRequestId = '' + + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds, + deviceToken: 'device-token', + method: 'runtime.clientEvents.unsubscribe', + params: null, + send: (payload) => { + cleanupRequestId = (payload as { id: string }).id + return false + } + }) + + expect(retiredRequestIds.has(cleanupRequestId)).toBe(false) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-subscription-close.ts b/src/shared/remote-runtime-shared-control-subscription-close.ts new file mode 100644 index 000000000..7ba60c9d6 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscription-close.ts @@ -0,0 +1,44 @@ +import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' +import { + closeSharedControlLogicalSubscription, + sendSharedControlCleanupRequest +} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +export function closeSharedControlConnectionSubscription(args: { + subscriptions: Map> + retiredRequestIds: SharedControlRetiredRequestIds + requestId: string + deviceToken: string + send: (payload: unknown) => boolean +}): void { + const subscription = args.subscriptions.get(args.requestId) + closeSharedControlLogicalSubscription({ + subscriptions: args.subscriptions, + subscription, + request: (method, params) => + sendRetiredSharedControlCleanupRequest({ + retiredRequestIds: args.retiredRequestIds, + deviceToken: args.deviceToken, + method, + params, + send: args.send + }) + }) + if (subscription && !args.subscriptions.has(args.requestId)) { + args.retiredRequestIds.retire(args.requestId) + } +} + +export function sendRetiredSharedControlCleanupRequest(args: { + retiredRequestIds: SharedControlRetiredRequestIds + deviceToken: string + method: string + params: unknown + send: (payload: unknown) => boolean +}): void { + const requestId = sendSharedControlCleanupRequest(args) + if (requestId) { + args.retiredRequestIds.retire(requestId) + } +} diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index 5b18ce801..4a93076d2 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -86,15 +86,17 @@ export function sendSharedControlCleanupRequest(args: { method: string params: unknown send: (payload: unknown) => boolean -}): void { +}): string | null { // Why: cleanup is best-effort and often runs during teardown; send it // synchronously so close() cannot race the async request path. - args.send({ - id: randomUUID(), + const requestId = randomUUID() + const sent = args.send({ + id: requestId, deviceToken: args.deviceToken, method: args.method, params: args.params }) + return sent ? requestId : null } export function replaySharedControlSubscriptions(args: { diff --git a/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts b/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts index fcb462d4c..2211328b5 100644 --- a/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts +++ b/tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from './helpers/orca-app' import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' import { launchPairedWebClient, type PairedWebClient } from './helpers/paired-electron-client' +import { runPairedTerminalColdActivationOracle } from './helpers/paired-terminal-cold-activation-oracle' import { runPairedTerminalParkingOracle } from './helpers/paired-terminal-parking-oracle' -test('ordinary-parks paired terminals against an isolated headless Orca host @headful', async ({ +test('ordinary-parks paired terminals against an isolated headless Orca host', async ({ testRepoPath }) => { test.setTimeout(240_000) @@ -38,3 +39,35 @@ test('ordinary-parks paired terminals against an isolated headless Orca host @he await host.dispose() } }) + +test('cold-activates only visible paired terminals against an isolated headless host', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedWebClient | null = null + try { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + await expect + .poll( + async () => { + const listed = await host.client.call<{ totalCount: number }>('worktree.list', { + repo: `id:${added.result.repo.id}` + }) + return listed.result.totalCount + }, + { timeout: 30_000 } + ) + .toBeGreaterThan(0) + client = await launchPairedWebClient(host.app, host.offer, { + terminalParkingDelayMs: 100 + }) + await runPairedTerminalColdActivationOracle(client.page, { repoId: added.result.repo.id }) + } finally { + await client?.dispose() + await host.dispose() + } +}) diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 926c4bf5f..83186e5a7 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -21,6 +21,10 @@ type ServeReady = { } } +const STARTUP_DIAGNOSTIC_LIMIT = 8_000 +const PAIRING_URL_PATTERN = /orca:\/\/[^\s"\\]+/g +const WEB_CLIENT_PAIRING_PATTERN = /([#&]pairing=)[^&\s"\\]+/g + export type HeadlessPairedRuntimeHost = { app: ElectronApplication client: RuntimeClient @@ -28,6 +32,62 @@ export type HeadlessPairedRuntimeHost = { offer: RuntimeDesktopPairingOffer } +export class HeadlessPairedRuntimeStartupDiagnosticBuffer { + private completed = '' + private discardingOversizedLine = false + private pending = '' + + append(chunk: Buffer): void { + let value = chunk.toString() + if (this.discardingOversizedLine) { + const newlineIndex = value.indexOf('\n') + if (newlineIndex === -1) { + return + } + value = value.slice(newlineIndex + 1) + this.discardingOversizedLine = false + } + + const combined = `${this.pending}${value}` + const newlineIndex = combined.lastIndexOf('\n') + if (newlineIndex !== -1) { + this.completed = `${this.completed}${redactPairingMaterial( + combined.slice(0, newlineIndex + 1) + )}`.slice(-STARTUP_DIAGNOSTIC_LIMIT) + this.pending = combined.slice(newlineIndex + 1) + } else { + this.pending = combined + } + if (this.pending.length > STARTUP_DIAGNOSTIC_LIMIT) { + this.pending = '' + this.discardingOversizedLine = true + } + } + + read(): string { + const pending = this.discardingOversizedLine ? '' : redactPairingMaterial(this.pending) + return `${this.completed}${pending}`.slice(-STARTUP_DIAGNOSTIC_LIMIT) + } +} + +export function formatHeadlessPairedRuntimeStartupDiagnostics( + stdout: string, + stderr: string +): string { + return [ + stdout ? `stdout:\n${redactPairingMaterial(stdout)}` : '', + stderr ? `stderr:\n${redactPairingMaterial(stderr)}` : '' + ] + .filter(Boolean) + .join('\n') +} + +function redactPairingMaterial(value: string): string { + return value + .replace(PAIRING_URL_PATTERN, 'orca://[redacted]') + .replace(WEB_CLIENT_PAIRING_PATTERN, '$1[redacted]') +} + async function readPairingOffer(app: ElectronApplication): Promise { const child = app.process() const stdout = child.stdout @@ -36,24 +96,44 @@ async function readPairingOffer(app: ElectronApplication): Promise { let buffered = '' + const stdoutDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + const stderrDiagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + const stderr = child.stderr const timeout = setTimeout(() => { cleanup() - reject(new Error('Headless runtime did not publish pairing readiness')) + const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics( + stdoutDiagnostic.read(), + stderrDiagnostic.read() + ) + reject( + new Error( + `Headless runtime did not publish pairing readiness${diagnostics ? `\n${diagnostics}` : ''}` + ) + ) }, 60_000) const cleanup = (): void => { clearTimeout(timeout) stdout.off('data', onData) + stderr?.off('data', onStderr) child.off('close', onClose) } const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { cleanup() + const diagnostics = formatHeadlessPairedRuntimeStartupDiagnostics( + stdoutDiagnostic.read(), + stderrDiagnostic.read() + ) reject( new Error( - `Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})` + `Headless runtime exited before pairing readiness (code=${code ?? 'none'}, signal=${signal ?? 'none'})${diagnostics ? `\n${diagnostics}` : ''}` ) ) } + const onStderr = (chunk: Buffer): void => { + stderrDiagnostic.append(chunk) + } const onData = (chunk: Buffer): void => { + stdoutDiagnostic.append(chunk) buffered += chunk.toString() const lines = buffered.split(/\r?\n/) buffered = lines.pop() ?? '' @@ -79,6 +159,7 @@ async function readPairingOffer(app: ElectronApplication): Promise electronApp.getPath('home')), - isolation - ) - const offer = await readPairingOffer(app) + const [offer] = await Promise.all([ + readPairingOffer(app), + app + .evaluate(({ app: electronApp }) => electronApp.getPath('home')) + .then((home) => assertElectronResolvedIsolatedHome(home, isolation)) + ]) return { app, client: new RuntimeClient(userDataDir, 5_000), diff --git a/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts b/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts new file mode 100644 index 000000000..69e7e0a66 --- /dev/null +++ b/tests/e2e/helpers/headless-paired-runtime-host.unit.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + HeadlessPairedRuntimeStartupDiagnosticBuffer, + formatHeadlessPairedRuntimeStartupDiagnostics +} from './headless-paired-runtime-host' + +describe('headless paired runtime startup diagnostics', () => { + it('redacts pairing URLs before truncation can remove their prefix', () => { + const pairingUrl = `orca://${'secret'.repeat(1_500)}` + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + + diagnostic.append(Buffer.from(`prefix${pairingUrl}\n`)) + + expect(diagnostic.read()).toBe('prefixorca://[redacted]\n') + expect(diagnostic.read()).not.toContain('secret') + }) + + it('redacts pairing URLs split across chunks', () => { + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + diagnostic.append(Buffer.from('orca://p')) + diagnostic.append(Buffer.from('airing-secret\nready')) + + expect(formatHeadlessPairedRuntimeStartupDiagnostics(diagnostic.read(), '')).toBe( + 'stdout:\norca://[redacted]\nready' + ) + }) + + it('redacts encoded pairing material from web-client URLs', () => { + const pairingUrl = encodeURIComponent('orca://pairing-secret') + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + + diagnostic.append(Buffer.from(`https://host/web-index.html#pairing=${pairingUrl}\n`)) + + expect(diagnostic.read()).toBe('https://host/web-index.html#pairing=[redacted]\n') + expect(diagnostic.read()).not.toContain('pairing-secret') + }) + + it('drops oversized unfinished lines instead of retaining a pairing fragment', () => { + const diagnostic = new HeadlessPairedRuntimeStartupDiagnosticBuffer() + diagnostic.append(Buffer.from(`orca://${'secret'.repeat(1_500)}`)) + diagnostic.append(Buffer.from('still-secret\nsafe')) + + expect(diagnostic.read()).toBe('safe') + }) +}) diff --git a/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts b/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts new file mode 100644 index 000000000..04d27a736 --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-cold-activation-observation.ts @@ -0,0 +1,55 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +export async function callColdActivationRuntime( + page: Page, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function readColdActivationMountState( + page: Page, + tabIds: string[] +): Promise<{ mounted: number; parked: number }> { + return page.evaluate((targets) => { + const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? []) + return { + mounted: targets.filter((id) => window.__paneManagers?.has(id)).length, + parked: targets.filter((id) => parked.has(id)).length + } + }, tabIds) +} + +export async function expectStableColdActivationMountState( + page: Page, + tabIds: string[], + expected: { mounted: number; parked: number } +): Promise { + await expect + .poll(() => readColdActivationMountState(page, tabIds), { timeout: 30_000 }) + .toEqual(expected) + const samples = await page.evaluate(async (targets) => { + const result: { mounted: number; parked: number }[] = [] + for (let index = 0; index < 8; index += 1) { + const parked = new Set(window.__terminalParkingDebug?.parkedTabIds() ?? []) + result.push({ + mounted: targets.filter((id) => window.__paneManagers?.has(id)).length, + parked: targets.filter((id) => parked.has(id)).length + }) + await new Promise((resolve) => window.setTimeout(resolve, 25)) + } + return result + }, tabIds) + expect(samples).toEqual(Array.from({ length: 8 }, () => expected)) +} diff --git a/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts b/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts new file mode 100644 index 000000000..f11bb6e28 --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-cold-activation-oracle.ts @@ -0,0 +1,292 @@ +import type { Page } from '@stablyai/playwright-test' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { toWebTerminalSurfaceTabId } from '../../../src/shared/terminal-surface-id' +import { expect } from './orca-app' +import { + callColdActivationRuntime, + expectStableColdActivationMountState, + readColdActivationMountState +} from './paired-terminal-cold-activation-observation' +import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' +import { getTerminalContent, waitForActivePanePtyId } from './terminal' + +const TARGET_TAB_COUNT = 8 + +type ColdTab = { + marker: string + originalPtyId: string + tabId: string + terminal: string +} + +export async function runPairedTerminalColdActivationOracle( + page: Page, + seed: { repoId: string } +): Promise { + const fixture = createPairedTerminalParkingFixture() + const handles: string[] = [] + const createdWorktreeIds: string[] = [] + let fallbackWorktreeId: string | null = null + let worktreeId: string | null = null + try { + await expect + .poll( + () => + page.evaluate((capability) => { + const state = window.__store?.getState() + const statuses = Array.from(state?.runtimeStatusByEnvironmentId.entries() ?? []) + return JSON.stringify({ + capable: statuses.some(([, entry]) => + entry.status?.capabilities?.includes(capability) + ), + statuses: statuses.map(([environmentId, entry]) => ({ + capabilities: entry.status?.capabilities ?? [], + environmentId, + hasStatus: entry.status != null + })), + workspaces: state?.allWorktrees().length ?? 0, + workspaceSessionReady: state?.workspaceSessionReady ?? false + }) + }, TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY), + { timeout: 30_000 } + ) + .toContain('"capable":true') + + const fallback = await callColdActivationRuntime<{ + startupTerminal?: { handle?: string; tabId?: string } + worktree: { id: string } + }>(page, 'worktree.create', { + repo: seed.repoId, + name: `paired-cold-fallback-${Date.now()}`, + setupDecision: 'skip', + activate: false, + noParent: true, + startupCommand: fixture.command('PAIR_COLD_FALLBACK') + }) + fallbackWorktreeId = fallback.worktree.id + createdWorktreeIds.push(fallbackWorktreeId) + if (!fallback.startupTerminal?.handle || !fallback.startupTerminal.tabId) { + throw new Error('Paired cold-activation fallback terminal was not created') + } + handles.push(fallback.startupTerminal.handle) + const fallbackTabId = toWebTerminalSurfaceTabId(fallback.startupTerminal.tabId) + await page.evaluate( + ({ tabId, targetWorktreeId }) => { + const state = window.__store?.getState() + state?.setActiveTabForWorktree(targetWorktreeId, tabId) + state?.setActiveView('terminal') + state?.setActiveWorktree(targetWorktreeId) + }, + { tabId: fallbackTabId, targetWorktreeId: fallbackWorktreeId } + ) + const fallbackTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${fallbackTabId}"]`) + await expect(fallbackTab).toBeVisible({ timeout: 30_000 }) + await fallbackTab.click() + await expect(fallbackTab).toHaveAttribute('data-active', 'true') + + const firstMarker = 'PAIR_COLD_ACTIVATION_0' + const created = await callColdActivationRuntime<{ + startupTerminal?: { handle?: string; tabId?: string } + worktree: { id: string } + }>(page, 'worktree.create', { + repo: seed.repoId, + name: `paired-cold-activation-${Date.now()}`, + setupDecision: 'skip', + activate: false, + noParent: true, + startupCommand: fixture.command(firstMarker) + }) + worktreeId = created.worktree.id + createdWorktreeIds.push(worktreeId) + if (!created.startupTerminal?.handle || !created.startupTerminal.tabId) { + throw new Error('Paired cold-activation startup terminal was not created') + } + handles.push(created.startupTerminal.handle) + const pendingTabs = [ + { + marker: firstMarker, + tabId: toWebTerminalSurfaceTabId(created.startupTerminal.tabId), + terminal: created.startupTerminal.handle + } + ] + + while (pendingTabs.length < TARGET_TAB_COUNT) { + const marker = `PAIR_COLD_ACTIVATION_${pendingTabs.length}` + const result = await callColdActivationRuntime<{ + tab: { parentTabId: string; terminal: string | null } + }>(page, 'session.tabs.createTerminal', { + worktree: `id:${worktreeId}`, + command: fixture.command(marker), + activate: false, + select: false, + navigation: 'caller' + }) + if (!result.tab.terminal) { + throw new Error(`Paired cold-activation terminal ${pendingTabs.length} was not created`) + } + handles.push(result.tab.terminal) + pendingTabs.push({ + marker, + tabId: toWebTerminalSurfaceTabId(result.tab.parentTabId), + terminal: result.tab.terminal + }) + } + + let originalPtyIds: string[] | null = null + await expect + .poll( + async () => { + originalPtyIds = await page.evaluate( + ({ tabIds, targetWorktreeId }) => { + const tabs = window.__store?.getState().tabsByWorktree[targetWorktreeId] ?? [] + const byId = new Map(tabs.map((tab) => [tab.id, tab.ptyId])) + const ids = tabIds.map((id) => byId.get(id) ?? null) + return ids.every((id): id is string => typeof id === 'string') ? ids : null + }, + { + tabIds: pendingTabs.map((tab) => tab.tabId), + targetWorktreeId: worktreeId + } + ) + return originalPtyIds + }, + { timeout: 30_000 } + ) + .not.toBeNull() + if (originalPtyIds === null) { + throw new Error('Paired cold-activation PTY ids were not captured') + } + const tabs: ColdTab[] = pendingTabs.map((tab, index) => ({ + ...tab, + originalPtyId: originalPtyIds[index]! + })) + const tabIds = tabs.map((tab) => tab.tabId) + expect(await readColdActivationMountState(page, tabIds)).toEqual({ mounted: 0, parked: 0 }) + + await page.evaluate( + ({ activeTabId, targetWorktreeId }) => { + const state = window.__store?.getState() + state?.setActiveTabForWorktree(targetWorktreeId, activeTabId) + state?.setActiveView('terminal') + state?.setActiveWorktree(targetWorktreeId) + }, + { activeTabId: tabs[0].tabId, targetWorktreeId: worktreeId } + ) + const firstTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[0].tabId}"]`) + await expect(firstTab).toBeVisible({ timeout: 30_000 }) + await expect(firstTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 1, + parked: TARGET_TAB_COUNT - 1 + }) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(tabs[0].originalPtyId) + + const deferred = tabs[4] + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expectStableColdActivationMountState(page, tabIds, { + mounted: TARGET_TAB_COUNT, + parked: 0 + }) + const deferredTab = page.locator( + `[data-testid="sortable-tab"][data-tab-id="${deferred.tabId}"]` + ) + await deferredTab.click() + await expect(deferredTab).toHaveAttribute('data-active', 'true') + expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`READY:${deferred.marker}`) + await firstTab.click() + await expect(firstTab).toHaveAttribute('data-active', 'true') + await page.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalHiddenViewParking: true }) + }) + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + await page.evaluate( + (fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId), + fallbackWorktreeId + ) + await expect(firstTab).not.toBeVisible() + await page.evaluate( + (targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId), + worktreeId + ) + await expect(firstTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + const secondTab = page.locator(`[data-testid="sortable-tab"][data-tab-id="${tabs[1].tabId}"]`) + await secondTab.click() + await expect(secondTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + await page.evaluate( + (fallbackWorktreeId) => window.__store?.getState().setActiveWorktree(fallbackWorktreeId), + fallbackWorktreeId + ) + await expect(secondTab).not.toBeVisible() + await page.evaluate( + (targetWorktreeId) => window.__store?.getState().setActiveWorktree(targetWorktreeId), + worktreeId + ) + await expect(secondTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + + const deferredMarker = `PAIR_COLD_DEFERRED_${Date.now()}` + const sent = await callColdActivationRuntime<{ send: { accepted: boolean } }>( + page, + 'terminal.send', + { + terminal: deferred.terminal, + text: deferredMarker, + enter: true + } + ) + expect(sent.send.accepted).toBe(true) + + await deferredTab.click() + await expect(deferredTab).toHaveAttribute('data-active', 'true') + await expectStableColdActivationMountState(page, tabIds, { + mounted: 2, + parked: TARGET_TAB_COUNT - 2 + }) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(deferred.originalPtyId) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`READY:${deferred.marker}`) + await expect + .poll(() => getTerminalContent(page), { timeout: 30_000 }) + .toContain(`LIVE:${deferredMarker}`) + } finally { + for (const terminal of handles) { + await callColdActivationRuntime(page, 'terminal.closeTab', { terminal }).catch( + () => undefined + ) + } + await page + .evaluate(() => window.__store?.getState().setActiveWorktree(null)) + .catch(() => undefined) + for (const createdWorktreeId of createdWorktreeIds.toReversed()) { + await callColdActivationRuntime(page, 'worktree.rm', { + worktree: `id:${createdWorktreeId}`, + force: true, + runHooks: false + }).catch(() => undefined) + } + fixture.dispose() + } +}