From fa58ba9ecd7643c7880c3bd2bfe8d794a09c157a Mon Sep 17 00:00:00 2001 From: m4air Date: Wed, 16 Sep 2026 02:45:35 -0700 Subject: [PATCH] fix(runtime): preserve observed exit during explicit terminal close --- .../terminal-close-observed-exit/README.md | 37 ++ .../reproduce.mjs | 132 +++++++ .../terminal-close-observed-exit/results.json | 359 ++++++++++++++++++ ...runtime-stop-explicitly-closed-tab-ptys.ts | 10 + ...rminal-close-observed-exit-test-fixture.ts | 158 ++++++++ .../terminal-close-observed-exit.test.ts | 61 +++ 6 files changed, 757 insertions(+) create mode 100644 docs/audits/terminal-close-observed-exit/README.md create mode 100644 docs/audits/terminal-close-observed-exit/reproduce.mjs create mode 100644 docs/audits/terminal-close-observed-exit/results.json create mode 100644 src/main/runtime/terminal-close-observed-exit-test-fixture.ts create mode 100644 src/main/runtime/terminal-close-observed-exit.test.ts diff --git a/docs/audits/terminal-close-observed-exit/README.md b/docs/audits/terminal-close-observed-exit/README.md new file mode 100644 index 00000000000..f4d92f8ac55 --- /dev/null +++ b/docs/audits/terminal-close-observed-exit/README.md @@ -0,0 +1,37 @@ +# Preserve an observed exit during explicit terminal close + +An explicit close can receive the target daemon's physical EXIT, then fail its aggregate verification because another preserved daemon is unavailable. The close method used to invoke the fallback kill even though the runtime already held an `exited` verdict. That redundant request emitted a synthetic `-1`, replacing `operator_close` with `unknown/stop_unverified` and sending a second renderer exit notification. + +The fix captures the stamped PTY incarnation before awaiting the stop. A false stop result is accepted only when the same incarnation remains current and the runtime already has an `exited` verdict. It does not create an exit certificate from an empty inventory or transport failure. + +## Reproduce + +From the checkout, with dependencies already installed: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-close-observed-exit/reproduce.mjs /tmp/terminal-close-observed-exit.json +ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/runtime/terminal-close-observed-exit.test.ts +``` + +The script runs eight scenarios before and after the change, reversing only the new capture and guard for the before variant. It uses the actual runtime close method, runtime controller, daemon router, and two real daemon socket endpoints. The subprocess itself is controlled by the existing test harness. The script uses temporary configuration files, checks the expected outcomes, records source hashes, and removes its temporary directory. It does not install dependencies, launch a UI, or alter the checkout. `results.json` preserves the recorded result; use a separate output path when rerunning. + +| Scenario | Before | After | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Target physical EXIT received; unrelated daemon unavailable | Close returns false; one redundant kill; cause overwritten; two renderer exit notifications | Close returns true; no redundant kill; `operator_close` preserved; one renderer exit notification | +| Healthy aggregate inventory, delayed physical EXIT | Close succeeds | Unchanged | +| Target socket paused; unrelated daemon unavailable | Close remains unverifiable despite target's empty inventory | Unchanged | +| Same stamped incarnation already exited | Redundant fallback kill | Existing exit accepted | +| Same raw ID registered with a newer incarnation | Old certificate rejected | Unchanged | +| Synthetic negative exit, no host exit certificate | Close remains unverifiable | Unchanged | +| Unstamped legacy session | Certificate not reused | Unchanged | +| Stop throws after exit | Catch records unverifiable | Unchanged | + +In all socket scenarios, the physical provider event and runtime exit listener settle once. The fixed observed-exit case has no headless model or title tracker retained. This proof measures lifecycle behavior, not retained heap bytes. + +## Dependency and incident limits + +This change is stacked on [#21000](https://github.com/stablyai/orca/pull/21000), branch `np-oom-scan-daemon-late-exit`, and reuses its actual daemon socket fixture and late physical-exit reconciliation. #21000 fixes final DATA arriving after a synthetic exit. This change prevents a redundant synthetic exit after a physical exit has already been accepted. The before variant is the current checkout with this narrow guard reversed, not a pristine historical build. + +The unconditional fallback and exit-cause assignment are present in the reported `v1.4.197` source (`orca-runtime-stop-explicitly-closed-tab-ptys.ts`, `orca-runtime-on-pty-exit.ts`). They explain a concrete way to get a failed close and `stop_unverified` despite a confirmed local exit. [#19018](https://github.com/stablyai/orca/issues/19018) does not establish that an unrelated preserved daemon was unavailable; this is a conditional explanation, not proof of the reporter's exact ordering. + +Generic inventory remains fail-closed. Exact-owner verification across daemon generations is separate work. This change does not solve a thrown stop, SSH loss of contact, unstamped identities, or all same-ID shutdown races. A missing diagnostics row remains insufficient evidence of process death. diff --git a/docs/audits/terminal-close-observed-exit/reproduce.mjs b/docs/audits/terminal-close-observed-exit/reproduce.mjs new file mode 100644 index 00000000000..ce8873d098d --- /dev/null +++ b/docs/audits/terminal-close-observed-exit/reproduce.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { startVitest } from 'vitest/node' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const sourcePath = 'src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts' +const fixturePath = 'src/main/runtime/terminal-close-observed-exit-test-fixture.ts' +const source = await readFile(join(root, sourcePath), 'utf8') +const capture = ' const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId\n' +const guard = ` // Preserve an observed exit when a broader inventory check could not finish. + if ( + !stopped && + expectedIncarnationId && + this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + ) { + stopped = true + } +` +assert(source.includes(capture) && source.includes(guard), 'Review the baseline transform.') +const baseline = source.replace(capture, '').replace(guard, '') +const scratch = await mkdtemp(join(tmpdir(), 'orca-observed-exit-proof-')) +const phases = [] +try { + for (const phase of ['before', 'after']) { + const testPath = join(scratch, `${phase}.test.ts`) + const outputPath = join(scratch, `${phase}.json`) + const configPath = join(scratch, `${phase}.config.mjs`) + await writeFile( + testPath, + ` +import { afterAll, it } from ${JSON.stringify(join(root, 'node_modules/vitest/dist/index.js'))} +import { writeFileSync } from 'node:fs' +import { runObservedExitSocketScenario, runObservedExitControl } from ${JSON.stringify(join(root, fixturePath))} +const sockets = [] +const controls = [] +for (const scenario of ['healthy', 'unrelated-endpoint-gone', 'physical-exit-observed']) { + it(scenario, async () => sockets.push(await runObservedExitSocketScenario(scenario))) +} +for (const control of ['same-incarnation', 'replacement', 'unverified', 'legacy-unstamped', 'throw-after-exit']) { + it(control, async () => controls.push(await runObservedExitControl(control))) +} +afterAll(() => writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({ sockets, controls }))) +` + ) + await writeFile( + configPath, + ` +import base from ${JSON.stringify(pathToFileURL(join(root, 'config/vitest.config.ts')).href)} +export default { + ...base, + plugins: [{ name: 'observed-exit-baseline', enforce: 'pre', transform(code, id) { + if (id.replaceAll('\\\\', '/').endsWith(${JSON.stringify(`/${sourcePath}`)})) return ${JSON.stringify(phase === 'before' ? baseline : source)} + } }], + test: { ...base.test, include: [${JSON.stringify(testPath)}], maxWorkers: 1, fileParallelism: false } +} +` + ) + const runner = await startVitest('test', [], { + root, + config: configPath, + watch: false, + reporters: ['dot'] + }) + assert(runner, 'Vitest did not start') + await runner.close() + const result = JSON.parse(await readFile(outputPath, 'utf8')) + assert.equal(result.sockets.length, 3) + assert.equal(result.controls.length, 5) + for (const row of result.sockets) { + const observed = row.scenario === 'physical-exit-observed' + const healthy = row.scenario === 'healthy' + assert.equal(row.close.ptyKilled, healthy || (observed && phase === 'after')) + assert.equal(row.fallbackKills, healthy || (observed && phase === 'after') ? 0 : 1) + assert.equal(row.targetInventoryCount, 0) + assert.equal(row.targetProbe, false) + assert.equal(row.routerProbe, healthy ? false : null) + assert.equal(row.settled.connected, false) + assert.equal(row.settled.headlessModelRetained, false) + assert.equal(row.settled.providerExitCount, 1) + assert.equal(row.settled.exitListenerCalls, 1) + if (observed) { + assert.deepEqual( + row.settled.exitCause, + phase === 'after' + ? { kind: 'operator_close' } + : { kind: 'unknown', reason: 'stop_unverified' } + ) + assert.equal(row.settled.rendererExitCount, phase === 'after' ? 1 : 2) + } + if (!healthy && !observed) { + assert.equal(row.close.ptyStopVerdict, 'unverifiable') + assert.equal(row.beforeStreamResume.providerExitCount, 0) + } + delete row.beforeStreamResume.incarnationId + delete row.settled.incarnationId + } + for (const row of result.controls) { + const accepts = phase === 'after' && row.scenario === 'same-incarnation' + assert.equal(row.stopped, accepts) + assert.equal(row.fallbackKills, accepts ? 0 : 1) + } + phases.push({ phase, ...result }) + } + const output = `${JSON.stringify( + { + sourceHashes: { + before: createHash('sha256').update(baseline).digest('hex'), + after: createHash('sha256').update(source).digest('hex'), + fixture: createHash('sha256') + .update(await readFile(join(root, fixturePath))) + .digest('hex') + }, + phases + }, + null, + 2 + )}\n` + if (process.argv[2]) { + await writeFile(resolve(process.argv[2]), output) + } + process.stdout.write(output) +} finally { + await rm(scratch, { recursive: true, force: true }) +} diff --git a/docs/audits/terminal-close-observed-exit/results.json b/docs/audits/terminal-close-observed-exit/results.json new file mode 100644 index 00000000000..b7f79553c9a --- /dev/null +++ b/docs/audits/terminal-close-observed-exit/results.json @@ -0,0 +1,359 @@ +{ + "sourceHashes": { + "before": "963a18c7811f9bb47c5308f795edfc7ed6f7b91ccfa5c415357c701679f4204b", + "after": "36ea86fcd71c37384af31ae9b8cf8f348e4435854f5faf4829f5fafb618c2c44", + "fixture": "e9df0f19562ef2f162a6262c5052dd0e52d7770b88dc852b599e858d99452b1d" + }, + "phases": [ + { + "phase": "before", + "sockets": [ + { + "scenario": "healthy", + "close": { + "ptyKilled": true, + "ptyStopVerdict": null + }, + "fallbackKills": 0, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": false, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 0, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + }, + { + "scenario": "unrelated-endpoint-gone", + "close": { + "ptyKilled": false, + "ptyStopVerdict": "unverifiable" + }, + "fallbackKills": 1, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": null, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "unknown", + "reason": "stop_unverified" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "unverifiable", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 0, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + }, + { + "scenario": "physical-exit-observed", + "close": { + "ptyKilled": false, + "ptyStopVerdict": null + }, + "fallbackKills": 1, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": null, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "unknown", + "reason": "stop_unverified" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 2, + "providerExitCount": 1, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "unknown", + "reason": "stop_unverified" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 2, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + } + ], + "controls": [ + { + "scenario": "same-incarnation", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "exited" + } + }, + { + "scenario": "replacement", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "a follow-up stop was issued but its outcome could not be verified" + } + }, + { + "scenario": "unverified", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "a follow-up stop was issued but its outcome could not be verified" + } + }, + { + "scenario": "legacy-unstamped", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "exited" + } + }, + { + "scenario": "throw-after-exit", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "unverified transport failure" + } + } + ] + }, + { + "phase": "after", + "sockets": [ + { + "scenario": "healthy", + "close": { + "ptyKilled": true, + "ptyStopVerdict": null + }, + "fallbackKills": 0, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": false, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 0, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + }, + { + "scenario": "unrelated-endpoint-gone", + "close": { + "ptyKilled": false, + "ptyStopVerdict": "unverifiable" + }, + "fallbackKills": 1, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": null, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "unknown", + "reason": "stop_unverified" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "unverifiable", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 0, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + }, + { + "scenario": "physical-exit-observed", + "close": { + "ptyKilled": true, + "ptyStopVerdict": null + }, + "fallbackKills": 0, + "targetInventoryCount": 0, + "targetProbe": false, + "routerProbe": null, + "beforeStreamResume": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + }, + "settled": { + "connected": false, + "exitCause": { + "kind": "operator_close" + }, + "headlessModelRetained": false, + "titleTrackerRetained": false, + "liveness": "exited", + "providerHasPty": false, + "hostInventoryCount": 0, + "deliveredData": [], + "rendererExitCount": 1, + "providerExitCount": 1, + "exitListenerCalls": 1 + } + } + ], + "controls": [ + { + "scenario": "same-incarnation", + "stopped": true, + "fallbackKills": 0, + "verdict": { + "status": "exited" + } + }, + { + "scenario": "replacement", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "a follow-up stop was issued but its outcome could not be verified" + } + }, + { + "scenario": "unverified", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "a follow-up stop was issued but its outcome could not be verified" + } + }, + { + "scenario": "legacy-unstamped", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "exited" + } + }, + { + "scenario": "throw-after-exit", + "stopped": false, + "fallbackKills": 1, + "verdict": { + "status": "unverifiable", + "reason": "unverified transport failure" + } + } + ] + } + ] +} diff --git a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts index 1433ecfdb0e..196845d09ef 100644 --- a/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts +++ b/src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts @@ -15,6 +15,7 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS for (const ptyId of ptyIds) { this.markPtyStopRequested(ptyId) + const expectedIncarnationId = this.ptysById.get(ptyId)?.incarnationId let stopped = false if (this.ptyController?.stopAndWait) { try { @@ -25,6 +26,15 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF error instanceof Error ? error.message : String(error) ) } + // Preserve an observed exit when a broader inventory check could not finish. + if ( + !stopped && + expectedIncarnationId && + this.ptysById.get(ptyId)?.incarnationId === expectedIncarnationId && + this.getPtyLivenessVerdict(ptyId)?.status === 'exited' + ) { + stopped = true + } if (!stopped) { const verdict = this.getPtyLivenessVerdict(ptyId) const providerAlreadyRetiredPty = diff --git a/src/main/runtime/terminal-close-observed-exit-test-fixture.ts b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts new file mode 100644 index 00000000000..468508f896d --- /dev/null +++ b/src/main/runtime/terminal-close-observed-exit-test-fixture.ts @@ -0,0 +1,158 @@ +import { rmSync } from 'node:fs' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import { + createMockSubprocess, + startDaemonAdapterHarness +} from '../daemon/daemon-pty-adapter-test-harness' +import { startLateExitHarness } from '../ipc/pty/daemon-late-exit-test-fixture' +import { bindProviderListeners } from '../ipc/pty/provider/bind-listeners' +import { finishPtyShutdown } from '../ipc/pty/provider/liveness' +import { setLocalPtyProvider } from '../ipc/pty/provider/registry' +import { shutdownProviderAndDetectExit } from '../ipc/pty/provider/shutdown-detect' +import type { PtyRuntimeControllerDeps } from '../ipc/pty/runtime/controller-deps' +import { + killPtyFromRuntimeController, + stopAndWaitPtyFromRuntimeController +} from '../ipc/pty/runtime/kill' +import { OrcaRuntimeService } from './orca-runtime' + +export type ObservedExitSocketScenario = + | 'healthy' + | 'unrelated-endpoint-gone' + | 'physical-exit-observed' + +export async function runObservedExitSocketScenario(scenario: ObservedExitSocketScenario) { + const harness = await startLateExitHarness() + const legacy = await startDaemonAdapterHarness(() => createMockSubprocess()) + const router = new DaemonPtyRouter({ current: harness.adapter, legacy: [legacy.adapter] }) + let fallbackKills = 0 + try { + await router.discoverLegacySessions() + setLocalPtyProvider(router) + bindProviderListeners(harness.session) + const ports = { + runtime: harness.runtime, + getLocalPtyProviderStartupPromise: () => undefined, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit, + sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer, + finishPtyShutdown, + retiredRejectedPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stop/kill read only these controller ports and optional store; spawn ports are unused. + const deps = ports as unknown as PtyRuntimeControllerDeps + harness.runtime.setPtyController({ + write: () => true, + getForegroundProcess: async () => null, + kill: (id) => { + fallbackKills++ + return killPtyFromRuntimeController(deps, id) + }, + stopAndWait: (id) => + stopAndWaitPtyFromRuntimeController(deps, id, { deadlineMs: Date.now() + 1_500 }) + }) + const list = await harness.runtime.listTerminals() + const terminal = list.terminals.find((entry) => entry.ptyId === harness.id) + if (!terminal) { + throw new Error('Fixture terminal missing') + } + if (scenario !== 'healthy') { + await legacy.server.shutdown() + } + if (scenario !== 'physical-exit-observed') { + harness.pauseStream() + } + const close = await harness.runtime.closeTerminal(terminal.handle) + const targetInventory = await harness.adapter.listProcesses() + const targetProbe = await harness.adapter.probePtyLiveness(harness.id) + const routerProbe = await router.probePtyLiveness(harness.id) + const beforeStreamResume = await harness.capture() + harness.resumeStream() + await harness.waitForExit() + const settled = await harness.capture() + return { + scenario, + close: { + ptyKilled: close.ptyKilled, + ptyStopVerdict: close.ptyStopVerdict ?? null + }, + fallbackKills, + targetInventoryCount: targetInventory.length, + targetProbe, + routerProbe, + beforeStreamResume, + settled + } + } finally { + router.disposeRouterOnly() + await harness.dispose() + legacy.adapter.dispose() + await legacy.server.shutdown() + rmSync(legacy.dir, { recursive: true, force: true }) + } +} + +const CONTROL_PTY_ID = 'repo::/tmp/observed-exit-control@@pty' +const WORKTREE_ID = 'repo::/tmp/observed-exit-control' +const FIRST_INCARNATION = '10000000-0000-4000-8000-000000000001' +const NEXT_INCARNATION = '10000000-0000-4000-8000-000000000002' +const BINDING = { + tabId: 'control-tab', + leafId: '10000000-0000-4000-8000-000000000004' +} + +class ObservedExitRuntime extends OrcaRuntimeService { + closeControl(): Promise { + return this.stopExplicitlyClosedTabPtys([CONTROL_PTY_ID], CONTROL_PTY_ID) + } +} + +export type ObservedExitControl = + | 'same-incarnation' + | 'replacement' + | 'unverified' + | 'legacy-unstamped' + | 'throw-after-exit' + +export async function runObservedExitControl(control: ObservedExitControl) { + const runtime = new ObservedExitRuntime() + const original = control === 'legacy-unstamped' ? undefined : FIRST_INCARNATION + let fallbackKills = 0 + runtime.registerPty(CONTROL_PTY_ID, WORKTREE_ID, null, { + ...BINDING, + ...(original ? { incarnationId: original } : {}) + }) + runtime.setPtyController({ + write: () => true, + kill: () => { + fallbackKills++ + return true + }, + getForegroundProcess: async () => null, + stopAndWait: async () => { + runtime.onPtyExit(CONTROL_PTY_ID, control === 'unverified' ? -1 : 0, original) + if (control === 'replacement') { + runtime.registerPty(CONTROL_PTY_ID, WORKTREE_ID, null, { + ...BINDING, + incarnationId: NEXT_INCARNATION + }) + } + if (control === 'throw-after-exit') { + throw new Error('unverified transport failure') + } + return false + } + }) + try { + const stopped = await runtime.closeControl() + return { + scenario: control, + stopped, + fallbackKills, + verdict: runtime.getPtyLivenessVerdict(CONTROL_PTY_ID) + } + } finally { + runtime.onPtyExit(CONTROL_PTY_ID, 0, control === 'replacement' ? NEXT_INCARNATION : original) + } +} diff --git a/src/main/runtime/terminal-close-observed-exit.test.ts b/src/main/runtime/terminal-close-observed-exit.test.ts new file mode 100644 index 00000000000..b41f61cfcc8 --- /dev/null +++ b/src/main/runtime/terminal-close-observed-exit.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + runObservedExitControl, + runObservedExitSocketScenario +} from './terminal-close-observed-exit-test-fixture' + +describe('closing a terminal after observing its physical exit', () => { + it('preserves the physical cause when an unrelated daemon prevents aggregate verification', async () => { + const result = await runObservedExitSocketScenario('physical-exit-observed') + expect(result.close).toEqual({ ptyKilled: true, ptyStopVerdict: null }) + expect(result.fallbackKills).toBe(0) + expect(result.targetInventoryCount).toBe(0) + expect(result.targetProbe).toBe(false) + expect(result.routerProbe).toBeNull() + expect(result.settled).toMatchObject({ + connected: false, + exitCause: { kind: 'operator_close' }, + headlessModelRetained: false, + titleTrackerRetained: false, + liveness: 'exited', + rendererExitCount: 1, + providerExitCount: 1, + exitListenerCalls: 1 + }) + }) + + it('keeps the healthy aggregate verification and delayed physical exit behavior', async () => { + const result = await runObservedExitSocketScenario('healthy') + expect(result.close.ptyKilled).toBe(true) + expect(result.fallbackKills).toBe(0) + expect(result.routerProbe).toBe(false) + expect(result.settled.exitCause).toEqual({ kind: 'operator_close' }) + expect(result.settled.rendererExitCount).toBe(1) + expect(result.settled.exitListenerCalls).toBe(1) + }) + + it('does not treat target absence as an earned exit before the stream delivers it', async () => { + const result = await runObservedExitSocketScenario('unrelated-endpoint-gone') + expect(result.close).toEqual({ ptyKilled: false, ptyStopVerdict: 'unverifiable' }) + expect(result.fallbackKills).toBe(1) + expect(result.targetProbe).toBe(false) + expect(result.routerProbe).toBeNull() + expect(result.beforeStreamResume.providerExitCount).toBe(0) + }) + + it('uses a stamped exit for the incarnation that was actually being closed', async () => { + const result = await runObservedExitControl('same-incarnation') + expect(result.stopped).toBe(true) + expect(result.fallbackKills).toBe(0) + expect(result.verdict?.status).toBe('exited') + }) + + it.each(['replacement', 'unverified', 'legacy-unstamped', 'throw-after-exit'] as const)( + 'does not reuse an exit for %s', + async (control) => { + const result = await runObservedExitControl(control) + expect(result.stopped).toBe(false) + expect(result.fallbackKills).toBe(1) + } + ) +})