fix: release completed terminal spawn inputs (#21139)

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-09-17 20:12:13 -07:00
committed by GitHub
co-authored by m4air
parent fbfe3a2e74
commit 79800e60b4
24 changed files with 2015 additions and 20 deletions
@@ -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:117122`). 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:4860` 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.
@@ -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()
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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<void> | 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<CreateOrAttachResult> {
@@ -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')
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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()
@@ -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."
}
}
@@ -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 }
@@ -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
}
+3 -2
View File
@@ -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 }
@@ -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.
@@ -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<void> {
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<void>((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<object>[] = []
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<void>()
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<typeof subprocess>[] = []
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<boolean>()
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()
}
})
})
+17 -17
View File
@@ -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<void> | 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<CreateOrAttachResult> {
@@ -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')