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
This commit is contained in:
parent
ef9e6ab9a8
commit
e467b3ff7b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, number>()
|
||||
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<RuntimeStatus>('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<number> {
|
||||
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<MemorySnapshot> {
|
||||
const connection = new RemoteRuntimeSharedControlConnection(pairing, { environmentId })
|
||||
try {
|
||||
const response = await connection.request<MemorySnapshot>(
|
||||
'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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PtyProcessInspection> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, { groupId: string; isActiveInGroup: boolean }>()
|
||||
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 (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TerminalPane
|
||||
ref={terminalPaneRegistry.getRefCallback(tab.id)}
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={isActive}
|
||||
// Why: the closed panel is only CSS-hidden, so gate
|
||||
// visibility on `open` too. This routes the floating
|
||||
// terminal through the standard hidden-terminal
|
||||
// suspend/resume path: no live WebGL context (or glyph
|
||||
// atlas to corrupt) while hidden, and the resume on
|
||||
// reopen rebuilds the renderer from scratch.
|
||||
isVisible={isActive && open}
|
||||
onPtyExit={() => closeTab(tab.id, { reason: 'pty-exit' })}
|
||||
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
? tabs
|
||||
.filter((tab) => !parkedTerminalTabIds.has(tab.id))
|
||||
.map((tab) => {
|
||||
const isActive = tab.id === activeTerminalId
|
||||
return (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<TerminalPane
|
||||
ref={terminalPaneRegistry.getRefCallback(tab.id)}
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={isActive}
|
||||
// Why: the closed panel is only CSS-hidden, so gate
|
||||
// visibility on `open` too. This routes the floating
|
||||
// terminal through the standard hidden-terminal
|
||||
// suspend/resume path: no live WebGL context (or glyph
|
||||
// atlas to corrupt) while hidden, and the resume on
|
||||
// reopen rebuilds the renderer from scratch.
|
||||
isVisible={isActive && open}
|
||||
onPtyExit={(ptyId) => {
|
||||
if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTerminalTab(tab.id, {
|
||||
reason: 'pty-exit',
|
||||
lifecyclePtyId: ptyId
|
||||
})
|
||||
}}
|
||||
onCloseTab={() => closeFloatingItemConfirmed(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
: null}
|
||||
{browserTabs.map((tab) => {
|
||||
const isActive = tab.id === activeBrowserTab?.id
|
||||
|
|
|
|||
|
|
@ -55,10 +55,10 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
|
|||
|
||||
// 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<void> {
|
||||
async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise<void> {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string>): Set<string> {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { TerminalTab } from '../../../../shared/types'
|
|||
const mocks = vi.hoisted(() => ({
|
||||
storeState: {
|
||||
pendingStartupByTabId: {} as Record<string, unknown>,
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
settings: {} as Record<string, unknown>,
|
||||
terminalLayoutsByTabId: {} as Record<string, { ptyIdsByLeafId?: Record<string, string> }>
|
||||
},
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, number>())
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, BackgroundMountTerminalWorktreeDetail>()
|
||||
const requestListeners = new Set<() => void>()
|
||||
|
|
@ -153,10 +154,18 @@ export function canMountTerminalWorkspaceForStartup(args: {
|
|||
|
||||
export function canDeferColdActivationTabsForHost(args: {
|
||||
executionHostId: string | null
|
||||
pairedRuntimeParkingEnvironmentIds?: ReadonlySet<string>
|
||||
}): 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(
|
||||
|
|
|
|||
|
|
@ -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<string, SharedControlPendingRequest<unknown>>()
|
||||
private readonly subscriptions = new Map<string, SharedControlLogicalSubscription<unknown>>()
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, SharedControlPendingRequest<unknown>>
|
||||
subscriptions: Map<string, SharedControlLogicalSubscription<unknown>>
|
||||
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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function requestSharedControl<TResult>(args: {
|
|||
timeoutMs: number
|
||||
ensureReady: () => Promise<void>
|
||||
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<TResult>(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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, number>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, SharedControlLogicalSubscription<unknown>>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, SharedControlLogicalSubscription<unknown>>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<RuntimeDesktopPairingOffer> {
|
||||
const child = app.process()
|
||||
const stdout = child.stdout
|
||||
|
|
@ -36,24 +96,44 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
|
|||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
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<RuntimeDeskto
|
|||
}
|
||||
}
|
||||
stdout.on('data', onData)
|
||||
stderr?.on('data', onStderr)
|
||||
child.on('close', onClose)
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
onClose(child.exitCode, child.signalCode)
|
||||
|
|
@ -120,11 +201,12 @@ export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedR
|
|||
],
|
||||
env: isolation.env
|
||||
})
|
||||
assertElectronResolvedIsolatedHome(
|
||||
await app.evaluate(({ app: electronApp }) => 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),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect } from './orca-app'
|
||||
|
||||
export async function callColdActivationRuntime<TResult>(
|
||||
page: Page,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<TResult> {
|
||||
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<TResult>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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))
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue