fix(terminal): fence remaining exact-close cleanup

This commit is contained in:
Jinwoo-H
2026-08-20 06:44:35 -04:00
parent 01f2e36e88
commit 974b6c41fa
23 changed files with 514 additions and 111 deletions
+24 -7
View File
@@ -3380,7 +3380,7 @@
"providers": ["local", "daemon", "ssh", "runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A headed paired desktop/client journey proves a replacement handle remains writable and uniquely owned before terminal.closeTab, while an isolated CDP-attached desktop proves terminal.close acknowledges exact PTY teardown, removes the rendered tab, and leaves fresh host inventory empty. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, runtime-owned headless closeTab teardown, exact local/daemon/SSH/relay process-owner shutdown, old daemon and relay fail-closed behavior, same-id local replacement admission after physical exit, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression. The isolated headless orca serve fixture did not publish runtime/worktree state before terminal creation on 2026-08-20, so live headless close parity remains pending alongside Linux, Windows, WSL, and SSH process evidence.",
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A headed paired desktop/client journey proves a replacement handle remains writable and uniquely owned before terminal.closeTab, while an isolated CDP-attached desktop proves terminal.close acknowledges exact PTY teardown, removes the rendered tab, and leaves fresh host inventory empty. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, runtime-owned headless closeTab teardown, rejected-split exact cleanup, failed split-leaf stop retention, exact local/daemon/SSH/relay process-owner shutdown, pre-commit refusal for missing incarnation proof and old daemon/relay owners, same-id local replacement admission after physical exit, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression. The isolated headless orca serve fixture did not publish runtime/worktree state before terminal creation on 2026-08-20, so live headless close parity remains pending alongside Linux, Windows, WSL, and SSH process evidence.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/8628",
"https://github.com/stablyai/orca/pull/8706",
@@ -3389,11 +3389,11 @@
"https://github.com/stablyai/orca/pull/14590"
],
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; a close authorized for terminal incarnation A never closes, kills, retires, or tombstones replacement incarnation B, while unchanged A still closes. Final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Pause final-pane terminal.close and terminal.closeTab first at the acknowledged renderer commit and then inside provider stop after that commit; replace or republish the PTY under the same tab/leaf before releasing each barrier. Replacement B must remain in the persisted session and live inventory, retain its exact process identity and readable handle, and never appear in renderer retirement, provider stop, cleanup, or stop-intent attribution; unchanged A must retire and stop exactly once. Cover runtime-published, runtime-owned headless, and renderer-graph tabs plus git and folder workspaces, including a reused PTY id with a new incarnation. A headless terminal.closeTab whose caller owns exact teardown must commit the session surface without launching an ID-only kill, then let the incarnation-addressed stop reject same-id B. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Exact shutdown must reach local, daemon, and SSH/relay process owners, fail closed against old owners, fence final cleanup, and preserve duplicate same-incarnation teardown coalescing. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Pause final-pane terminal.close and terminal.closeTab first at the acknowledged renderer commit and then inside provider stop after that commit; replace or republish the PTY under the same tab/leaf before releasing each barrier. Replacement B must remain in the persisted session and live inventory, retain its exact process identity and readable handle, and never appear in renderer retirement, provider stop, cleanup, or stop-intent attribution; unchanged A must retire and stop exactly once. Cover runtime-published, runtime-owned headless, and renderer-graph tabs plus git and folder workspaces, including a reused PTY id with a new incarnation. A headless terminal.closeTab whose caller owns exact teardown must commit the session surface without launching an ID-only kill, then let the incarnation-addressed stop reject same-id B. Rejected split cleanup must address the spawned incarnation and preserve a same-id replacement; an unconfirmed exact split-leaf stop must retain the whole tab without invoking renderer teardown. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Exact shutdown must reach local, daemon, and SSH/relay process owners, refuse missing incarnation proof or old-owner capability before tab retirement, fence final cleanup, and preserve duplicate same-incarnation teardown coalescing. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/rpc/terminal-tab-close-expectation.test.ts src/main/window/terminal-tab-close-request-relay.test.ts src/renderer/src/components/terminal/terminal-tab-close-expectation.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts",
"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/ipc/pty-daemon-controller-teardown.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-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",
"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",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-daemon-controller-teardown.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-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/terminal-host.test.ts src/relay/pty-handler-shutdown-signals.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
@@ -3406,10 +3406,13 @@
],
"testFiles": [
"src/main/runtime/orca-runtime-terminal-close-incarnation-race.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/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",
@@ -3455,6 +3458,13 @@
"same-PTY-id replacement is distinguished by provider incarnation while new-PTY replacement is distinguished by the exact binding"
]
},
{
"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"
]
},
{
"file": "src/main/ipc/pty-daemon-controller-teardown.test.ts",
"assertions": [
@@ -3483,7 +3493,13 @@
{
"file": "src/main/providers/ssh-pty-provider.test.ts",
"assertions": [
"SSH exact close uses pty.shutdownIncarnation and never falls back to unsafe id-only shutdown when an old relay rejects the method"
"SSH exact close proves the relay capability before commit, uses pty.shutdownIncarnation, and never falls back to unsafe id-only shutdown"
]
},
{
"file": "src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts",
"assertions": [
"exact-stop capability follows the daemon adapter that owns the session instead of the current daemon"
]
},
{
@@ -3494,6 +3510,7 @@
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
"assertions": [
"final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout",
"missing incarnation proof and old-owner exact-stop capability retain the tab before renderer acknowledgement",
"post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive",
"renderer reload with the same provider-attested PTY incarnation preserves the exact handle",
"same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale",
@@ -3675,10 +3692,10 @@
"date": "2026-08-20",
"runner": "local",
"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/ipc/pty-daemon-controller-teardown.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-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",
"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.25,
"summary": "Eleven files and 204 tests passed on candidate d02f2f22da, including a controlled headless ID-only-kill barrier plus the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew, host duplicate teardown, SSH method skew, and relay process-owner rejection."
"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."
},
{
"date": "2026-08-20",
@@ -653,6 +653,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
it('fails closed before contacting a legacy daemon', async () => {
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 35 })
try {
expect(legacy.supportsIncarnationAddressedShutdown()).toBe(false)
expect(adapter.supportsIncarnationAddressedShutdown()).toBe(true)
await expect(
legacy.shutdown('legacy-session', {
immediate: true,
+4
View File
@@ -422,6 +422,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION
}
supportsIncarnationAddressedShutdown(): boolean {
return this.protocolVersion >= INCARNATION_ADDRESSED_SHUTDOWN_DAEMON_PROTOCOL_VERSION
}
providesAgentSessionOwnerListings(_ptyId: string): boolean {
return this.supportsAgentSessionClaims()
}
@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from 'vitest'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
function createAdapter(sessionId: string, supported: boolean): DaemonPtyAdapter {
const unsubscribe = () => {}
return {
listProcesses: vi.fn(async () => [{ id: sessionId, cwd: '', title: sessionId }]),
supportsIncarnationAddressedShutdown: () => supported,
onData: vi.fn(() => unsubscribe),
onExit: vi.fn(() => unsubscribe),
onDaemonIdentityChanged: vi.fn(() => unsubscribe)
} as unknown as DaemonPtyAdapter
}
describe('daemon PTY router incarnation shutdown capability', () => {
it('uses the adapter that owns the exact session', async () => {
const current = createAdapter('current-session', true)
const legacy = createAdapter('legacy-session', false)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
expect(router.supportsIncarnationAddressedShutdown('current-session')).toBe(true)
expect(router.supportsIncarnationAddressedShutdown('legacy-session')).toBe(false)
})
})
+6 -4
View File
@@ -68,10 +68,12 @@ export class DaemonPtyRouter implements IPtyProvider {
return adapter?.providesAgentSessionOwnerListings(ptyId) === true
}
supportsAgentSessionCreateOperations(): boolean {
// Fresh sessions always route to the current daemon; legacy adapters only retain old IDs.
return this.current.supportsAgentSessionCreateOperations()
}
// Fresh sessions always route to the current daemon; legacy adapters only retain old IDs.
supportsAgentSessionCreateOperations = (): boolean =>
this.current.supportsAgentSessionCreateOperations()
supportsIncarnationAddressedShutdown = (id: string): boolean =>
this.adapterFor(id).supportsIncarnationAddressedShutdown()
async attach(id: string): ReturnType<IPtyProvider['attach']> {
return await this.adapterFor(id).attach(id)
@@ -77,6 +77,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
recoverFreshSpawnRouting = (): Promise<boolean> => this.freshSpawns.recover()
supportsIncarnationAddressedShutdown = (id: string, opts?: { deadlineMs?: number }) =>
this.providerFor(id).supportsIncarnationAddressedShutdown?.(id, opts) ?? false
supportsGitCredentialGuardHost = (id?: string): boolean =>
this.freshSpawns.supportsGitGuardHost(id)
@@ -108,9 +110,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId)
)?.providesAgentSessionOwnerListings?.(ptyId) === true
write(id: string, data: string): boolean | void {
return this.providerFor(id).write(id, data)
}
write = (id: string, data: string): boolean | void => this.providerFor(id).write(id, data)
async writeWithSettlement(id: string, data: string): Promise<boolean> {
const provider = this.providerFor(id)
@@ -265,12 +265,18 @@ describe('registerPtyHandlers', () => {
onPtyExit: vi.fn(),
registerPty: vi.fn(),
onPtySpawned: vi.fn(),
markPtyStopRequested: vi.fn()
markPtyStopRequested: vi.fn(),
markPtyLivenessUnverifiable: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
spawn: (args: { cols: number; rows: number }) => Promise<{ id: string }>
retireRejectedPty: (
ptyId: string,
stopConfirmed: boolean,
expectedIncarnationId: string
) => void
stopAndWait: (ptyId: string, opts: { expectedIncarnationId: string }) => Promise<boolean>
}
@@ -283,7 +289,9 @@ describe('registerPtyHandlers', () => {
releaseShutdown()
await expect(stopping).resolves.toBe(false)
controller.retireRejectedPty('reused-pty', false, 'incarnation-a')
expect(liveIncarnation).toBe('incarnation-b')
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalledWith(
'reused-pty',
expect.anything(),
@@ -443,7 +451,7 @@ describe('registerPtyHandlers', () => {
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
})
it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => {
it('does not overwrite a failed kill verdict with unproven rejected retirement', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = {
markSshRemotePtyLease: vi.fn()
@@ -511,7 +519,7 @@ describe('registerPtyHandlers', () => {
)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
'remote-pty',
'a follow-up stop was issued but its outcome could not be verified'
'Multiplexer disposed'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalledWith('remote-pty', 0, undefined)
+8 -8
View File
@@ -63,7 +63,11 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
controller: {
kill: (ptyId: string) => boolean
listProcesses: (connectionId?: string | null) => Promise<{ id: string }[]>
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
retireRejectedPty: (
ptyId: string,
stopConfirmed: boolean,
expectedIncarnationId?: string
) => void
stopAndWait: (ptyId: string, opts?: { deadlineMs?: number }) => Promise<boolean>
}
runtime: {
@@ -131,19 +135,15 @@ describe('stopping a PTY whose SSH provider is unregistered', () => {
}
})
it('retires a rejected split without asserting an unconfirmed exit', () => {
it('refuses rejected split retirement without incarnation proof', () => {
const ptyId = 'ssh-rejected-split'
setPtyOwnership(ptyId, 'ssh-dropped')
const { controller, runtime } = installController()
try {
controller.retireRejectedPty(ptyId, false)
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
ptyId,
'a follow-up stop was issued but its outcome could not be verified'
)
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
expect(runtime.onPtyExit).not.toHaveBeenCalledWith(ptyId, 0, expect.anything())
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
expect(runtime.onPtyExit).not.toHaveBeenCalled()
} finally {
deletePtyOwnership(ptyId)
}
+43 -9
View File
@@ -5703,21 +5703,18 @@ export function registerPtyHandlers(
}
return killWithCurrentProvider()
},
retireRejectedPty: (ptyId, stopConfirmed) => {
rememberRetiredRejectedPty(ptyId)
retireRejectedPty: (ptyId, stopConfirmed, expectedIncarnationId) => {
if (!expectedIncarnationId || ptyIncarnationById.get(ptyId) !== expectedIncarnationId) {
return
}
if (!stopConfirmed) {
runtime?.markPtyLivenessUnverifiable?.(
ptyId,
'a follow-up stop was issued but its outcome could not be verified'
)
if (!ptyOwnership.has(ptyId)) {
return
}
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: -1 })
return
}
rememberRetiredRejectedPty(ptyId)
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
// renderer; repeating that double-fires the exit IPC.
if (!ptyOwnership.has(ptyId)) {
@@ -5727,7 +5724,11 @@ export function registerPtyHandlers(
let connectionId: string | null | undefined = ptyOwnership.get(ptyId)
const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null
connectionId ??= parsedSshId?.connectionId
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
const finished = finishExpectedPtyShutdown(ptyId, connectionId, store, expectedIncarnationId)
if (!finished.finished) {
return
}
const { incarnationId } = finished
runtime?.onPtyExit(ptyId, 0, incarnationId)
rememberSyntheticKillExit(ptyId)
sendPtyExitToRenderer({ id: ptyId, code: 0 })
@@ -5874,6 +5875,39 @@ export function registerPtyHandlers(
}
return true
},
supportsIncarnationAddressedStop: async (ptyId, opts) => {
const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId ?? null
const startupPromise = getLocalPtyProviderStartupPromise(connectionId)
if (startupPromise) {
const deadlineMs = opts?.deadlineMs
if (deadlineMs !== undefined) {
const ready = await Promise.race([
startupPromise.then(
() => true,
() => false
),
delay(Math.max(1, deadlineMs - Date.now())).then(() => false)
])
if (!ready) {
return false
}
} else {
try {
await startupPromise
} catch {
return false
}
}
}
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(ptyId)
const supported =
(await provider?.supportsIncarnationAddressedShutdown?.(ptyId, opts)) === true
return (
supported &&
opts?.expectedIncarnationId !== undefined &&
ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId
)
},
getForegroundProcess: async (ptyId) => {
try {
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
+4
View File
@@ -542,6 +542,10 @@ export type LocalPtyProviderOptions = {
}
export class LocalPtyProvider implements IPtyProvider {
supportsIncarnationAddressedShutdown(): boolean {
return true
}
private opts: LocalPtyProviderOptions
constructor(opts: LocalPtyProviderOptions = {}) {
@@ -191,6 +191,11 @@ export type IPtyProvider = {
*/
getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null>
supportsIncarnationAddressedShutdown?: (
id: string,
opts?: { deadlineMs?: number }
) => boolean | Promise<boolean>
// Why: deadlineMs (absolute epoch ms) bounds the underlying RPCs so destructive
// teardown fails fast inside its sweep budget instead of tripping the outer sweep
// deadline; each RPC leaf converts to a relative timeout when it actually issues.
@@ -0,0 +1,36 @@
import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../../shared/pty-incarnation'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
export class SshIncarnationShutdownCapability {
private probe: Promise<boolean> | null = null
constructor(private readonly mux: SshChannelMultiplexer) {}
async supports(opts: { deadlineMs?: number } = {}): Promise<boolean> {
const probe =
this.probe ??
this.mux
.request(
'pty.getCapabilities',
undefined,
opts.deadlineMs === undefined
? undefined
: { timeoutMs: Math.max(1, opts.deadlineMs - Date.now()) }
)
.then(
(result) =>
(result as { incarnationAddressedShutdownVersion?: unknown })
.incarnationAddressedShutdownVersion === PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION
)
// Why: relay upgrades replace the connection/provider, so a negative result is stable here.
this.probe = probe
try {
return await probe
} catch {
if (this.probe === probe) {
this.probe = null
}
return false
}
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshPtyProvider } from './ssh-pty-provider'
import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority'
import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../../shared/pty-incarnation'
import {
createMockMux,
expectRequest,
@@ -181,6 +182,33 @@ describe('SshPtyProvider', () => {
)
})
it('proves incarnation-addressed shutdown before a close commit', async () => {
mux.request.mockResolvedValue({
incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION
})
await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(true)
expectRequest(mux.request, 'pty.getCapabilities', undefined, undefined)
})
it('fails closed when an old relay omits incarnation-addressed shutdown', async () => {
mux.request.mockResolvedValue({})
await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false)
await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false)
expect(mux.request).toHaveBeenCalledOnce()
})
it('retries incarnation capability after a transient relay failure', async () => {
mux.request.mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({
incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION
})
await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false)
await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(true)
expect(mux.request).toHaveBeenCalledTimes(2)
})
it('does not fall back to an unsafe shutdown on an old relay', async () => {
mux.request.mockRejectedValueOnce(new Error('Method not found: pty.shutdownIncarnation'))
+6
View File
@@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities'
import type { PtyProcessInspection } from './pty-process-inspection'
import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write'
import { SshIncarnationShutdownCapability } from './ssh-incarnation-shutdown-capability'
// Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch.
function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined {
@@ -36,6 +37,7 @@ export class SshPtyProvider implements IPtyProvider {
private livePtyIds = new Set<string>()
readonly getAppliedSize: NonNullable<IPtyProvider['getAppliedSize']>
private readonly agentSessionCapabilities: SshAgentSessionCapabilities
private readonly incarnationShutdownCapability: SshIncarnationShutdownCapability
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
private readonly outputState: SshPtyProviderOutputState
@@ -51,6 +53,7 @@ export class SshPtyProvider implements IPtyProvider {
this.connectionId = connectionId
this.mux = mux
this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux)
this.incarnationShutdownCapability = new SshIncarnationShutdownCapability(mux)
this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId)
this.outputState = new SshPtyProviderOutputState(providerGeneration, {
@@ -201,6 +204,9 @@ export class SshPtyProvider implements IPtyProvider {
return writeToSshPtyWithSettlement(this.mux, this.toRelayPtyId(id), data)
}
supportsIncarnationAddressedShutdown = (_id: string, opts: { deadlineMs?: number } = {}) =>
this.incarnationShutdownCapability.supports(opts)
resize(id: string, cols: number, rows: number): void {
this.mux.notify('pty.resize', { id: this.toRelayPtyId(id), cols, rows })
}
@@ -247,8 +247,8 @@ describe('STA-4603/STA-4536 exit provenance', () => {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
stopAndWait: async (ptyId: string) => {
runtime.onPtyExit(ptyId, 0)
stopAndWait: async (ptyId: string, opts) => {
runtime.onPtyExit(ptyId, 0, opts?.expectedIncarnationId)
return true
}
})
@@ -16,7 +16,10 @@ const SIBLING_PTY_ID = 'pty-close-continuity-sibling'
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444'
function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
function makeSession(
ptyId = PTY_ID,
incarnationId: string | null = INCARNATION_ID
): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
@@ -41,9 +44,9 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
}
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID
}
terminalPtyIncarnationsByPaneKey: incarnationId
? { [makePaneKey(TAB_ID, LEAF_ID)]: incarnationId }
: {}
}
}
@@ -56,12 +59,19 @@ function makeDeferred() {
}
function createHarness(
options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {}
options: {
ptyId?: string
incarnationId?: string | null
publishMobileSurface?: boolean
registerPtyBacked?: boolean
} = {}
) {
const ptyId = options.ptyId ?? PTY_ID
let session = makeSession(ptyId)
const initialIncarnationId =
options.incarnationId === undefined ? INCARNATION_ID : options.incarnationId
let session = makeSession(ptyId, initialIncarnationId)
let sessionAvailable = true
let incarnationId = INCARNATION_ID
let incarnationId = initialIncarnationId
let includeSiblingPty = false
const repo = {
id: REPO_ID,
@@ -96,10 +106,12 @@ function createHarness(
}
return verifiedStopResult
})
let exactStopSupported = true
const supportsIncarnationAddressedStop = vi.fn(async () => exactStopSupported)
const listProcesses = vi.fn(async () => [
{
id: ptyId,
incarnationId,
...(incarnationId ? { incarnationId } : {}),
cwd: WORKTREE_PATH,
title: 'Fixture shell'
},
@@ -120,6 +132,7 @@ function createHarness(
write: () => true,
kill,
stopAndWait,
supportsIncarnationAddressedStop,
listProcesses,
getForegroundProcess: async () => null
})
@@ -243,7 +256,7 @@ function createHarness(
runtime.registerPty(ptyId, WORKTREE_ID, null, {
tabId: TAB_ID,
leafId: LEAF_ID,
incarnationId: INCARNATION_ID
...(initialIncarnationId ? { incarnationId: initialIncarnationId } : {})
})
}
syncFixtureGraph()
@@ -254,6 +267,7 @@ function createHarness(
closeTerminalTab,
kill,
stopAndWait,
supportsIncarnationAddressedStop,
syncEmptyGraph,
syncFixtureGraph,
syncFixtureTabWithoutLeaf,
@@ -276,6 +290,9 @@ function createHarness(
setVerifiedStopResult: (result: boolean | Error) => {
verifiedStopResult = result
},
setExactStopSupported: (supported: boolean) => {
exactStopSupported = supported
},
replaceIncarnation: (next: string) => {
incarnationId = next
}
@@ -329,6 +346,33 @@ describe('terminal close and handle incarnation continuity', () => {
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('retains the tab when the addressed PTY has no incarnation proof', async () => {
const harness = createHarness({ incarnationId: null })
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow(
'terminal_incarnation_fence_unavailable'
)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.stopAndWait).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('retains the tab when its process owner cannot stop by incarnation', async () => {
const harness = createHarness()
harness.setExactStopSupported(false)
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow(
'terminal_incarnation_fence_unavailable'
)
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.stopAndWait).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('requests a stop for every live tab PTY after retirement when the renderer graph is stale', async () => {
const harness = createHarness()
harness.setVerifiedStopResult(true)
@@ -363,6 +407,10 @@ describe('terminal close and handle incarnation continuity', () => {
const closing = harness.runtime.closeTerminal(handle)
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
expect(harness.supportsIncarnationAddressedStop).toHaveBeenCalledWith(PTY_ID, {
deadlineMs: expect.any(Number),
expectedIncarnationId: INCARNATION_ID
})
expect(harness.stopAndWait).not.toHaveBeenCalled()
harness.retirePersistedTab()
@@ -415,6 +463,29 @@ describe('terminal close and handle incarnation continuity', () => {
expect(harness.kill).not.toHaveBeenCalled()
})
it('retains a split tab when exact leaf stop is unconfirmed', async () => {
const harness = createHarness()
harness.syncSplitFixtureGraph()
harness.setVerifiedStopResult(false)
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
(candidate) => candidate.ptyId === PTY_ID
)!
await expect(harness.runtime.closeTerminal(terminal.handle)).resolves.toMatchObject({
ptyKilled: false,
ptyStopVerdict: 'unverifiable'
})
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
deadlineMs: expect.any(Number),
expectedIncarnationId: INCARNATION_ID
})
expect(harness.closeTerminal).not.toHaveBeenCalled()
expect(harness.closeTerminalTab).not.toHaveBeenCalled()
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
})
it('leaves a confirmed kill receipt free of any stop verdict', async () => {
const harness = createHarness()
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
@@ -12,6 +12,8 @@ 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 SPLIT_INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
const REPLACEMENT_SPLIT_INCARNATION_ID = '33333333-3333-4333-8333-333333333333'
function sourceLayout(): TerminalLayoutSnapshot {
return {
@@ -76,6 +78,7 @@ function createHarness(
connectionId?: string | null
deferReveal?: boolean
deferSpawn?: boolean
deferStop?: boolean
includePairedSnapshot?: boolean
rendererMounted?: boolean
sourceIncarnationId?: string
@@ -106,18 +109,26 @@ function createHarness(
},
persistPtyBinding: () => true
}
let resolveSpawn: ((result: { id: string }) => void) | undefined
let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined
const spawn = options.deferSpawn
? vi.fn(
() =>
new Promise<{ id: string }>((resolve) => {
new Promise<{ id: string; incarnationId: string }>((resolve) => {
resolveSpawn = resolve
})
)
: vi.fn(async () => ({ id: SPLIT_PTY_ID }))
: vi.fn(async () => ({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID }))
const kill = vi.fn(() => true)
const retireRejectedPty = vi.fn()
const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true)
let resolveStop: ((stopped: boolean) => void) | undefined
const stopAndWait = options.deferStop
? vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveStop = resolve
})
)
: vi.fn(async () => options.stopAndWaitResult ?? true)
let resolveReveal: ((result: { tabId: string }) => void) | undefined
const revealTerminalSession = options.deferReveal
? vi.fn(
@@ -142,7 +153,7 @@ function createHarness(
write: () => true,
kill,
retireRejectedPty,
...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}),
...(options.stopAndWaitResult !== undefined || options.deferStop ? { stopAndWait } : {}),
getForegroundProcess: async () => null
})
runtime.setNotifier({ revealTerminalSession } as never)
@@ -181,7 +192,7 @@ function createHarness(
const internals = runtime as unknown as {
issuePtyHandle: (pty: unknown) => string
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
ptysById: Map<string, unknown>
ptysById: Map<string, { incarnationId?: string }>
}
const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID))
return {
@@ -211,7 +222,13 @@ function createHarness(
}
},
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID })
replaceSplitIncarnation: () =>
runtime.onPtySpawned(SPLIT_PTY_ID, REPLACEMENT_SPLIT_INCARNATION_ID, {
awaitsRegistration: false
}),
getSplitIncarnation: () => internals.ptysById.get(SPLIT_PTY_ID)?.incarnationId,
resolveStop: (stopped: boolean) => resolveStop?.(stopped),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
}
}
@@ -300,7 +317,7 @@ describe('remote runtime terminal split authority', () => {
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.requestedSessionHostIds).toContain(expectedHostId)
}
)
@@ -325,7 +342,7 @@ describe('remote runtime terminal split authority', () => {
harness.resolveSpawn()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.revealTerminalSession).not.toHaveBeenCalled()
})
@@ -345,10 +362,13 @@ describe('remote runtime terminal split authority', () => {
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
expect.objectContaining({
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true, SPLIT_INCARNATION_ID)
})
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
@@ -371,22 +391,57 @@ describe('remote runtime terminal split authority', () => {
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(
SPLIT_PTY_ID,
expect.objectContaining({ deadlineMs: expect.any(Number) })
expect.objectContaining({
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
})
it('preserves the split error when kill and retirement throw', async () => {
it('addresses rejected split cleanup to the spawned incarnation after same-ID reuse', async () => {
const harness = createHarness(false, {
deferReveal: true,
deferStop: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before'
})
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' })
await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce())
harness.replaceSourceIncarnation('projected-after')
harness.resolveReveal()
await vi.waitFor(() => expect(harness.stopAndWait).toHaveBeenCalledOnce())
harness.replaceSplitIncarnation()
harness.resolveStop(false)
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.stopAndWait).toHaveBeenCalledWith(SPLIT_PTY_ID, {
deadlineMs: expect.any(Number),
expectedIncarnationId: SPLIT_INCARNATION_ID
})
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
expect(harness.getSplitIncarnation()).toBe(REPLACEMENT_SPLIT_INCARNATION_ID)
expect(harness.kill).not.toHaveBeenCalled()
})
it('preserves the split error when exact retirement throws', async () => {
const harness = createHarness(false, {
deferReveal: true,
includePairedSnapshot: true,
sourceIncarnationId: 'projected-before',
stopAndWaitResult: false
})
harness.kill.mockImplementation(() => {
throw new Error('kill failed')
})
harness.retireRejectedPty.mockImplementation(() => {
throw new Error('retire failed')
})
@@ -397,7 +452,11 @@ describe('remote runtime terminal split authority', () => {
harness.resolveReveal()
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
expect(harness.kill).not.toHaveBeenCalled()
expect(harness.retireRejectedPty).toHaveBeenCalledWith(
SPLIT_PTY_ID,
false,
SPLIT_INCARNATION_ID
)
})
})
+54 -13
View File
@@ -15476,19 +15476,23 @@ describe('OrcaRuntimeService', () => {
}
})
)
let resolveSpawn!: (result: { id: string }) => void
let resolveSpawn!: (result: { id: string; incarnationId: string }) => void
const spawn = vi.fn(
(_args: unknown) =>
new Promise<{ id: string }>((resolve) => {
new Promise<{ id: string; incarnationId: string }>((resolve) => {
resolveSpawn = resolve
})
)
const kill = vi.fn(() => false)
const stopAndWait = vi.fn(async () => true)
const retireRejectedPty = vi.fn()
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
spawn,
write: () => true,
kill,
stopAndWait,
retireRejectedPty,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
@@ -15506,7 +15510,7 @@ describe('OrcaRuntimeService', () => {
setSession(getDefaultWorkspaceSession())
runtimeStore.persistPtyBinding.mockReturnValue(false)
resolveSpawn({ id: 'rejected-split-pty' })
resolveSpawn({ id: 'rejected-split-pty', incarnationId: 'rejected-split-incarnation' })
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(spawn.mock.calls[0]?.[0]).toMatchObject({
@@ -15518,28 +15522,44 @@ describe('OrcaRuntimeService', () => {
ptyId
}
})
expect(kill).toHaveBeenCalledWith('rejected-split-pty')
expect(stopAndWait).toHaveBeenCalledWith('rejected-split-pty', {
deadlineMs: expect.any(Number),
expectedIncarnationId: 'rejected-split-incarnation'
})
expect(retireRejectedPty).toHaveBeenCalledWith(
'rejected-split-pty',
true,
'rejected-split-incarnation'
)
expect(kill).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toBeUndefined()
expect(getSession().terminalLayoutsByTabId[tabId]).toBeUndefined()
})
it('rejects a projected split retired during spawn before publishing the new pane', async () => {
let resolveSplitSpawn!: (result: { id: string }) => void
let resolveSplitSpawn!: (result: { id: string; incarnationId: string }) => void
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'projected-source-pty' })
.mockResolvedValueOnce({
id: 'projected-source-pty',
incarnationId: 'projected-source-incarnation'
})
.mockImplementationOnce(
() =>
new Promise<{ id: string }>((resolve) => {
new Promise<{ id: string; incarnationId: string }>((resolve) => {
resolveSplitSpawn = resolve
})
)
const kill = vi.fn(() => true)
const stopAndWait = vi.fn(async () => true)
const retireRejectedPty = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill,
stopAndWait,
retireRejectedPty,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
@@ -15549,10 +15569,22 @@ describe('OrcaRuntimeService', () => {
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID)
resolveSplitSpawn({ id: 'retired-projected-split-pty' })
resolveSplitSpawn({
id: 'retired-projected-split-pty',
incarnationId: 'retired-projected-split-incarnation'
})
await expect(split).rejects.toThrow('terminal_split_source_not_found')
expect(kill).toHaveBeenCalledWith('retired-projected-split-pty')
expect(stopAndWait).toHaveBeenCalledWith('retired-projected-split-pty', {
deadlineMs: expect.any(Number),
expectedIncarnationId: 'retired-projected-split-incarnation'
})
expect(retireRejectedPty).toHaveBeenCalledWith(
'retired-projected-split-pty',
true,
'retired-projected-split-incarnation'
)
expect(kill).not.toHaveBeenCalled()
expect(runtime['mobileSessionTabsByWorktree'].has(TEST_WORKTREE_ID)).toBe(false)
})
@@ -29538,14 +29570,16 @@ describe('OrcaRuntimeService', () => {
const flushOrThrow = vi.fn()
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'headless-left' })
.mockResolvedValueOnce({ id: 'headless-right' })
.mockResolvedValueOnce({ id: 'headless-left', incarnationId: 'headless-left-incarnation' })
.mockResolvedValueOnce({ id: 'headless-right', incarnationId: 'headless-right-incarnation' })
const kill = vi.fn(() => true)
const stopAndWait = vi.fn(async () => true)
const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never)
runtime.setPtyController({
spawn,
write: () => true,
kill,
stopAndWait,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
@@ -29557,8 +29591,15 @@ describe('OrcaRuntimeService', () => {
await runtime.closeTerminalTab(terminal.handle)
expect(kill).toHaveBeenCalledWith('headless-left')
expect(kill).toHaveBeenCalledWith('headless-right')
expect(stopAndWait).toHaveBeenCalledWith(
'headless-left',
expect.objectContaining({ expectedIncarnationId: 'headless-left-incarnation' })
)
expect(stopAndWait).toHaveBeenCalledWith(
'headless-right',
expect.objectContaining({ expectedIncarnationId: 'headless-right-incarnation' })
)
expect(kill).not.toHaveBeenCalled()
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined()
expect(flushOrThrow).toHaveBeenCalledTimes(1)
+84 -28
View File
@@ -1911,7 +1911,11 @@ type RuntimePtyController = {
* False on doubt (absent session, SSH-scoped id, non-daemon provider). */
attach?(ptyId: string): Promise<boolean>
kill(ptyId: string): boolean
retireRejectedPty?(ptyId: string, stopConfirmed: boolean): void
retireRejectedPty?(
ptyId: string,
stopConfirmed: boolean,
expectedIncarnationId?: PtyIncarnationId
): void
stopAndWait?(
ptyId: string,
opts?: {
@@ -1920,6 +1924,10 @@ type RuntimePtyController = {
expectedIncarnationId?: PtyIncarnationId
}
): Promise<boolean>
supportsIncarnationAddressedStop?(
ptyId: string,
opts?: { deadlineMs?: number; expectedIncarnationId?: PtyIncarnationId }
): boolean | Promise<boolean>
markReversibleStops?(ptyIds: readonly string[]): () => void
getCwd?(ptyId: string): Promise<string | null>
getForegroundProcess(ptyId: string): Promise<string | null>
@@ -29615,6 +29623,12 @@ export class OrcaRuntimeService {
tabId: string
): { ptyId: string; incarnationId?: string }[] {
const targets = new Map<string, string | undefined>()
const persistedPtyIds = new Set(
Object.values(
this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[tabId]
?.ptyIdsByLeafId ?? {}
)
)
const remember = (ptyId: string, incarnationId?: string): void => {
if (!targets.has(ptyId) || incarnationId !== undefined) {
targets.set(ptyId, incarnationId)
@@ -29622,17 +29636,23 @@ export class OrcaRuntimeService {
}
for (const pty of this.ptysById.values()) {
if (pty.connected && pty.worktreeId === worktreeId && pty.tabId === tabId) {
remember(pty.ptyId, pty.incarnationId ?? undefined)
if (pty.incarnationId || persistedPtyIds.has(pty.ptyId)) {
remember(pty.ptyId, pty.incarnationId ?? undefined)
}
}
}
for (const leaf of this.leaves.values()) {
if (leaf.worktreeId === worktreeId && leaf.tabId === tabId && leaf.ptyId) {
remember(
leaf.ptyId,
this.ptysById.get(leaf.ptyId)?.incarnationId ??
this.getPersistedTerminalIncarnation(worktreeId, tabId, leaf.leafId, leaf.ptyId) ??
undefined
const livePty = this.ptysById.get(leaf.ptyId)
const persistedIncarnation = this.getPersistedTerminalIncarnation(
worktreeId,
tabId,
leaf.leafId,
leaf.ptyId
)
if (livePty?.incarnationId || persistedPtyIds.has(leaf.ptyId)) {
remember(leaf.ptyId, livePty?.incarnationId ?? persistedIncarnation ?? undefined)
}
}
}
return [...targets].map(([ptyId, incarnationId]) => ({
@@ -29693,6 +29713,32 @@ export class OrcaRuntimeService {
return addressedPtyStopped
}
private async assertExplicitCloseTargetsCanStopExactly(
targets: readonly { ptyId: string; incarnationId?: string }[],
addressedPtyId: string
): Promise<void> {
if (
!this.ptyController?.stopAndWait ||
!targets.some(
(target) => target.ptyId === addressedPtyId && target.incarnationId !== undefined
) ||
targets.some((target) => !target.incarnationId)
) {
throw new Error('terminal_incarnation_fence_unavailable')
}
const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS
for (const { ptyId, incarnationId } of targets) {
if (
(await this.ptyController.supportsIncarnationAddressedStop?.(ptyId, {
deadlineMs,
expectedIncarnationId: incarnationId
})) === false
) {
throw new Error('terminal_incarnation_fence_unavailable')
}
}
}
private buildTerminalTabCloseExpectation(
handle: string,
ptyId: string,
@@ -29969,6 +30015,7 @@ export class OrcaRuntimeService {
: this.countLeavesInTab(tabId)
if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) {
const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId)
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId)
try {
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, {
localPtyTeardownOwnedExternally: true,
@@ -29989,6 +30036,7 @@ export class OrcaRuntimeService {
}
if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) {
const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId)
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId)
await this.notifier.closeTerminalTab(tabId, {
localPtyTeardownOwnedExternally: true,
expectedTerminal
@@ -30000,23 +30048,26 @@ export class OrcaRuntimeService {
)
return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled)
}
const ptyIdsToKill = [
{
ptyId: pty.pty.ptyId,
...(expectedTerminal.incarnationId
? { incarnationId: expectedTerminal.incarnationId }
: {})
}
]
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId)
const ptyKilled = await this.stopExplicitlyClosedTabPtys(
[
{
ptyId: pty.pty.ptyId,
...(expectedTerminal.incarnationId
? { incarnationId: expectedTerminal.incarnationId }
: {})
}
],
ptyIdsToKill,
pty.pty.ptyId,
expectedTerminal
)
if (!ptyKilled || siblingCount <= 1) {
if (siblingCount <= 1) {
if (surface) {
// Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling.
try {
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, {
localPtyTeardownOwnedExternally: true,
expectedTerminal
})
} catch (error) {
@@ -30049,6 +30100,9 @@ export class OrcaRuntimeService {
}
]
: []
if (leaf.ptyId) {
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, leaf.ptyId)
}
if (siblingCount <= 1 && this.notifier?.closeTerminalTab) {
await this.notifier.closeTerminalTab(leaf.tabId, {
localPtyTeardownOwnedExternally: true,
@@ -30058,7 +30112,7 @@ export class OrcaRuntimeService {
const ptyKilled = leaf.ptyId
? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId, expectedTerminal)
: false
if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) {
if (siblingCount <= 1 && !this.notifier?.closeTerminalTab) {
this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId)
}
return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, ptyKilled)
@@ -30110,6 +30164,7 @@ export class OrcaRuntimeService {
leafId
)
const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId)
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId)
await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, {
reason: 'user',
localPtyTeardownOwnedExternally: true,
@@ -30127,6 +30182,9 @@ export class OrcaRuntimeService {
const { leaf } = this.getLiveLeafForHandle(handle)
const expectedTerminal = this.buildLeafTerminalTabCloseExpectation(handle, leaf)
const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(leaf.worktreeId, leaf.tabId)
if (leaf.ptyId) {
await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, leaf.ptyId)
}
await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, {
reason: 'user',
localPtyTeardownOwnedExternally: true,
@@ -30257,7 +30315,11 @@ export class OrcaRuntimeService {
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId)
this.registerPty(result.id, workspace.id, workspace.connectionId, {
tabId: parentTabId,
leafId,
...(result.incarnationId ? { incarnationId: result.incarnationId } : {})
})
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
if (createdPty) {
createdPty.tabId = parentTabId
@@ -30334,20 +30396,14 @@ export class OrcaRuntimeService {
try {
stopped =
(await this.ptyController.stopAndWait?.(result.id, {
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS
deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS,
...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {})
})) ?? false
} catch {
// Best-effort fallback below preserves the original split authority error.
}
if (!stopped) {
try {
this.ptyController.kill(result.id)
} catch {
// Best-effort cleanup; retirement below still runs and the original error still throws.
}
// Exact cleanup remains unverifiable; preserve the original split authority error.
}
try {
this.ptyController.retireRejectedPty?.(result.id, stopped)
this.ptyController.retireRejectedPty?.(result.id, stopped, result.incarnationId)
} catch {
// Best-effort cleanup; preserve the original split authority error.
}
+3 -1
View File
@@ -58,6 +58,7 @@ import {
parsePtyStartupIngressIntent,
type PtyIngressEmission
} from '../shared/pty-startup-ingress'
import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../shared/pty-incarnation'
import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend'
import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer'
import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion'
@@ -917,7 +918,8 @@ export class PtyHandler {
this.dispatcher.onRequest('pty.getCapabilities', async () => ({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION
agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION,
incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION
}))
this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses())
this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell())
@@ -3,9 +3,9 @@
"appId": "com.stablyai.orca",
"stateSchemaVersion": 1,
"readableStateSchemaVersions": [1],
"daemonProtocolVersion": 35,
"daemonProtocolVersion": 36,
"attachableDaemonProtocolVersions": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
]
}
@@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = {
appId: 'com.stablyai.orca',
stateSchemaVersion: 1,
readableStateSchemaVersions: [1],
daemonProtocolVersion: 35,
daemonProtocolVersion: 36,
attachableDaemonProtocolVersions: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35
27, 28, 29, 30, 31, 32, 33, 34, 35, 36
]
} as const
+2
View File
@@ -1,5 +1,7 @@
export type PtyIncarnationId = string
export const PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION = 1
export function isPtyIncarnationId(value: unknown): value is PtyIncarnationId {
return typeof value === 'string' && value.length > 0 && value.length <= 128
}