mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix: release completed terminal spawn inputs (#21139)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user