fix(terminal): require split source incarnation proof

This commit is contained in:
Jinwoo-H
2026-08-20 07:00:38 -04:00
parent 5685471ac0
commit 1cf921bb29
5 changed files with 101 additions and 28 deletions
+3 -3
View File
@@ -3463,7 +3463,7 @@
"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 legacy SSH owner is refused before split spawn, and missing spawn proof never reaches ID-only cleanup"
"missing source proof and a legacy SSH owner are refused before split spawn, while missing spawn proof never reaches ID-only cleanup"
]
},
{
@@ -3695,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.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."
"durationSeconds": 4.72,
"summary": "Fourteen files and 233 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, missing-source and legacy-owner pre-spawn 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) => {
@@ -12,6 +12,7 @@ const TAB_ID = 'tab-remote'
const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SOURCE_PTY_ID = 'pty-source'
const SPLIT_PTY_ID = 'pty-split'
const SOURCE_INCARNATION_ID = '11111111-1111-4111-8111-111111111112'
const SPLIT_INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
const REPLACEMENT_SPLIT_INCARNATION_ID = '33333333-3333-4333-8333-333333333333'
@@ -83,12 +84,14 @@ function createHarness(
includePairedSnapshot?: boolean
rendererMounted?: boolean
spawnIncarnationId?: string | null
sourceIncarnationId?: string
sourceIncarnationId?: string | null
stopAndWaitResult?: boolean
} = {}
) {
let session = persistedSession(includeSource)
const connectionId = options.connectionId ?? null
const sourceIncarnationId =
options.sourceIncarnationId === undefined ? SOURCE_INCARNATION_ID : options.sourceIncarnationId
const ownerHostId = connectionId ? `ssh:${connectionId}` : 'local'
const requestedSessionHostIds: (string | undefined)[] = []
const repo = {
@@ -196,7 +199,7 @@ function createHarness(
runtime.registerPty(SOURCE_PTY_ID, WORKTREE_ID, connectionId, {
tabId: TAB_ID,
leafId: SOURCE_LEAF_ID,
...(options.sourceIncarnationId ? { incarnationId: options.sourceIncarnationId } : {})
...(sourceIncarnationId ? { incarnationId: sourceIncarnationId } : {})
})
const internals = runtime as unknown as {
issuePtyHandle: (pty: unknown) => string
@@ -314,6 +317,19 @@ describe('remote runtime terminal split authority', () => {
expect(harness.kill).not.toHaveBeenCalled()
})
it('refuses a split source without incarnation proof before capability probing', async () => {
const harness = createHarness(true, { sourceIncarnationId: null })
await expect(
harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
).rejects.toThrow('terminal_incarnation_fence_unavailable')
expect(harness.supportsIncarnationAddressedStop).not.toHaveBeenCalled()
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,
@@ -404,6 +420,7 @@ describe('remote runtime terminal split authority', () => {
const harness = createHarness(true, {
deferSpawn: true,
includePairedSnapshot: false,
sourceIncarnationId: null,
stopAndWaitResult: true
})
harness.replacePersistedSourceIncarnation('persisted-before')
+73 -20
View File
@@ -6451,8 +6451,14 @@ describe('OrcaRuntimeService', () => {
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-remote-initial' })
.mockResolvedValueOnce({ id: 'pty-remote-setup-split' })
.mockResolvedValueOnce({
id: 'pty-remote-initial',
incarnationId: 'pty-remote-initial-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-remote-setup-split',
incarnationId: 'pty-remote-setup-split-incarnation'
})
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-remote-split' })
registerSshGitProvider('ssh-1', provider as never)
registerSshFilesystemProvider('ssh-1', fsProvider as never)
@@ -15288,8 +15294,8 @@ describe('OrcaRuntimeService', () => {
it('splits visible pty-backed terminal sessions through the parent renderer tab', async () => {
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-source' })
.mockResolvedValueOnce({ id: 'pty-split' })
.mockResolvedValueOnce({ id: 'pty-source', incarnationId: 'pty-source-incarnation' })
.mockResolvedValueOnce({ id: 'pty-split', incarnationId: 'pty-split-incarnation' })
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg' })
const splitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
@@ -15498,7 +15504,8 @@ describe('OrcaRuntimeService', () => {
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, {
tabId,
leafId: HEADLESS_LEAF_ID
leafId: HEADLESS_LEAF_ID,
incarnationId: 'closing-persisted-incarnation'
})
const internals = runtime as unknown as {
issuePtyHandle: (pty: unknown) => string
@@ -15592,8 +15599,14 @@ describe('OrcaRuntimeService', () => {
const folderPath = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-split-'))
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-folder-source' })
.mockResolvedValueOnce({ id: 'pty-folder-split' })
.mockResolvedValueOnce({
id: 'pty-folder-source',
incarnationId: 'pty-folder-source-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-folder-split',
incarnationId: 'pty-folder-split-incarnation'
})
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-folder' })
const folderWorkspace = makeFolderWorkspace({ folderPath })
const projectGroup = makeFolderProjectGroup({ parentPath: folderPath })
@@ -15693,7 +15706,10 @@ describe('OrcaRuntimeService', () => {
}),
'ssh:ssh-1'
)
const spawn = vi.fn().mockResolvedValue({ id: splitPtyId })
const spawn = vi.fn().mockResolvedValue({
id: splitPtyId,
incarnationId: 'ssh-split-incarnation'
})
const runtime = new OrcaRuntimeService({
...runtimeStore,
getRepos: () => [remoteRepo],
@@ -15708,7 +15724,8 @@ describe('OrcaRuntimeService', () => {
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
runtime.registerPty(sourcePtyId, TEST_WORKTREE_ID, 'ssh-1', {
tabId,
leafId: HEADLESS_LEAF_ID
leafId: HEADLESS_LEAF_ID,
incarnationId: 'ssh-source-incarnation'
})
const internals = runtime as unknown as {
issuePtyHandle: (pty: unknown) => string
@@ -28722,7 +28739,13 @@ describe('OrcaRuntimeService', () => {
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValueOnce({ id: ptyId }).mockResolvedValueOnce({ id: splitPtyId }),
spawn: vi
.fn()
.mockResolvedValueOnce({ id: ptyId, incarnationId: 'runtime-owned-incarnation' })
.mockResolvedValueOnce({
id: splitPtyId,
incarnationId: 'runtime-owned-split-incarnation'
}),
write: () => true,
kill,
getForegroundProcess: async () => null,
@@ -28942,8 +28965,14 @@ describe('OrcaRuntimeService', () => {
it('publishes laptop-created remote runtime split terminals to phone session tabs', async () => {
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'laptop-created-pty' })
.mockResolvedValueOnce({ id: 'laptop-split-pty' })
.mockResolvedValueOnce({
id: 'laptop-created-pty',
incarnationId: 'laptop-created-incarnation'
})
.mockResolvedValueOnce({
id: 'laptop-split-pty',
incarnationId: 'laptop-split-incarnation'
})
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
@@ -44700,8 +44729,14 @@ describe('OrcaRuntimeService', () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-cli-setup-split' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-cli-setup-main' })
.mockResolvedValueOnce({ id: 'pty-cli-setup-setup' })
.mockResolvedValueOnce({
id: 'pty-cli-setup-main',
incarnationId: 'pty-cli-setup-main-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-cli-setup-setup',
incarnationId: 'pty-cli-setup-child-incarnation'
})
runtime.setPtyController({
spawn,
write: () => true,
@@ -44788,8 +44823,14 @@ describe('OrcaRuntimeService', () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-bg-split-main' })
.mockResolvedValueOnce({ id: 'pty-bg-split-setup' })
.mockResolvedValueOnce({
id: 'pty-bg-split-main',
incarnationId: 'pty-bg-split-main-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-bg-split-setup',
incarnationId: 'pty-bg-split-child-incarnation'
})
runtime.setPtyController({
spawn,
write: () => true,
@@ -44870,8 +44911,14 @@ describe('OrcaRuntimeService', () => {
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-active-split-main' })
.mockResolvedValueOnce({ id: 'pty-active-split-setup' })
.mockResolvedValueOnce({
id: 'pty-active-split-main',
incarnationId: 'pty-active-split-main-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-active-split-setup',
incarnationId: 'pty-active-split-child-incarnation'
})
runtime.setPtyController({
spawn,
write: () => true,
@@ -45577,8 +45624,14 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(runtimeStore as never)
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-startup-split-main' })
.mockResolvedValueOnce({ id: 'pty-startup-split-setup' })
.mockResolvedValueOnce({
id: 'pty-startup-split-main',
incarnationId: 'pty-startup-split-main-incarnation'
})
.mockResolvedValueOnce({
id: 'pty-startup-split-setup',
incarnationId: 'pty-startup-split-child-incarnation'
})
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-split' })
runtime.setPtyController({
spawn,
+4 -1
View File
@@ -30275,10 +30275,13 @@ export class OrcaRuntimeService {
}
const sourceIncarnationId =
sourceAuthority.liveIncarnationId ?? sourceAuthority.persistedIncarnationId
if (!sourceIncarnationId) {
throw new Error('terminal_incarnation_fence_unavailable')
}
if (
(await this.ptyController.supportsIncarnationAddressedStop?.(pty.ptyId, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS,
...(sourceIncarnationId ? { expectedIncarnationId: sourceIncarnationId } : {})
expectedIncarnationId: sourceIncarnationId
})) === false
) {
throw new Error('terminal_incarnation_fence_unavailable')