mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix(terminal): fence remaining exact-close cleanup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user