diff --git a/docs/audits/terminal-completed-spawn-inputs/README.md b/docs/audits/terminal-completed-spawn-inputs/README.md new file mode 100644 index 00000000000..bb8bae2dfbc --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/README.md @@ -0,0 +1,71 @@ +# Completed terminal spawns retain consumed inputs + +Status: reproduced against actual `TerminalHost`, `Session`, output pipeline, and daemon admission code on Node 26.6.0 and installed Electron 43.7.0 / Node 24.21.0. The fix releases completed request objects and consumed history seed arrays while the terminal remains alive. + +## Retaining paths and fix + +Three long-lived callbacks kept spawn-only input objects reachable: + +1. `terminal-host-session-create.ts::spawnAndPublishSession` gave `Session` an exit callback capturing the complete request and dependencies. A small factory now captures only the exit callback, session ID, and agent-session generation. +2. `TerminalHost.createOrAttach` constructed that exit callback beside the cancellation check that captures the request. Their shared lexical context kept the request reachable even after the first capture was projected. The unchanged exit/reap body now lives in a method bound to its host. +3. `session-output-pipeline.ts` captured pipeline options in its foreground-confirmation callback. Those options include history chunks that `SessionOutputPlane` has already consumed synchronously. The callback now captures the subprocess object; the liveness callback is also extracted before constructing the pipeline. + +The subprocess remains the receiver of `subprocess.confirmShellForeground?.()`. The only production provider of the exit callback is `TerminalHost`; its bound method preserves the host receiver. Exit codes, incarnation tombstones, claimed-generation release, reaping, cancellation, and process ownership follow the same paths. A constructor-only `maxTombstones` field was removed to keep `TerminalHost` within the existing line limit; the registry receives the same configured/default value directly. + +## Production reachability and limits + +- `daemon-provider-init.ts::initDaemonPtyProvider` installs the local daemon adapter. The cold-restore path in `daemon-pty-spawn-result.ts` supplies recovered history to terminal creation. `daemon-server.ts` owns the host and admission objects; `daemon-request-router.ts:59` routes `createOrAttach` to admission. +- `daemon-terminal-admission.ts:90` obtains inline history or takes completed transfer chunks, then passes the chunks, environment, and cancellation inputs into the host at line 96. `session-output-plane.ts:63` consumes all seed chunks into the emulator and retains the success flag. +- `terminal-history-seed-transfer-registry.ts:97` removes a completed transfer from its map and byte accounting when handing its chunks to creation. Its pending-transfer limits therefore do not bound the aggregate of already-consumed seeds retained by live sessions. The configured checkpoint maximum is 200,000,000 bytes, but these proofs use tiny seeds and do **not** measure a 200 MB allocation or incident-sized RSS. +- Retention lasts for the live session. Disposal permits collection even before the fix. This is avoidable retention per live terminal, not proof of unlimited growth after successful teardown. +- Real admission stream callbacks still keep preparation/signal metadata while attached: the routed-session getter shares the admission context with its cancellation callback (`daemon-terminal-admission.ts:117–122`). The admission control confirms those objects collect after public `host.detach` with the fix. This patch does not change that attached-stream lifetime. +- Native `pty-subprocess/subprocess-handle.ts:48–60` still captures its spawn arguments through the exit-status callback, including its merged environment object. Collection of the original request environment object does not prove all copied environment strings disappear from a real native process owner. The proof injects an inert subprocess and does not measure native allocations. +- The daemon path can run locally and on execution hosts used remotely. No wire fields or messages change, and folder workspaces require no special behavior. The finding is compatible with a local application memory report such as #19831, but no affected-host process/heap evidence establishes that the incident used this restore path or that it explains the reported magnitude. + +## Reproduction and controls + +`spawn-source.cjs` bundles actual source and reconstructs the baseline in memory by reversing `fix.patch`. SHA-256 checks fence both versions of all three changed modules using `source-versions.json`. The loader accepts the exact audit-branch pair and the exact independent-main publication pair; all other source hashes fail. Reports contain hashes of the source actually evaluated. Dependencies remain actual worktree code. Only the OS descendant-kill port is replaced with a throwing guard; subprocess handles are small injected objects, with no real shell, socket, process signal, or network activity. + +`reproduce.cjs` measures weak references to request, environment, history array, and cancellation signal objects. It also tests pending ownership, actual exit/reap and claimed-generation replacement, retired-incarnation exit evidence, and foreground confirmation with the correct subprocess receiver and queued prompt delivery. + +| Check | Baseline | Fixed | +| --------------------------------------------------------- | ------------------------------- | -------------------- | +| Three completed requests while three sessions remain live | 3 of each input object retained | 0 of each retained | +| One request during unresolved spawn | All four input objects retained | All four retained | +| That request after publication | All four retained | All four collectible | +| Inputs after disposal | All collectible | All collectible | +| Exit/reap, new incarnation/generation, shell confirmation | Pass | Pass | + +`admission-control.cjs` exercises actual daemon admission and preparations above the actual host. A forwarding observer stores only weak references. Transport, attachment bookkeeping, and native subprocess ports are inert. Both runtimes reproduce the following: + +| Admission phase | Original options/env/history | Preparation/signal | Request/payload | +| ------------------------------------------- | ---------------------------- | ------------------ | --------------- | +| Baseline, attached or detached live session | Retained | Retained | Collectible | +| Fixed, attached live session | Collectible | Retained | Collectible | +| Fixed, detached live session | Collectible | Collectible | Collectible | +| Either version after disposal | Collectible | Collectible | Collectible | + +The seeded snapshot remains readable after collection. These object reachability checks establish specific removed retaining paths; they do not establish total memory released. No heap-snapshot tool was exposed in this session. The historical Electron 43.4.1 binary was not tested. Each process uses a 192 MiB old-space limit and a 15-second deadline. + +Run from the worktree: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/terminal-completed-spawn-inputs/reproduce.cjs --baseline +ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/terminal-completed-spawn-inputs/reproduce.cjs +ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/terminal-completed-spawn-inputs/admission-control.cjs +ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/daemon/terminal-host-spawn-input-retention.test.ts +``` + +For Electron, run the same proof scripts with the binary returned by `require('electron')`, the same Node flags, `ELECTRON_RUN_AS_NODE=1`, and `ORCA_BACKGROUND_LAUNCH=1`. This starts no application or window. Node and Electron reports are stored separately in this directory. + +The four permanent regressions pass with the fix. The reconstructed baseline deliberately fails the two retention regressions and passes both lifecycle controls: `ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config docs/audits/terminal-completed-spawn-inputs/baseline.config.mjs` exits 1. Existing host, concurrent create, teardown/recreate, reaping, agent ownership, preflight replacement, and history restore tests also pass: 67 tests across nine files. Node typecheck and the changed-code quality gate passed; explicit basic/type-aware lint includes the audit scripts. + +## Source identity and compatibility + +`source-versions.json` records the exact audited branch baseline, fixed hashes, previously reviewed main commit `77cd61df396f25ec91ee2d5ddcbd1f55aa94f818`, release `v1.4.198` commit `e0826956fcfc532f5a1e55b5e081f2e57e553c43`, and independent publication main commit `291b4ddd6f1c1af480169885e0fda7f9c78ff053`. The create and pipeline files exactly match these historical baselines. Historical `TerminalHost` differs only in the unrelated producer pause/resume source parameter from #20947 on the audit branch. This fix applies independently and does not require #20947. + +The supported `TerminalHost` SHA-256 pairs are audit baseline `8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4` → fixed `23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844`, and independent-main baseline `5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca` → fixed `f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f`. Each selected fixed source is reverse-patched and checked against its own paired baseline hash. + +The four permanent tests pass when the three patched main modules are overlaid on current dependencies. The six `mapped-*.json` reports repeat both runtime proofs and admission controls using the exact patched publication-main modules. They report the main hashes actually evaluated. This is a narrow compatibility check with working-tree dependencies, not a full historical application build. An optional `ORCA_SPAWN_INPUT_PROOF_SOURCE_MAP` points to a JSON object from these three relative source paths to exact reviewed fixed-source strings; unknown or incomplete mappings fail the same hash checks. With no mapping, the loader checks the published checkout directly. Mapped runs write separate reports prefixed `mapped-`. + +Cancellation wait/listener findings from the preceding audit remain diagnostic and are outside this patch. The independent admission review narrowed the signal/environment claims before publication. diff --git a/docs/audits/terminal-completed-spawn-inputs/admission-control.cjs b/docs/audits/terminal-completed-spawn-inputs/admission-control.cjs new file mode 100644 index 00000000000..de421ae36ad --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/admission-control.cjs @@ -0,0 +1,172 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { + loadExports, + evaluatedSourceHashes, + sourceMode, + reportPrefix, + sha +} = require('./spawn-source.cjs') +const { subprocess, collect } = require('./spawn-fixture.cjs') + +assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1') +assert.equal(typeof global.gc, 'function') +const root = path.resolve(__dirname, '../../..') + +function observeOptions(refs, host) { + return { + createOrAttach(options) { + refs.options = new WeakRef(options) + refs.env = new WeakRef(options.env) + refs.history = new WeakRef(options.historySeedChunks) + refs.signal = new WeakRef(options.cancelSignal) + return host.createOrAttach(options) + }, + detach: (...args) => host.detach(...args) + } +} + +function observePreparations(refs, preparations) { + return { + register(...args) { + const preparation = preparations.register(...args) + refs.preparation = new WeakRef(preparation) + return preparation + }, + prepareUnlessCanceled: (...args) => preparations.prepareUnlessCanceled(...args), + finish: (...args) => preparations.finish(...args) + } +} + +async function create(admission, refs) { + const request = { + id: 'request', + type: 'createOrAttach', + payload: { + sessionId: 'admission-review', + cols: 80, + rows: 24, + env: { REVIEW: 'request-input' }, + historySeed: 'ADMISSION-HISTORY-SEED\r\n' + } + } + refs.request = new WeakRef(request) + refs.payload = new WeakRef(request.payload) + const result = await admission.createOrAttach('client', request) + assert.equal(result.isNew, true) + assert.equal(result.historySeeded, true) +} + +async function exercise(api) { + const refs = {} + const host = new api.TerminalHost({ spawnSubprocess: async () => subprocess() }) + const preparations = new api.DaemonPtySpawnPreparations(async () => {}) + const client = { authenticatedPairEstablished: true, streamSocket: {} } + const attachments = [] + const admission = new api.DaemonTerminalAdmission({ + host: observeOptions(refs, host), + preparations: observePreparations(refs, preparations), + connections: new Map([['client', client]]), + endpoint: { hasLostOwnership: () => false }, + attachments: { + attach(...args) { + attachments.push(args) + }, + release() {}, + lastInputAt: () => undefined + }, + historySeedTransfers: { + take() { + throw new Error('Inline history only') + } + }, + transientFactRelay: { isBackgrounded: () => false, onSessionData() {}, onSessionExit() {} }, + streamDataBatcher: { + enqueue() {}, + enqueueControlEvent() {}, + flush() {}, + refreshSessionDroppability() {} + }, + log: { log() {} }, + isAcceptingWork: () => true, + requestEndpointRetirement() { + throw new Error('Unexpected endpoint retirement') + }, + reevaluateIdleShutdown() {} + }) + const retained = () => + Object.fromEntries(Object.entries(refs).map(([key, ref]) => [key, ref.deref() !== undefined])) + try { + await create(admission, refs) + assert.equal(admission.inFlight, 0) + assert.equal(preparations.pending.size, 0) + await collect() + const attached = retained() + assert.equal(host.listSessions().length, 1) + assert.match(host.getSnapshot('admission-review').snapshotAnsi, /ADMISSION-HISTORY-SEED/) + assert.equal(attachments.length, 1) + host.detach('admission-review', attachments[0][2]) + await collect() + const detached = retained() + assert.equal(host.listSessions().length, 1) + await host.dispose() + await collect() + const disposed = retained() + assert(Object.values(disposed).every((value) => !value)) + return { attached, detached, disposed, historyVisibleAfterCollection: true } + } finally { + await host.dispose() + } +} + +async function main() { + const phases = {} + for (const phase of ['baseline', 'fixed']) { + const result = await exercise(await loadExports(phase === 'fixed')) + for (const key of ['options', 'env', 'history']) { + assert.equal(result.attached[key], phase === 'baseline') + assert.equal(result.detached[key], phase === 'baseline') + } + for (const key of ['preparation', 'signal']) { + assert.equal(result.attached[key], true) + assert.equal(result.detached[key], phase === 'baseline') + } + for (const key of ['request', 'payload']) { + assert.equal(result.attached[key], false) + assert.equal(result.detached[key], false) + } + phases[phase] = result + } + const sourceHashes = { ...evaluatedSourceHashes } + for (const file of [ + 'src/main/daemon/daemon-terminal-admission.ts', + 'src/main/daemon/daemon-pty-spawn-preparations.ts' + ]) { + sourceHashes[file] = sha(fs.readFileSync(path.join(root, file))) + } + const report = { + node: process.version, + electron: process.versions.electron ?? null, + v8: process.versions.v8, + sourceMode, + sourceHashes, + phases + } + fs.writeFileSync( + path.join( + __dirname, + `${reportPrefix}admission-${process.versions.electron ? 'electron' : 'node'}.json` + ), + `${JSON.stringify(report, null, 2)}\n` + ) + console.log(JSON.stringify(phases, null, 2)) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) +setTimeout(() => { + console.error('fixture timeout') + process.exit(2) +}, 15000).unref() diff --git a/docs/audits/terminal-completed-spawn-inputs/admission-electron.json b/docs/audits/terminal-completed-spawn-inputs/admission-electron.json new file mode 100644 index 00000000000..98f93204cef --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/admission-electron.json @@ -0,0 +1,84 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": { + "baseline": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixed": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f" + }, + "src/main/daemon/terminal-host-session-create.ts": { + "baseline": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixed": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309" + }, + "src/main/daemon/terminal-host.ts": { + "baseline": "8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4", + "fixed": "23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844" + }, + "src/main/daemon/daemon-terminal-admission.ts": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251", + "src/main/daemon/daemon-pty-spawn-preparations.ts": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e" + }, + "phases": { + "baseline": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + }, + "fixed": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": false, + "env": false, + "history": false, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + } + } +} diff --git a/docs/audits/terminal-completed-spawn-inputs/admission-node.json b/docs/audits/terminal-completed-spawn-inputs/admission-node.json new file mode 100644 index 00000000000..84f7e83a4a1 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/admission-node.json @@ -0,0 +1,84 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": { + "baseline": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixed": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f" + }, + "src/main/daemon/terminal-host-session-create.ts": { + "baseline": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixed": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309" + }, + "src/main/daemon/terminal-host.ts": { + "baseline": "8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4", + "fixed": "23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844" + }, + "src/main/daemon/daemon-terminal-admission.ts": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251", + "src/main/daemon/daemon-pty-spawn-preparations.ts": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e" + }, + "phases": { + "baseline": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + }, + "fixed": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": false, + "env": false, + "history": false, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + } + } +} diff --git a/docs/audits/terminal-completed-spawn-inputs/baseline.config.mjs b/docs/audits/terminal-completed-spawn-inputs/baseline.config.mjs new file mode 100644 index 00000000000..82362685b1c --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/baseline.config.mjs @@ -0,0 +1,23 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import { defineConfig, mergeConfig } from 'vitest/config' +import rootConfig from '../../../config/vitest.config.ts' + +const require = createRequire(import.meta.url) +const { baselineSources } = require('./spawn-source.cjs') +const config = mergeConfig( + rootConfig, + defineConfig({ + plugins: [ + { + name: 'completed-spawn-input-baseline', + enforce: 'pre', + load(id) { + return baselineSources.get(path.normalize(id)) + } + } + ] + }) +) +config.test.include = ['src/main/daemon/terminal-host-spawn-input-retention.test.ts'] +export default config diff --git a/docs/audits/terminal-completed-spawn-inputs/electron-baseline.json b/docs/audits/terminal-completed-spawn-inputs/electron-baseline.json new file mode 100644 index 00000000000..448cb931379 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/electron-baseline.json @@ -0,0 +1,66 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "fixed": false, + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "src/main/daemon/terminal-host-session-create.ts": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "src/main/daemon/terminal-host.ts": "8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 3, + "env": 3, + "history": 3, + "signal": 3 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/electron-fixed.json b/docs/audits/terminal-completed-spawn-inputs/electron-fixed.json new file mode 100644 index 00000000000..3351fc4d975 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/electron-fixed.json @@ -0,0 +1,66 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "fixed": true, + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "src/main/daemon/terminal-host-session-create.ts": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "src/main/daemon/terminal-host.ts": "23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/fix.patch b/docs/audits/terminal-completed-spawn-inputs/fix.patch new file mode 100644 index 00000000000..d18ce888276 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/fix.patch @@ -0,0 +1,122 @@ +diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts +index c249e4d1d3..f67d2e15b9 100644 +--- a/src/main/daemon/session-output-pipeline.ts ++++ b/src/main/daemon/session-output-pipeline.ts +@@ -14,6 +14,7 @@ export function createSessionOutputPipeline(opts: { + subprocess: SubprocessHandle + isAlive: () => boolean + }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { ++ const { subprocess, isAlive } = opts + let barrier: TerminalShellRecoveryBarrier | null = null + const output = new SessionOutputPlane({ + cols: opts.cols, +@@ -24,9 +25,9 @@ export function createSessionOutputPipeline(opts: { + getTerminalOwner: () => barrier?.getOwner() + }) + const recoveryBarrier = new TerminalShellRecoveryBarrier({ +- confirmShellForeground: async () => (await opts.subprocess.confirmShellForeground?.()) ?? false, ++ confirmShellForeground: async () => (await subprocess.confirmShellForeground?.()) ?? false, + release: (emission) => output.emit(emission), +- isAlive: opts.isAlive ++ isAlive + }) + barrier = recoveryBarrier + return { output, recoveryBarrier } +diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts +index 8f6833c3d9..fc4cc01088 100644 +--- a/src/main/daemon/terminal-host-session-create.ts ++++ b/src/main/daemon/terminal-host-session-create.ts +@@ -150,7 +150,11 @@ async function spawnAndPublishSession( + historySeedChunks: opts.historySeedChunks, + ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + wslDistro, +- onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), ++ onExit: createSessionExitHandler( ++ deps.onSessionExit, ++ opts.sessionId, ++ opts.agentSessionGeneration ++ ), + ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), + ...(opts.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } +@@ -212,6 +216,14 @@ async function spawnAndPublishSession( + } + } + ++function createSessionExitHandler( ++ onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], ++ sessionId: string, ++ generation: string | undefined ++): () => void { ++ return () => onSessionExit(sessionId, generation) ++} ++ + // Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what + // TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never + // masquerade as a permission denial. +diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts +index 81dae092b1..1fdbb6d161 100644 +--- a/src/main/daemon/terminal-host.ts ++++ b/src/main/daemon/terminal-host.ts +@@ -54,7 +54,6 @@ export class TerminalHost { + private onSessionReaped: TerminalHostOptions['onSessionReaped'] + private reportReadinessEvent: TerminalHostOptions['reportReadinessEvent'] + private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] +- private maxTombstones: number + private creationFenced = false + private disposePromise: Promise | null = null + private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() +@@ -71,8 +70,7 @@ export class TerminalHost { + this.onSessionReaped = opts.onSessionReaped + this.reportReadinessEvent = opts.reportReadinessEvent + this.onFinalCheckpoint = opts.onFinalCheckpoint +- this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES +- this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) ++ this.killedTombstones = new TerminalHostTombstones(opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES) + } + + async createOrAttach(opts: InternalCreateOrAttachOptions): Promise { +@@ -123,20 +121,7 @@ export class TerminalHost { + ...(this.reportReadinessEvent + ? { reportReadinessEvent: this.reportReadinessEvent } + : {}), +- onSessionExit: (sessionId, generation) => { +- const session = this.sessions.get(sessionId) +- if (session) { +- pruneRetiredPtyIncarnations(this.retiredIncarnations) +- this.retiredIncarnations.set(sessionId, { +- incarnationId: session.incarnationId, +- code: session.exitCode ?? 0, +- expiresAt: Date.now() + REMOTE_FOREGROUND_TOMBSTONE_RETENTION_MS +- }) +- } +- this.agentSessionOwners.release(sessionId, generation) +- this.agentSessionGenerations.forget(sessionId, generation) +- this.reapSession(sessionId) +- } ++ onSessionExit: this.handleSessionExit.bind(this) + }) + } + }) +@@ -146,6 +131,21 @@ export class TerminalHost { + } + } + ++ private handleSessionExit(sessionId: string, generation: string | undefined): void { ++ const session = this.sessions.get(sessionId) ++ if (session) { ++ pruneRetiredPtyIncarnations(this.retiredIncarnations) ++ this.retiredIncarnations.set(sessionId, { ++ incarnationId: session.incarnationId, ++ code: session.exitCode ?? 0, ++ expiresAt: Date.now() + REMOTE_FOREGROUND_TOMBSTONE_RETENTION_MS ++ }) ++ } ++ this.agentSessionOwners.release(sessionId, generation) ++ this.agentSessionGenerations.forget(sessionId, generation) ++ this.reapSession(sessionId) ++ } ++ + private assertCreateOrAttachAllowed(opts: InternalCreateOrAttachOptions): void { + if (this.creationFenced) { + throw new Error('Terminal host is shutting down') diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-admission-electron.json b/docs/audits/terminal-completed-spawn-inputs/mapped-admission-electron.json new file mode 100644 index 00000000000..788cde40243 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-admission-electron.json @@ -0,0 +1,84 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": { + "baseline": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixed": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f" + }, + "src/main/daemon/terminal-host-session-create.ts": { + "baseline": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixed": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309" + }, + "src/main/daemon/terminal-host.ts": { + "baseline": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "fixed": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f" + }, + "src/main/daemon/daemon-terminal-admission.ts": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251", + "src/main/daemon/daemon-pty-spawn-preparations.ts": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e" + }, + "phases": { + "baseline": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + }, + "fixed": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": false, + "env": false, + "history": false, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + } + } +} diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-admission-node.json b/docs/audits/terminal-completed-spawn-inputs/mapped-admission-node.json new file mode 100644 index 00000000000..a2ec9a4d177 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-admission-node.json @@ -0,0 +1,84 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": { + "baseline": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixed": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f" + }, + "src/main/daemon/terminal-host-session-create.ts": { + "baseline": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixed": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309" + }, + "src/main/daemon/terminal-host.ts": { + "baseline": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "fixed": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f" + }, + "src/main/daemon/daemon-terminal-admission.ts": "14d1d8bbf4b662c98db123719741daffc3d0fe911d3a6a08d3516be666068251", + "src/main/daemon/daemon-pty-spawn-preparations.ts": "77c94466578a5066af069e45eb5e87a64b73b88580a13b2172b2ea846b434f4e" + }, + "phases": { + "baseline": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": true, + "options": true, + "env": true, + "history": true, + "signal": true + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + }, + "fixed": { + "attached": { + "request": false, + "payload": false, + "preparation": true, + "options": false, + "env": false, + "history": false, + "signal": true + }, + "detached": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "disposed": { + "request": false, + "payload": false, + "preparation": false, + "options": false, + "env": false, + "history": false, + "signal": false + }, + "historyVisibleAfterCollection": true + } + } +} diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-electron-baseline.json b/docs/audits/terminal-completed-spawn-inputs/mapped-electron-baseline.json new file mode 100644 index 00000000000..90de8b8ec0c --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-electron-baseline.json @@ -0,0 +1,66 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "fixed": false, + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "src/main/daemon/terminal-host-session-create.ts": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "src/main/daemon/terminal-host.ts": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 3, + "env": 3, + "history": 3, + "signal": 3 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-electron-fixed.json b/docs/audits/terminal-completed-spawn-inputs/mapped-electron-fixed.json new file mode 100644 index 00000000000..653e5d8cd75 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-electron-fixed.json @@ -0,0 +1,66 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "fixed": true, + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "src/main/daemon/terminal-host-session-create.ts": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "src/main/daemon/terminal-host.ts": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-node-baseline.json b/docs/audits/terminal-completed-spawn-inputs/mapped-node-baseline.json new file mode 100644 index 00000000000..b77daa28825 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-node-baseline.json @@ -0,0 +1,66 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "fixed": false, + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "src/main/daemon/terminal-host-session-create.ts": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "src/main/daemon/terminal-host.ts": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 3, + "env": 3, + "history": 3, + "signal": 3 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/mapped-node-fixed.json b/docs/audits/terminal-completed-spawn-inputs/mapped-node-fixed.json new file mode 100644 index 00000000000..571ae217572 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/mapped-node-fixed.json @@ -0,0 +1,66 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "fixed": true, + "sourceMode": "mapped modules with working-tree dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "src/main/daemon/terminal-host-session-create.ts": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "src/main/daemon/terminal-host.ts": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/node-baseline.json b/docs/audits/terminal-completed-spawn-inputs/node-baseline.json new file mode 100644 index 00000000000..4a7048adb29 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/node-baseline.json @@ -0,0 +1,66 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "fixed": false, + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "src/main/daemon/terminal-host-session-create.ts": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "src/main/daemon/terminal-host.ts": "8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 3, + "env": 3, + "history": 3, + "signal": 3 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/node-fixed.json b/docs/audits/terminal-completed-spawn-inputs/node-fixed.json new file mode 100644 index 00000000000..b2cf00a4556 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/node-fixed.json @@ -0,0 +1,66 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "fixed": true, + "sourceMode": "working-tree modules and dependencies", + "sourceHashes": { + "src/main/daemon/session-output-pipeline.ts": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "src/main/daemon/terminal-host-session-create.ts": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "src/main/daemon/terminal-host.ts": "23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844" + }, + "reports": [ + { + "case": "completed-inputs", + "whileLive": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "liveSessionCountAtCollection": 3 + }, + { + "case": "pending-inputs", + "duringSpawn": { + "options": 1, + "env": 1, + "history": 1, + "signal": 1 + }, + "afterPublication": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + }, + "afterDispose": { + "options": 0, + "env": 0, + "history": 0, + "signal": 0 + } + }, + { + "case": "exit-and-recreate", + "exitVerdict": "exited", + "exitReason": "pty_exit_7", + "reaped": ["claimed", "claimed"], + "newIncarnation": true, + "newGeneration": true + }, + { + "case": "foreground-confirmation", + "confirmations": 1, + "preservedReceiver": true, + "owner": "shell", + "queuedPromptReleased": true + } + ] +} diff --git a/docs/audits/terminal-completed-spawn-inputs/reproduce.cjs b/docs/audits/terminal-completed-spawn-inputs/reproduce.cjs new file mode 100644 index 00000000000..4900a3f4b5a --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/reproduce.cjs @@ -0,0 +1,208 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { load, evaluatedSourceHashes, sourceMode, reportPrefix } = require('./spawn-source.cjs') +const { + subprocess, + streamClient, + startWithInputs, + counts, + expected, + collect +} = require('./spawn-fixture.cjs') +const fixed = !process.argv.includes('--baseline') +assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1') +assert.equal(typeof global.gc, 'function') + +async function completedInputs(Host) { + const host = new Host({ spawnSubprocess: async () => subprocess() }) + const refs = [] + try { + for (let index = 0; index < 3; index += 1) { + const created = startWithInputs(host, `retention-${index}`) + assert.equal((await created.creation).historySeeded, true) + refs.push(created.refs) + } + await collect() + const whileLive = counts(refs) + assert.deepEqual(whileLive, expected(fixed ? 0 : 3)) + assert.equal(host.listSessions().length, 3) + assert.ok(host.getSnapshot('retention-0').snapshotAnsi.includes('retention-seed')) + await host.dispose() + await collect() + const afterDispose = counts(refs) + assert.deepEqual(afterDispose, expected(0)) + return { case: 'completed-inputs', whileLive, afterDispose, liveSessionCountAtCollection: 3 } + } finally { + await host.dispose() + } +} + +async function pendingInputs(Host) { + const gate = Promise.withResolvers() + const host = new Host({ + spawnSubprocess: async () => { + await gate.promise + return subprocess() + } + }) + const created = startWithInputs(host, 'pending') + try { + await collect() + const duringSpawn = counts([created.refs]) + assert.deepEqual(duringSpawn, expected(1)) + gate.resolve() + assert.equal((await created.creation).isNew, true) + await collect() + const afterPublication = counts([created.refs]) + assert.deepEqual(afterPublication, expected(fixed ? 0 : 1)) + await host.dispose() + await collect() + assert.deepEqual(counts([created.refs]), expected(0)) + return { + case: 'pending-inputs', + duringSpawn, + afterPublication, + afterDispose: counts([created.refs]) + } + } finally { + gate.resolve() + await created.creation + await host.dispose() + } +} + +async function exitAndRecreate(Host) { + const handles = [] + const reaped = [] + const host = new Host({ + spawnSubprocess: async () => { + const handle = subprocess() + handles.push(handle) + return handle + }, + onSessionReaped: (id) => reaped.push(id) + }) + const options = { + sessionId: 'claimed', + cols: 80, + rows: 24, + streamClient, + agentSessionEnsure: { + claim: { + digestVersion: 1, + keyId: 'key', + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + } + try { + const first = await host.createOrAttach(options) + handles[0].emitExit(7) + assert.deepEqual(reaped, ['claimed']) + assert.deepEqual(host.listSessions(), []) + const evidence = ( + await host.inspectProcess('claimed', { expectedIncarnationId: first.incarnationId }) + ).foregroundProcessEvidence + assert.equal(evidence.verdict, 'exited') + assert.equal(evidence.reason, 'pty_exit_7') + assert.equal(evidence.ptyIncarnationId, first.incarnationId) + const second = await host.createOrAttach(options) + assert.equal(second.agentSessionEnsure.disposition, 'created') + assert.notEqual( + second.agentSessionEnsure.owner.generation, + first.agentSessionEnsure.owner.generation + ) + assert.notEqual(second.incarnationId, first.incarnationId) + assert.equal(handles.length, 2) + await host.dispose() + assert.deepEqual(reaped, ['claimed', 'claimed']) + return { + case: 'exit-and-recreate', + exitVerdict: evidence.verdict, + exitReason: evidence.reason, + reaped, + newIncarnation: true, + newGeneration: true + } + } finally { + await host.dispose() + } +} + +async function foregroundConfirmation(Host) { + const gate = Promise.withResolvers() + let confirmations = 0 + const handle = { + ...subprocess(), + confirmShellForeground() { + assert.equal(this, handle) + confirmations += 1 + return gate.promise + } + } + const host = new Host({ spawnSubprocess: async () => handle }) + try { + await host.createOrAttach({ sessionId: 'recovery', cols: 80, rows: 24, streamClient }) + handle.emitData('\x1b[?1049hTUI\x1b]133;D;137\x07SHELL-PROMPT') + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(confirmations, 1) + gate.resolve(true) + const snapshot = await host.getSettledSnapshot('recovery') + assert.equal(snapshot.terminalOwner, 'shell') + assert.ok(snapshot.snapshotAnsi.includes('SHELL-PROMPT')) + return { + case: 'foreground-confirmation', + confirmations, + preservedReceiver: true, + owner: snapshot.terminalOwner, + queuedPromptReleased: true + } + } finally { + gate.resolve(false) + await host.dispose() + } +} + +async function main() { + const Host = await load(fixed) + const reports = [ + await completedInputs(Host), + await pendingInputs(Host), + await exitAndRecreate(Host), + await foregroundConfirmation(Host) + ] + const report = { + node: process.version, + electron: process.versions.electron ?? null, + v8: process.versions.v8, + fixed, + sourceMode, + sourceHashes: Object.fromEntries( + Object.entries(evaluatedSourceHashes).map(([file, hashes]) => [ + file, + fixed ? hashes.fixed : hashes.baseline + ]) + ), + reports + } + const file = `${reportPrefix}${process.versions.electron ? 'electron' : 'node'}-${fixed ? 'fixed' : 'baseline'}.json` + fs.writeFileSync(path.join(__dirname, file), `${JSON.stringify(report, null, 2)}\n`) + console.log(JSON.stringify(report, null, 2)) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) +setTimeout(() => { + console.error('fixture timeout') + process.exit(2) +}, 15000).unref() diff --git a/docs/audits/terminal-completed-spawn-inputs/source-versions.json b/docs/audits/terminal-completed-spawn-inputs/source-versions.json new file mode 100644 index 00000000000..bdfb76e1493 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/source-versions.json @@ -0,0 +1,113 @@ +{ + "baselineCommit": "9e2c137548bf99f91255ab4862c01145e42a0883", + "sources": [ + { + "sourcePath": "src/main/daemon/session-output-pipeline.ts", + "baselineSha256": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixedSha256": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f" + }, + { + "sourcePath": "src/main/daemon/terminal-host-session-create.ts", + "baselineSha256": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixedSha256": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309" + }, + { + "sourcePath": "src/main/daemon/terminal-host.ts", + "baselineSha256": "8cd6804b249ffb0d82da7f5a7ec8faa66514b0d6e3e36472cd81e6409c3edbc4", + "fixedSha256": "23cfd9c6317db30edd48a0c8bd7c54658206654703461cf12295274b23fb8844", + "alternatePairs": [ + { + "name": "independent-main-publication", + "baselineSha256": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "fixedSha256": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f" + } + ] + } + ], + "comparedRefs": [ + { + "ref": "origin/main", + "commit": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818", + "sources": [ + { + "sourcePath": "src/main/daemon/session-output-pipeline.ts", + "sha256": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "matchesBaseline": true, + "patchApplies": true, + "overlaySha256": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "matchesFixed": true + }, + { + "sourcePath": "src/main/daemon/terminal-host-session-create.ts", + "sha256": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "matchesBaseline": true, + "patchApplies": true, + "overlaySha256": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "matchesFixed": true + }, + { + "sourcePath": "src/main/daemon/terminal-host.ts", + "sha256": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "matchesBaseline": false, + "patchApplies": true, + "overlaySha256": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f", + "matchesFixed": false + } + ] + }, + { + "ref": "v1.4.198", + "commit": "e0826956fcfc532f5a1e55b5e081f2e57e553c43", + "sources": [ + { + "sourcePath": "src/main/daemon/session-output-pipeline.ts", + "sha256": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "matchesBaseline": true, + "patchApplies": true, + "overlaySha256": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "matchesFixed": true + }, + { + "sourcePath": "src/main/daemon/terminal-host-session-create.ts", + "sha256": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "matchesBaseline": true, + "patchApplies": true, + "overlaySha256": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "matchesFixed": true + }, + { + "sourcePath": "src/main/daemon/terminal-host.ts", + "sha256": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "matchesBaseline": false, + "patchApplies": true, + "overlaySha256": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f", + "matchesFixed": false + } + ] + } + ], + "publicationMain": { + "commit": "291b4ddd6f1c1af480169885e0fda7f9c78ff053", + "sources": [ + { + "sourcePath": "src/main/daemon/session-output-pipeline.ts", + "baselineSha256": "99ff9d936469a59e07f979b19344815c8636cd58a985e139614f221e99eaf8c9", + "fixedSha256": "94f6f221d85388274d585ad714fda25c32a824942950074e4e95b85c94a6771f", + "patchApplies": true + }, + { + "sourcePath": "src/main/daemon/terminal-host-session-create.ts", + "baselineSha256": "44fb82244a3307b5c89ebf724640be585dfe1b79566d11649b38eaf518c54d32", + "fixedSha256": "d54cbaff13c8daba1766e4fec24a6903ecdf1486249f1a9e5d96a207de410309", + "patchApplies": true + }, + { + "sourcePath": "src/main/daemon/terminal-host.ts", + "baselineSha256": "5216562b3c10c5fce4238614dc3a81887c970359a60e7e23179c6dfa15ccbcca", + "fixedSha256": "f22856504559a6bb78498c6d5aae07cbbd80a21019723278a560628e0142236f", + "patchApplies": true + } + ], + "dependencyScope": "Only these three modules are mapped; other dependencies are current worktree source." + } +} diff --git a/docs/audits/terminal-completed-spawn-inputs/spawn-fixture.cjs b/docs/audits/terminal-completed-spawn-inputs/spawn-fixture.cjs new file mode 100644 index 00000000000..193ab304a33 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/spawn-fixture.cjs @@ -0,0 +1,81 @@ +const assert = require('node:assert/strict') + +function subprocess() { + let dataListener + let exitListener + return { + pid: 424242, + getForegroundProcess: () => null, + write() {}, + resize() {}, + signal() {}, + kill() { + exitListener?.(0) + }, + forceKill() { + exitListener?.(137) + }, + terminateOwnedTree: () => 'unavailable', + onData(listener) { + dataListener = listener + }, + onExit(listener) { + exitListener = listener + }, + dispose() { + dataListener = undefined + exitListener = undefined + }, + emitData(data) { + dataListener?.(data) + }, + emitExit(code) { + exitListener?.(code) + } + } +} + +// These callbacks must not share a lexical context with the request's signal. +const streamClient = { onData() {}, onExit() {} } +function startWithInputs(host, sessionId) { + const controller = new AbortController() + const env = { RETENTION_FIXTURE: 'x'.repeat(1024) } + const historySeedChunks = ['retention-seed\r\n'] + const options = { + sessionId, + cols: 80, + rows: 24, + env, + historySeedChunks, + streamClient, + cancelSignal: controller.signal, + isCanceled: () => controller.signal.aborted + } + return { + refs: { + options: new WeakRef(options), + env: new WeakRef(env), + history: new WeakRef(historySeedChunks), + signal: new WeakRef(controller.signal) + }, + creation: host.createOrAttach(options) + } +} + +function counts(refs) { + return Object.fromEntries( + ['options', 'env', 'history', 'signal'].map((key) => [ + key, + refs.filter((ref) => ref[key].deref() !== undefined).length + ]) + ) +} +const expected = (count) => ({ options: count, env: count, history: count, signal: count }) +async function collect() { + assert.equal(typeof global.gc, 'function') + for (let round = 0; round < 4; round += 1) { + await new Promise((resolve) => setImmediate(resolve)) + global.gc() + } +} +module.exports = { subprocess, streamClient, startWithInputs, counts, expected, collect } diff --git a/docs/audits/terminal-completed-spawn-inputs/spawn-source.cjs b/docs/audits/terminal-completed-spawn-inputs/spawn-source.cjs new file mode 100644 index 00000000000..5001b5d4d31 --- /dev/null +++ b/docs/audits/terminal-completed-spawn-inputs/spawn-source.cjs @@ -0,0 +1,114 @@ +const assert = require('node:assert/strict') +const { createHash } = require('node:crypto') +const { readFileSync } = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const esbuild = require('esbuild') +const { applyPatch, parsePatch, reversePatch } = require('diff') +const versions = require('./source-versions.json') + +const root = path.resolve(__dirname, '../../..') +const readText = (file) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n') +const sha = (value) => createHash('sha256').update(value).digest('hex') +const patches = parsePatch(readText(path.join(__dirname, 'fix.patch'))) +assert.equal(patches.length, versions.sources.length) +const fixedSources = new Map() +const baselineSources = new Map() +const evaluatedSourceHashes = {} +const sourceMapPath = process.env.ORCA_SPAWN_INPUT_PROOF_SOURCE_MAP +const sourceOverrides = sourceMapPath ? JSON.parse(readText(path.resolve(sourceMapPath))) : null +if (sourceMapPath) { + assert.equal(typeof sourceOverrides, 'object') + assert.notEqual(sourceOverrides, null) + assert.equal(Array.isArray(sourceOverrides), false) + assert.deepEqual( + Object.keys(sourceOverrides).sort(), + versions.sources.map((source) => source.sourcePath).sort() + ) +} +for (const source of versions.sources) { + const file = path.join(root, source.sourcePath) + const fixed = sourceOverrides ? sourceOverrides[source.sourcePath] : readText(file) + assert.equal(typeof fixed, 'string') + const pair = [source, ...(source.alternatePairs ?? [])].find( + (entry) => entry.fixedSha256 === sha(fixed) + ) + assert.ok(pair, `Unreviewed product source: ${source.sourcePath}`) + const patch = patches.find((entry) => entry.oldFileName === `a/${source.sourcePath}`) + assert.ok(patch) + const baseline = applyPatch(fixed, reversePatch(patch)) + assert.notEqual(baseline, false) + assert.equal(sha(baseline), pair.baselineSha256, `Baseline changed: ${source.sourcePath}`) + fixedSources.set(file, fixed) + baselineSources.set(file, baseline) + evaluatedSourceHashes[source.sourcePath] = { baseline: sha(baseline), fixed: sha(fixed) } +} + +const sourceMode = sourceMapPath + ? 'mapped modules with working-tree dependencies' + : 'working-tree modules and dependencies' +const reportPrefix = sourceMapPath ? 'mapped-' : '' + +async function loadExports(fixed) { + const sources = fixed ? fixedSources : baselineSources + const build = await esbuild.build({ + stdin: { + contents: [ + "export { TerminalHost } from './src/main/daemon/terminal-host'", + "export { DaemonTerminalAdmission } from './src/main/daemon/daemon-terminal-admission'", + "export { DaemonPtySpawnPreparations } from './src/main/daemon/daemon-pty-spawn-preparations'" + ].join(';'), + resolveDir: root, + loader: 'ts' + }, + platform: 'node', + format: 'cjs', + bundle: true, + packages: 'external', + write: false, + plugins: [ + { + name: 'reviewed-spawn-input-sources', + setup(builder) { + builder.onLoad( + { filter: /(?:terminal-host(?:-session-create)?|session-output-pipeline)\.ts$/ }, + (args) => { + const contents = sources.get(args.path) + return contents === undefined ? undefined : { contents, loader: 'ts' } + } + ) + builder.onResolve({ filter: /pty-descendant-termination$/ }, () => ({ + path: 'no-os-signals', + namespace: 'fixture' + })) + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ + contents: + "export function killWithDescendantSweep() { throw new Error('Unexpected real process teardown') }", + loader: 'js' + })) + } + } + ] + }) + const filename = path.join(__dirname, 'bundled-terminal-host.cjs') + const loaded = new Module(filename, module) + loaded.filename = filename + loaded.paths = Module._nodeModulePaths(__dirname) + loaded._compile(build.outputFiles[0].text, filename) + return loaded.exports +} + +async function load(fixed) { + return (await loadExports(fixed)).TerminalHost +} + +module.exports = { + load, + loadExports, + versions, + sha, + baselineSources, + evaluatedSourceHashes, + sourceMode, + reportPrefix +} diff --git a/src/main/daemon/session-output-pipeline.ts b/src/main/daemon/session-output-pipeline.ts index c249e4d1d31..f67d2e15b92 100644 --- a/src/main/daemon/session-output-pipeline.ts +++ b/src/main/daemon/session-output-pipeline.ts @@ -14,6 +14,7 @@ export function createSessionOutputPipeline(opts: { subprocess: SubprocessHandle isAlive: () => boolean }): { output: SessionOutputPlane; recoveryBarrier: TerminalShellRecoveryBarrier } { + const { subprocess, isAlive } = opts let barrier: TerminalShellRecoveryBarrier | null = null const output = new SessionOutputPlane({ cols: opts.cols, @@ -24,9 +25,9 @@ export function createSessionOutputPipeline(opts: { getTerminalOwner: () => barrier?.getOwner() }) const recoveryBarrier = new TerminalShellRecoveryBarrier({ - confirmShellForeground: async () => (await opts.subprocess.confirmShellForeground?.()) ?? false, + confirmShellForeground: async () => (await subprocess.confirmShellForeground?.()) ?? false, release: (emission) => output.emit(emission), - isAlive: opts.isAlive + isAlive }) barrier = recoveryBarrier return { output, recoveryBarrier } diff --git a/src/main/daemon/terminal-host-session-create.ts b/src/main/daemon/terminal-host-session-create.ts index 8f6833c3d9f..fc4cc01088a 100644 --- a/src/main/daemon/terminal-host-session-create.ts +++ b/src/main/daemon/terminal-host-session-create.ts @@ -150,7 +150,11 @@ async function spawnAndPublishSession( historySeedChunks: opts.historySeedChunks, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), wslDistro, - onExit: () => deps.onSessionExit(opts.sessionId, opts.agentSessionGeneration), + onExit: createSessionExitHandler( + deps.onSessionExit, + opts.sessionId, + opts.agentSessionGeneration + ), ...(deps.reportReadinessEvent ? { reportReadinessEvent: deps.reportReadinessEvent } : {}), ...(opts.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: opts.shellReadyTimeoutMs } @@ -212,6 +216,14 @@ async function spawnAndPublishSession( } } +function createSessionExitHandler( + onSessionExit: TerminalHostSessionCreateDependencies['onSessionExit'], + sessionId: string, + generation: string | undefined +): () => void { + return () => onSessionExit(sessionId, generation) +} + // Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what // TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never // masquerade as a permission denial. diff --git a/src/main/daemon/terminal-host-spawn-input-retention.test.ts b/src/main/daemon/terminal-host-spawn-input-retention.test.ts new file mode 100644 index 00000000000..651fddcb0bf --- /dev/null +++ b/src/main/daemon/terminal-host-spawn-input-retention.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SubprocessHandle } from './session-subprocess-handle' +import type { InternalCreateOrAttachOptions } from './terminal-host-agent-session-claim' +import { TerminalHost } from './terminal-host' + +vi.mock('../pty-descendant-termination', () => ({ + killWithDescendantSweep: () => { + throw new Error('The retention fixture must not signal real processes') + } +})) + +function subprocess() { + let dataListener: ((data: string) => void) | undefined + let exitListener: ((code: number) => void) | undefined + return { + pid: 424242, + getForegroundProcess: () => null, + write() {}, + resize() {}, + signal() {}, + kill() { + exitListener?.(0) + }, + forceKill() { + exitListener?.(137) + }, + terminateOwnedTree: () => 'unavailable' as const, + onData(listener: (data: string) => void) { + dataListener = listener + }, + onExit(listener: (code: number) => void) { + exitListener = listener + }, + dispose() { + dataListener = undefined + exitListener = undefined + }, + emitData(data: string) { + dataListener?.(data) + }, + emitExit(code: number) { + exitListener?.(code) + } + } satisfies SubprocessHandle & { + emitData: (data: string) => void + emitExit: (code: number) => void + } +} + +const streamClient = { onData() {}, onExit() {} } + +function startWithInputs(host: TerminalHost, sessionId: string) { + const controller = new AbortController() + const env = { RETENTION_FIXTURE: 'x'.repeat(1024) } + const historySeedChunks = ['retention-seed\r\n'] + const options: InternalCreateOrAttachOptions = { + sessionId, + cols: 80, + rows: 24, + env, + historySeedChunks, + streamClient, + cancelSignal: controller.signal, + isCanceled: () => controller.signal.aborted + } + return { + refs: [ + new WeakRef(options), + new WeakRef(env), + new WeakRef(historySeedChunks), + new WeakRef(controller.signal) + ], + creation: host.createOrAttach(options) + } +} + +async function collect(): Promise { + if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + for (let round = 0; round < 4; round += 1) { + await new Promise((resolve) => setImmediate(resolve)) + globalThis.gc() + } +} + +describe('TerminalHost completed spawn inputs', () => { + it('releases request, environment, consumed history and cancellation inputs for live sessions', async () => { + const host = new TerminalHost({ spawnSubprocess: async () => subprocess() }) + try { + const refs: WeakRef[] = [] + for (let index = 0; index < 3; index += 1) { + const created = startWithInputs(host, `retention-${index}`) + expect((await created.creation).historySeeded).toBe(true) + refs.push(...created.refs) + } + await collect() + expect(refs.map((ref) => ref.deref() === undefined)).toEqual(Array(12).fill(true)) + expect(host.listSessions()).toHaveLength(3) + expect(host.getSnapshot('retention-0')?.snapshotAnsi).toContain('retention-seed') + } finally { + await host.dispose() + } + }) + + it('retains inputs during spawn and releases them after publication', async () => { + const gate = Promise.withResolvers() + const host = new TerminalHost({ + spawnSubprocess: async () => { + await gate.promise + return subprocess() + } + }) + const created = startWithInputs(host, 'pending') + try { + await collect() + expect(created.refs.map((ref) => ref.deref() !== undefined)).toEqual(Array(4).fill(true)) + gate.resolve() + expect((await created.creation).isNew).toBe(true) + await collect() + expect(created.refs.map((ref) => ref.deref() === undefined)).toEqual(Array(4).fill(true)) + expect(host.listSessions()).toHaveLength(1) + } finally { + gate.resolve() + await created.creation + await host.dispose() + } + }) + + it('reaps exited sessions, preserves exit evidence and releases claimed generations', async () => { + const handles: ReturnType[] = [] + const reaped: string[] = [] + const host = new TerminalHost({ + spawnSubprocess: async () => { + const handle = subprocess() + handles.push(handle) + return handle + }, + onSessionReaped: (sessionId) => reaped.push(sessionId) + }) + const options = { + sessionId: 'claimed', + cols: 80, + rows: 24, + streamClient, + agentSessionEnsure: { + claim: { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' as const + }, + surface: { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + } + try { + const first = await host.createOrAttach(options) + handles[0]?.emitExit(7) + expect(reaped).toEqual(['claimed']) + expect(host.listSessions()).toEqual([]) + expect( + await host.inspectProcess('claimed', { expectedIncarnationId: first.incarnationId }) + ).toMatchObject({ + foregroundProcessEvidence: { + verdict: 'exited', + reason: 'pty_exit_7', + ptyIncarnationId: first.incarnationId + } + }) + const second = await host.createOrAttach(options) + expect(second.agentSessionEnsure?.disposition).toBe('created') + expect(second.incarnationId).not.toBe(first.incarnationId) + expect(second.agentSessionEnsure?.owner.generation).not.toBe( + first.agentSessionEnsure?.owner.generation + ) + expect(handles).toHaveLength(2) + } finally { + await host.dispose() + } + expect(reaped).toEqual(['claimed', 'claimed']) + }) + + it('confirms shell recovery with the subprocess receiver and releases queued output', async () => { + let confirmations = 0 + const gate = Promise.withResolvers() + const handle = { + ...subprocess(), + confirmShellForeground() { + expect(this).toBe(handle) + confirmations += 1 + return gate.promise + } + } + const host = new TerminalHost({ spawnSubprocess: async () => handle }) + try { + await host.createOrAttach({ sessionId: 'recovery', cols: 80, rows: 24, streamClient }) + handle.emitData('\x1b[?1049hTUI\x1b]133;D;137\x07SHELL-PROMPT') + await vi.waitFor(() => expect(confirmations).toBe(1)) + gate.resolve(true) + const snapshot = await host.getSettledSnapshot('recovery') + expect(snapshot?.terminalOwner).toBe('shell') + expect(snapshot?.snapshotAnsi).toContain('SHELL-PROMPT') + } finally { + gate.resolve(false) + await host.dispose() + } + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 95bedd1a7fd..9c164354564 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -54,7 +54,6 @@ export class TerminalHost { private onSessionReaped: TerminalHostOptions['onSessionReaped'] private reportReadinessEvent: TerminalHostOptions['reportReadinessEvent'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] - private maxTombstones: number private creationFenced = false private disposePromise: Promise | null = null private readonly agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() @@ -71,8 +70,7 @@ export class TerminalHost { this.onSessionReaped = opts.onSessionReaped this.reportReadinessEvent = opts.reportReadinessEvent this.onFinalCheckpoint = opts.onFinalCheckpoint - this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES - this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) + this.killedTombstones = new TerminalHostTombstones(opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES) } async createOrAttach(opts: InternalCreateOrAttachOptions): Promise { @@ -123,20 +121,7 @@ export class TerminalHost { ...(this.reportReadinessEvent ? { reportReadinessEvent: this.reportReadinessEvent } : {}), - onSessionExit: (sessionId, generation) => { - const session = this.sessions.get(sessionId) - if (session) { - pruneRetiredPtyIncarnations(this.retiredIncarnations) - this.retiredIncarnations.set(sessionId, { - incarnationId: session.incarnationId, - code: session.exitCode ?? 0, - expiresAt: Date.now() + REMOTE_FOREGROUND_TOMBSTONE_RETENTION_MS - }) - } - this.agentSessionOwners.release(sessionId, generation) - this.agentSessionGenerations.forget(sessionId, generation) - this.reapSession(sessionId) - } + onSessionExit: this.handleSessionExit.bind(this) }) } }) @@ -146,6 +131,21 @@ export class TerminalHost { } } + private handleSessionExit(sessionId: string, generation: string | undefined): void { + const session = this.sessions.get(sessionId) + if (session) { + pruneRetiredPtyIncarnations(this.retiredIncarnations) + this.retiredIncarnations.set(sessionId, { + incarnationId: session.incarnationId, + code: session.exitCode ?? 0, + expiresAt: Date.now() + REMOTE_FOREGROUND_TOMBSTONE_RETENTION_MS + }) + } + this.agentSessionOwners.release(sessionId, generation) + this.agentSessionGenerations.forget(sessionId, generation) + this.reapSession(sessionId) + } + private assertCreateOrAttachAllowed(opts: InternalCreateOrAttachOptions): void { if (this.creationFenced) { throw new Error('Terminal host is shutting down')