fix(terminal): refuse unaddressable split cleanup

This commit is contained in:
Jinwoo-H
2026-08-20 06:50:16 -04:00
parent 974b6c41fa
commit 5685471ac0
4 changed files with 82 additions and 17 deletions
+4 -3
View File
@@ -3462,7 +3462,8 @@
"file": "src/main/runtime/orca-runtime-terminal-split-authority.test.ts",
"assertions": [
"rejected split cleanup passes the spawned incarnation through exact stop and final retirement",
"a same-id replacement installed behind the stop barrier remains current and never reaches an ID-only kill"
"a same-id replacement installed behind the stop barrier remains current and never reaches an ID-only kill",
"a legacy SSH owner is refused before split spawn, and missing spawn proof never reaches ID-only cleanup"
]
},
{
@@ -3694,8 +3695,8 @@
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/ipc/pty-ssh-stop-verdict.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts",
"result": "passed",
"durationSeconds": 4.87,
"summary": "Fourteen files and 230 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, failed split-leaf retention, the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew and owner routing, host duplicate teardown, SSH method skew, and relay process-owner rejection."
"durationSeconds": 4.75,
"summary": "Fourteen files and 232 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, legacy split-owner preflight refusal, missing-proof cleanup refusal, failed split-leaf retention, the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew and owner routing, host duplicate teardown, SSH method skew, and relay process-owner rejection."
},
{
"date": "2026-08-20",
+2 -2
View File
@@ -5904,8 +5904,8 @@ export function registerPtyHandlers(
(await provider?.supportsIncarnationAddressedShutdown?.(ptyId, opts)) === true
return (
supported &&
opts?.expectedIncarnationId !== undefined &&
ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId
(opts?.expectedIncarnationId === undefined ||
ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId)
)
},
getForegroundProcess: async (ptyId) => {
@@ -79,8 +79,10 @@ function createHarness(
deferReveal?: boolean
deferSpawn?: boolean
deferStop?: boolean
exactStopSupported?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
spawnIncarnationId?: string | null
sourceIncarnationId?: string
stopAndWaitResult?: boolean
} = {}
@@ -109,15 +111,20 @@ function createHarness(
},
persistPtyBinding: () => true
}
let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined
const spawnIncarnationId =
options.spawnIncarnationId === undefined ? SPLIT_INCARNATION_ID : options.spawnIncarnationId
let resolveSpawn: ((result: { id: string; incarnationId?: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string; incarnationId: string }>((resolve) => {
new Promise<{ id: string; incarnationId?: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID }))
: vi.fn(async () => ({
id: SPLIT_PTY_ID,
...(spawnIncarnationId ? { incarnationId: spawnIncarnationId } : {})
}))
const kill = vi.fn(() => true)
const retireRejectedPty = vi.fn()
let resolveStop: ((stopped: boolean) => void) | undefined
@@ -129,6 +136,7 @@ function createHarness(
})
)
: vi.fn(async () => options.stopAndWaitResult ?? true)
const supportsIncarnationAddressedStop = vi.fn(async () => options.exactStopSupported ?? true)
let resolveReveal: ((result: { tabId: string }) => void) | undefined
const revealTerminalSession = options.deferReveal
? vi.fn(
@@ -153,6 +161,7 @@ function createHarness(
write: () => true,
kill,
retireRejectedPty,
supportsIncarnationAddressedStop,
...(options.stopAndWaitResult !== undefined || options.deferStop ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
@@ -202,6 +211,7 @@ function createHarness(
kill,
retireRejectedPty,
stopAndWait,
supportsIncarnationAddressedStop,
revealTerminalSession,
getSession: () => session,
getSnapshot: () => internals.mobileSessionTabsByWorktree.get(WORKTREE_ID),
@@ -228,7 +238,11 @@ function createHarness(
}),
getSplitIncarnation: () => internals.ptysById.get(SPLIT_PTY_ID)?.incarnationId,
resolveStop: (stopped: boolean) => resolveStop?.(stopped),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
resolveSpawn: () =>
resolveSpawn?.({
id: SPLIT_PTY_ID,
...(spawnIncarnationId ? { incarnationId: spawnIncarnationId } : {})
})
}
}
@@ -280,6 +294,46 @@ describe('remote runtime terminal split authority', () => {
expect.soft(harness.revealTerminalSession).not.toHaveBeenCalled()
})
it('refuses a legacy SSH split owner before spawning an unaddressable PTY', async () => {
const harness = createHarness(true, {
connectionId: 'ssh-legacy',
exactStopSupported: false,
sourceIncarnationId: 'source-incarnation'
})
await expect(
harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
).rejects.toThrow('terminal_incarnation_fence_unavailable')
expect(harness.supportsIncarnationAddressedStop).toHaveBeenCalledWith(SOURCE_PTY_ID, {
deadlineMs: expect.any(Number),
expectedIncarnationId: 'source-incarnation'
})
expect(harness.spawn).not.toHaveBeenCalled()
expect(harness.stopAndWait).not.toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalled()
})
it('never stops a rejected split by ID when spawn omits its incarnation', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
spawnIncarnationId: null,
stopAndWaitResult: true
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false, undefined)
expect(harness.kill).not.toHaveBeenCalled()
})
it.each([
{ label: 'local', connectionId: null, expectedHostId: 'local' },
{ label: 'SSH', connectionId: 'ssh-1', expectedHostId: 'ssh:ssh-1' }
+18 -8
View File
@@ -30275,6 +30275,14 @@ export class OrcaRuntimeService {
}
const sourceIncarnationId =
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
if (
(await this.ptyController.supportsIncarnationAddressedStop?.(pty.ptyId, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS,
...(sourceIncarnationId ? { expectedIncarnationId: sourceIncarnationId } : {})
})) === false
) {
throw new Error('terminal_incarnation_fence_unavailable')
}
const leafId = randomUUID()
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const paneKey = makePaneKey(parentTabId, leafId)
@@ -30393,14 +30401,16 @@ export class OrcaRuntimeService {
} catch (error) {
this.setPairedRendererSessionOwnership(result.id, false)
let stopped = false
try {
stopped =
(await this.ptyController.stopAndWait?.(result.id, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS,
...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {})
})) ?? false
} catch {
// Exact cleanup remains unverifiable; preserve the original split authority error.
if (result.incarnationId) {
try {
stopped =
(await this.ptyController.stopAndWait?.(result.id, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS,
expectedIncarnationId: result.incarnationId
})) ?? false
} catch {
// Exact cleanup remains unverifiable; preserve the original split authority error.
}
}
try {
this.ptyController.retireRejectedPty?.(result.id, stopped, result.incarnationId)