fix: release retired shared daemon owner metadata

This commit is contained in:
m4air
2026-09-16 22:04:26 -07:00
parent 291b4ddd6f
commit 4e106236a8
14 changed files with 8412 additions and 0 deletions
@@ -0,0 +1,67 @@
# Shared daemon owner incarnation retention
A degraded daemon provider creates two owner resolvers with one shared route map. On an authenticated daemon identity change, the attach resolver removes that daemons routes first. The liveness resolver then sees no corresponding routes and previously left its private session-to-incarnation entries behind. Repeating replacements with newly discovered session IDs grows that private map for the lifetime of the degraded provider.
The fix removes private incarnation entries whose shared route is absent after provider invalidation. It preserves every remaining route, including another providers live session and a same-ID successor. It does not change process liveness, stop remote work, change the wire protocol, or depend on a git workspace.
## Actual ownership and trigger
- `src/main/daemon/daemon-provider-init.ts:123` selects `DegradedDaemonPtyProvider` for `degraded-new-pty-fallback`; startup discovery runs at line 139.
- `src/main/daemon/degraded-daemon-owner-recovery.ts:15` constructs both resolvers with the same map; public discovery and liveness probes populate their private indexes. Startup reconciliation can also record both routes.
- `src/main/daemon/degraded-daemon-owner-recovery.ts:70` subscribes to each daemons identity publication and invalidates the attach resolver before the liveness resolver.
- `src/main/daemon/daemon-pty-connection-lifecycle.ts:41` publishes only after a different authenticated identity replaces a previous identity. Repeated observation of the same identity does not retire anything.
- `src/main/daemon/daemon-pty-daemon-recovery.ts:268` can replace the daemon while retaining its adapter and the degraded provider.
- `src/main/daemon/daemon-session-owner-resolution.ts:44` performs the invalidation and the new private-metadata prune.
This is a local desktop main-process degraded-provider path. Loss of SSH contact is not its retirement trigger. Entry counts below do not establish retained bytes, RSS, an OOM, or causation for #19831.
## Bounded actual-source proof
The fixture uses the actual degraded provider, recovery controller, resolvers, daemon adapter inventory, authenticated identity publication, and direct attach implementation. Only finite authenticated transport replies and the empty fallback provider are inert; it starts no native PTY, socket, network connection, or application window. It does not depend on garbage-collection timing or a never-settling promise.
Each of 32 cycles discovers a new current-daemon session, populates both resolvers through public calls, observes an unchanged identity, then publishes a replacement identity. An unrelated legacy-daemon session remains live throughout. Finally, an ordinary legacy exit removes its route from both resolvers.
| After 32 replacements and the legacy exit | Baseline | Fixed |
| ----------------------------------------- | -------: | ----: |
| Shared routes | 0 | 0 |
| Attach resolver incarnation entries | 0 | 0 |
| Liveness resolver incarnation entries | 32 | 0 |
Additional assertions preserve a same-ID successor on another provider, an unchanged authenticated identity, direct attach with a matching authoritative incarnation without inventory, refusal of a mismatched authoritative incarnation, and ordinary exit cleanup. The permanent tests include the repetition regression and three compatibility controls; the portable fixture additionally exercises the actual adapter attach transport path.
## Reproduce
Run from the repository root with its dependencies installed:
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/daemon-shared-owner-incarnation-retention/reproduce.cjs
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts
```
The runner accepts an optional output filename as its first argument. On macOS, the Electron runtime control is:
```sh
ORCA_BACKGROUND_LAUNCH=1 ELECTRON_RUN_AS_NODE=1 node_modules/electron/dist/Electron.app/Contents/MacOS/Electron docs/audits/daemon-shared-owner-incarnation-retention/reproduce.cjs
```
On Linux or Windows, use the corresponding installed Electron binary with the same environment variables. It runs as Node and never displays a window.
The baseline test overlay reverses only the fenced product patch in memory:
```sh
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/daemon-shared-owner-incarnation-retention/before.config.mjs src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts src/main/daemon/daemon-session-owner-resolution.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts
```
Expected: exactly the new repeated-retirement assertion fails before the fix; the other 53 tests pass. All 54 pass with the fix.
## Source identities and publication independence
`sources.cjs` checks the exact fixed source hash, reverses `fix.patch`, checks the baseline hash, and fences every evaluated TypeScript dependency. It records actual evaluated and input hashes and a bundle hash in each report. A CRLF control checks source and patch normalization. The default runner needs neither Git history nor ignored audit notes.
`source-versions.json` records the audited source graph (276 modules) and the independent main graph at `291b4ddd6f1c1af480169885e0fda7f9c78ff053` (274 modules). Both graphs are accepted explicitly; ten surrounding modules differ because of unrelated audit fixes. The proof therefore does not require those fixes to be stacked. The publication reports were produced through the exported `run({ readSource, output, sourceLabel })` API, reading each non-target source from that named main revision and applying only this product change. The default command also runs directly on that publication tree with the fix and artifact installed.
Node 26.6.0 and Electron 43.7.0 / Node 24.21.0 both produced the table above against both source graphs. All four executions used the working installations external packages. These are source overlays, not historical application or dependency installations.
At reported v1.4.198 (`e0826956fcfc532f5a1e55b5e081f2e57e553c43`), the resolver, shared recovery controller, and authenticated identity publication match the recorded baseline exactly. The surrounding degraded provider differs, as recorded in `historicalCore`; no whole-v1.4.198 execution or incident attribution is claimed.
`validation.json` records tests, typecheck, full-file artifact quality, and limits. The four result files contain measured entry counts and exact source/artifact identities.
@@ -0,0 +1,23 @@
import { resolve } from 'node:path'
import { createRequire } from 'node:module'
import { defineConfig, mergeConfig } from 'vitest/config'
import baseConfig from '../../../config/vitest.config.ts'
const { loadSources } = createRequire(import.meta.url)('./sources.cjs')
const { before } = loadSources()
const sourcePath = resolve('src/main/daemon/daemon-session-owner-resolution.ts')
export default mergeConfig(
baseConfig,
defineConfig({
plugins: [
{
name: 'shared-owner-incarnation-before-fix',
enforce: 'pre',
transform(_code, id) {
return resolve(id.split('?')[0]) === sourcePath ? { code: before, map: null } : undefined
}
}
]
})
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts
index 1906ebfb35..47fe531ec7 100644
--- a/src/main/daemon/daemon-session-owner-resolution.ts
+++ b/src/main/daemon/daemon-session-owner-resolution.ts
@@ -54,0 +55,6 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
+ // Another resolver may already have removed this provider's shared routes.
+ for (const sessionId of this.routeIncarnations.keys()) {
+ if (!this.routes.has(sessionId)) {
+ this.routeIncarnations.delete(sessionId)
+ }
+ }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const { load, loadSources, read, sha } = require('./sources.cjs')
const { exercise } = require('./scenario.cjs')
async function run({ readSource = read, output, sourceLabel = 'working-tree' } = {}) {
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
const phases = {}
for (const phase of ['before', 'fixed']) {
const loaded = await load(phase, readSource)
phases[phase] = { ...(await exercise(loaded.api, phase)), provenance: loaded.provenance }
}
let crlfReads = 0
const crlf = loadSources((file) => {
crlfReads += 1
return readSource(file).replaceAll('\n', '\r\n')
})
assert.deepEqual(crlf, loadSources(readSource))
assert.equal(crlfReads, 2)
const artifacts = [
'sources.cjs',
'scenario.cjs',
'reproduce.cjs',
'before.config.mjs',
'source-versions.json',
'fix.patch'
]
const result = {
scope:
'Actual degraded provider/recovery/resolvers, adapter inventory, identity publication and direct attach; finite inert authenticated transport replies, no native PTY or network',
runtime: process.versions,
sourceLabel,
crlfReads,
artifactHashes: Object.fromEntries(
artifacts.map((file) => [file, sha(read(path.join(__dirname, file)))])
),
phases
}
const filename =
output ??
path.join(__dirname, process.versions.electron ? 'electron-results.json' : 'node-results.json')
fs.writeFileSync(filename, `${JSON.stringify(result, null, 2)}\n`)
console.log(
JSON.stringify({
output: filename,
before: phases.before.afterLegacyExit,
fixed: phases.fixed.afterLegacyExit,
sourceLabel
})
)
return result
}
module.exports = { run }
if (require.main === module) {
run({ output: process.argv[2] }).catch((error) => {
console.error(error)
process.exitCode = 1
})
}
@@ -0,0 +1,182 @@
const assert = require('node:assert/strict')
const path = require('node:path')
function identity(epoch, pid) {
return { pid, startedAtMs: epoch + 1, launchNonce: `daemon-${pid}-${epoch}` }
}
function makeAdapter(api, name, pid) {
const adapter = new api.DaemonPtyAdapter({
socketPath: path.join(__dirname, `${name}.sock`),
tokenPath: path.join(__dirname, `${name}.token`)
})
let sessions = []
const requests = []
adapter.client.daemonIdentity = identity(0, pid)
// Only the authenticated transport ports are inert; inventory and identity publication are actual methods.
adapter.client.ensureConnected = async () => {}
adapter.client.ensureConnectedWithin = async () => {}
adapter.client.request = async (type, payload) => {
requests.push(type)
if (type === 'listSessions') {
return { sessions }
}
assert.equal(type, 'createOrAttach')
assert.equal(payload.attachOnly, true)
const found = sessions.find((item) => item.sessionId === payload.sessionId)
assert(found)
return {
isNew: false,
snapshot: null,
pid: found.pid,
incarnationId: found.incarnationId,
shellState: 'unsupported'
}
}
return {
adapter,
requests,
setSessions(value) {
sessions = value
},
publishIdentity(epoch) {
adapter.client.daemonIdentity = identity(epoch, pid)
return adapter.establishLifecycleLease()
}
}
}
function session(id, incarnationId) {
return {
sessionId: id,
incarnationId,
isAlive: true,
pid: 999999999,
cwd: '/fixture',
cols: 80,
rows: 24
}
}
async function exercise(api, phase) {
const current = makeAdapter(api, 'current', 999999997)
const legacy = makeAdapter(api, 'legacy', 999999998)
const fallback = {
onData: () => () => {},
onExit: () => () => {},
hasPty: () => false,
listProcesses: async () => []
}
const provider = new api.DegradedDaemonPtyProvider({
current: current.adapter,
legacy: [legacy.adapter],
fallback
})
const recovery = provider.ownerRecovery
const attach = recovery.attachResolver
const liveness = recovery.livenessResolver
const rows = []
try {
await current.publishIdentity(0)
await legacy.publishIdentity(0)
legacy.setSessions([session('legacy-live', 'legacy-incarnation')])
for (let cycle = 0; cycle < 32; cycle++) {
const id = `current-${cycle}`
current.setSessions([session(id, `incarnation-${cycle}`)])
// Public discovery populates attach authority; public liveness fills the other resolver.
await provider.discoverDaemonSessions()
assert.equal(await provider.probePtyLiveness(`unmapped-probe-${cycle}`), false)
assert.equal(attach.routeIncarnations.get(id), `incarnation-${cycle}`)
assert.equal(liveness.routeIncarnations.get(id), `incarnation-${cycle}`)
assert.equal(provider.sessionProviders.get(id), current.adapter)
const beforeDuplicate = liveness.routeIncarnations.size
await current.publishIdentity(cycle)
assert.equal(liveness.routeIncarnations.size, beforeDuplicate)
// A new authenticated identity retires the old daemon's routes through actual listeners.
current.setSessions([])
await current.publishIdentity(cycle + 1)
assert.equal(provider.sessionProviders.has(id), false)
assert.equal(attach.routeIncarnations.has(id), false)
assert.equal(liveness.routeIncarnations.has(id), phase === 'before')
assert.equal(provider.sessionProviders.get('legacy-live'), legacy.adapter)
assert.equal(attach.routeIncarnations.get('legacy-live'), 'legacy-incarnation')
assert.equal(liveness.routeIncarnations.get('legacy-live'), 'legacy-incarnation')
rows.push({
cycle,
sharedRoutes: provider.sessionProviders.size,
attachEntries: attach.routeIncarnations.size,
livenessEntries: liveness.routeIncarnations.size
})
}
assert.equal(provider.sessionProviders.size, 1)
assert.equal(attach.routeIncarnations.size, 1)
assert.equal(liveness.routeIncarnations.size, phase === 'before' ? 33 : 1)
legacy.adapter.client.eventListeners.each((listener) =>
listener({
type: 'event',
event: 'exit',
sessionId: 'legacy-live',
payload: { code: 0, incarnationId: 'legacy-incarnation' }
})
)
assert.equal(provider.sessionProviders.size, 0)
assert.equal(attach.routeIncarnations.size, 0)
assert.equal(liveness.routeIncarnations.size, phase === 'before' ? 32 : 0)
const afterLegacyExit = {
sharedRoutes: provider.sessionProviders.size,
attachEntries: attach.routeIncarnations.size,
livenessEntries: liveness.routeIncarnations.size
}
legacy.setSessions([])
current.setSessions([session('same-id', 'old-incarnation')])
await provider.discoverDaemonSessions()
await provider.probePtyLiveness('unmapped-old')
current.setSessions([])
legacy.setSessions([session('same-id', 'new-incarnation')])
await provider.discoverDaemonSessions()
await provider.probePtyLiveness('unmapped-new')
await current.publishIdentity(33)
assert.equal(provider.sessionProviders.get('same-id'), legacy.adapter)
assert.equal(attach.routeIncarnations.get('same-id'), 'new-incarnation')
assert.equal(liveness.routeIncarnations.get('same-id'), 'new-incarnation')
current.requests.length = 0
legacy.requests.length = 0
const attached = await provider.spawn({
sessionId: 'same-id',
attachOnly: true,
cols: 80,
rows: 24,
expectedIncarnationId: 'new-incarnation',
expectedIncarnationIsAuthoritative: true
})
assert.equal(attached.id, 'same-id')
assert.equal(attached.incarnationId, 'new-incarnation')
assert.equal(attached.isReattach, true)
assert.deepEqual(current.requests, [])
assert.deepEqual(legacy.requests, ['createOrAttach'])
await assert.rejects(
provider.spawn({
sessionId: 'same-id',
attachOnly: true,
cols: 80,
rows: 24,
expectedIncarnationId: 'retired-incarnation',
expectedIncarnationIsAuthoritative: true
}),
{ name: 'TerminalSessionOwnerUnverifiedError' }
)
assert.equal(legacy.requests.filter((type) => type === 'createOrAttach').length, 1)
return {
cycles: 32,
rows,
afterLegacyExit,
sameIdSuccessorPreserved: true,
matchingDirectAttachWithoutInventory: true,
authoritativeIncarnationMismatchRefused: true,
unchangedIdentityPreserved: true,
otherLiveProviderPreserved: true,
ordinaryExitRetiresBoth: true
}
} finally {
provider.dispose()
}
}
module.exports = { exercise }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const Module = require('node:module')
const { createHash } = require('node:crypto')
const { build } = require('esbuild')
const { applyPatch, parsePatch, reversePatch } = require('diff')
const root = path.resolve(__dirname, '../../..')
const canonicalLf = (value) => value.replaceAll('\r\n', '\n')
const read = (file) => canonicalLf(fs.readFileSync(file, 'utf8'))
const sha = (value) => createHash('sha256').update(value).digest('hex')
const versions = JSON.parse(read(path.join(__dirname, 'source-versions.json')))
const relative = (file) => path.relative(root, file).split(path.sep).join('/')
function loadSources(readSource = read) {
const fixed = canonicalLf(readSource(path.join(root, versions.sourcePath)))
assert.equal(sha(fixed), versions.fixedSha256)
const patches = parsePatch(canonicalLf(readSource(path.join(__dirname, 'fix.patch'))))
assert.equal(patches.length, 1)
assert.equal(patches[0].newFileName, `b/${versions.sourcePath}`)
const before = applyPatch(fixed, reversePatch(patches[0]))
assert.notEqual(before, false)
assert.equal(sha(before), versions.baselineSha256)
return { before, fixed }
}
async function load(phase, readSource = read) {
assert.ok(['before', 'fixed'].includes(phase))
const checked = loadSources(readSource)
const evaluatedSources = {}
const provenanceSources = {}
const built = await build({
stdin: {
contents: [
"export { DaemonPtyAdapter } from './src/main/daemon/daemon-pty-adapter'",
"export { DegradedDaemonPtyProvider } from './src/main/daemon/degraded-daemon-pty-provider'"
].join('\n'),
resolveDir: root,
loader: 'ts'
},
absWorkingDir: root,
bundle: true,
platform: 'node',
format: 'cjs',
packages: 'external',
write: false,
plugins: [
{
name: 'hash-fenced-owner-incarnation-sources',
setup(builder) {
builder.onResolve({ filter: /^\./ }, (args) => {
const base = path.resolve(args.resolveDir, args.path)
for (const file of [base, `${base}.ts`, path.join(base, 'index.ts')]) {
const key = relative(file)
if (key === versions.sourcePath || Object.hasOwn(versions.dependencies, key)) {
return { path: file }
}
}
return undefined
})
builder.onLoad({ filter: /\.ts$/ }, ({ path: file }) => {
const key = relative(file)
let contents = canonicalLf(readSource(file))
const actual = sha(contents)
provenanceSources[key] = actual
if (key === versions.sourcePath) {
assert.equal(actual, versions.fixedSha256)
contents = checked[phase]
} else {
assert.ok(versions.dependencies[key]?.includes(actual), `Dependency drift: ${key}`)
}
evaluatedSources[key] = sha(contents)
return { contents, loader: 'ts' }
})
}
}
]
})
const evaluatedKeys = Object.keys(evaluatedSources).sort()
const recognizedGraph = [versions.workingEvaluated, versions.publicationEvaluated].some(
(known) => JSON.stringify(Object.keys(known).sort()) === JSON.stringify(evaluatedKeys)
)
assert.equal(recognizedGraph, true, 'Unreviewed evaluated module graph')
const filename = path.join(__dirname, `${phase}-bundle.cjs`)
const loaded = new Module(filename, module)
loaded.filename = filename
loaded.paths = Module._nodeModulePaths(root)
loaded._compile(built.outputFiles[0].text, filename)
return {
api: loaded.exports,
provenance: {
evaluatedSources,
provenanceSources,
bundleSha256: sha(built.outputFiles[0].text)
}
}
}
module.exports = { load, loadSources, read, sha, root, versions }
@@ -0,0 +1,88 @@
{
"backgroundLaunch": "All tests and proofs used ORCA_BACKGROUND_LAUNCH=1; Electron additionally used ELECTRON_RUN_AS_NODE=1. No application, native PTY, socket or network.",
"fixedTests": {
"passed": 54,
"failed": 0,
"files": 3,
"newTests": 4,
"config": "config/vitest.config.ts"
},
"baselineOverlay": {
"passed": 53,
"failed": 1,
"config": "docs/audits/daemon-shared-owner-incarnation-retention/before.config.mjs",
"intendedFailure": "releases both private indexes after repeated authenticated daemon replacements",
"actualLivenessKeys": ["retired-0", "legacy-live"],
"expectedLivenessKeys": ["legacy-live"]
},
"portableProofs": {
"reports": [
"node-results.json",
"electron-results.json",
"publication-node-results.json",
"publication-electron-results.json"
],
"phasesPerReport": ["before", "fixed"],
"replacementCyclesPerPhase": 32,
"afterLegacyExit": {
"before": {
"sharedRoutes": 0,
"attachEntries": 0,
"livenessEntries": 32
},
"fixed": {
"sharedRoutes": 0,
"attachEntries": 0,
"livenessEntries": 0
}
},
"workingEvaluatedModules": 276,
"publicationEvaluatedModules": 274,
"crlfSourceAndPatchReads": 2,
"controls": [
"unchanged authenticated identity",
"other live provider",
"ordinary exit",
"same-ID successor",
"matching direct attach without inventory",
"authoritative incarnation mismatch refusal"
]
},
"typechecks": {
"node": "Passed full pnpm tc:node."
},
"fullPublicationQuality": {
"paths": [
"src/main/daemon/daemon-session-owner-resolution.ts",
"src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts",
"docs/audits/daemon-shared-owner-incarnation-retention/sources.cjs",
"docs/audits/daemon-shared-owner-incarnation-retention/scenario.cjs",
"docs/audits/daemon-shared-owner-incarnation-retention/reproduce.cjs",
"docs/audits/daemon-shared-owner-incarnation-retention/before.config.mjs"
],
"scans": [
"default rules and unused suppression",
"casting",
"type-aware quality",
"React Doctor",
"design system",
"default type-aware rules"
],
"result": "All six full-file scans passed with --no-ignore --deny-warnings, including CJS/MJS artifact files."
},
"changedQuality": {
"base": "6ab6802df90553d2a26e9a4c7a519ae11d4b5e09",
"result": "Passed all changed-code scans plus SAFETY rationale across two changed source files. Artifacts separately covered by explicit full-file scans."
},
"productHashes": [
{
"path": "src/main/daemon/daemon-session-owner-resolution.ts",
"sha256": "8f62a181a398dc91d20d269a19e3266e199191486fddfee6dcaf3b2a570c76eb"
},
{
"path": "src/main/daemon/daemon-shared-owner-incarnation-retention.test.ts",
"sha256": "b88680e374c054143c344444b98e80368c05736add90698f9e540d2a4420265f"
}
],
"limits": "Entry-count retention proof, no byte/RSS/OOM/incident claim. Finite inert transport replies. Named main source overlays use working external dependencies; v1.4.198 checked core source parity only."
}
@@ -52,6 +52,12 @@ export class DaemonSessionOwnerResolver<T extends IPtyProvider> {
this.routeIncarnations.delete(sessionId)
}
}
// Another resolver may already have removed this provider's shared routes.
for (const sessionId of this.routeIncarnations.keys()) {
if (!this.routes.has(sessionId)) {
this.routeIncarnations.delete(sessionId)
}
}
}
async spawnAttachOnly(opts: PtySpawnOptions & { sessionId: string }): Promise<PtySpawnResult> {
@@ -0,0 +1,157 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { PtyProcessInfo } from '../providers/types'
import { TerminalSessionOwnerUnverifiedError } from './daemon-errors'
const cleanups: (() => void)[] = []
afterEach(() => {
for (const cleanup of cleanups.splice(0)) {
cleanup()
}
vi.restoreAllMocks()
})
function adapterFixture(label: string, pid: number) {
const adapter = new DaemonPtyAdapter({
socketPath: join(tmpdir(), `unused-${label}.sock`),
tokenPath: join(tmpdir(), `unused-${label}.token`)
})
const client = adapter['client']
vi.spyOn(client, 'ensureConnected').mockResolvedValue()
vi.spyOn(client, 'ensureConnectedWithin').mockResolvedValue()
const identity = vi.spyOn(client, 'getDaemonIdentity')
const request = vi.spyOn(client, 'request').mockResolvedValue({ sessions: [] })
const spawn = vi.spyOn(adapter, 'spawn').mockImplementation(async (opts) => ({
id: opts.sessionId ?? 'unexpected-fresh-spawn',
incarnationId: opts.expectedIncarnationId,
isReattach: true
}))
return {
adapter,
request,
spawn,
setProcesses(processes: PtyProcessInfo[]) {
request.mockResolvedValue({
sessions: processes.map((process) => ({
sessionId: process.id,
incarnationId: process.incarnationId,
cwd: process.cwd,
isAlive: true
}))
})
},
async publishIdentity(generation: number) {
identity.mockReturnValue({
pid,
startedAtMs: generation + 1,
launchNonce: label + generation
})
await adapter.establishLifecycleLease()
}
}
}
async function fixture() {
const current = adapterFixture('current', 999_999_997)
const legacy = adapterFixture('legacy', 999_999_998)
const fallback = new LocalPtyProvider()
vi.spyOn(fallback, 'listProcesses').mockResolvedValue([])
const provider = new DegradedDaemonPtyProvider({
current: current.adapter,
legacy: [legacy.adapter],
fallback
})
cleanups.push(() => provider.dispose())
await current.publishIdentity(0)
await legacy.publishIdentity(0)
const recovery = provider['ownerRecovery']
return { current, legacy, provider, recovery }
}
function processInfo(id: string, incarnationId: string): PtyProcessInfo {
return { id, incarnationId, cwd: '', title: 'shell' }
}
describe('shared daemon owner incarnation retirement', () => {
it('releases both private indexes after repeated authenticated daemon replacements', async () => {
const { current, legacy, provider, recovery } = await fixture()
legacy.setProcesses([processInfo('legacy-live', 'legacy-incarnation')])
for (let generation = 0; generation < 16; generation++) {
const id = `retired-${generation}`
current.setProcesses([processInfo(id, `incarnation-${generation}`)])
await provider.discoverDaemonSessions()
await expect(provider.probePtyLiveness(`unmapped-${generation}`)).resolves.toBe(false)
expect(recovery['livenessResolver']['routeIncarnations'].get(id)).toBe(
`incarnation-${generation}`
)
current.setProcesses([])
await current.publishIdentity(generation + 1)
expect([...provider['sessionProviders'].keys()]).toEqual(['legacy-live'])
expect([...recovery['attachResolver']['routeIncarnations'].keys()]).toEqual(['legacy-live'])
expect([...recovery['livenessResolver']['routeIncarnations'].keys()]).toEqual(['legacy-live'])
}
})
it('preserves the same session ID after another provider publishes its successor', async () => {
const { current, legacy, provider, recovery } = await fixture()
current.setProcesses([processInfo('same-id', 'old-incarnation')])
await provider.discoverDaemonSessions()
await provider.probePtyLiveness('unmapped-old')
current.setProcesses([])
legacy.setProcesses([processInfo('same-id', 'new-incarnation')])
await provider.discoverDaemonSessions()
await provider.probePtyLiveness('unmapped-new')
await current.publishIdentity(1)
expect(provider['sessionProviders'].get('same-id')).toBe(legacy.adapter)
expect(recovery['attachResolver']['routeIncarnations'].get('same-id')).toBe('new-incarnation')
expect(recovery['livenessResolver']['routeIncarnations'].get('same-id')).toBe('new-incarnation')
})
it('keeps matching-incarnation direct attach without consulting another inventory', async () => {
const { current, legacy, provider } = await fixture()
legacy.setProcesses([processInfo('live', 'live-incarnation')])
await provider.discoverDaemonSessions()
await provider.probePtyLiveness('unmapped')
await current.publishIdentity(1)
current.request.mockClear()
legacy.request.mockClear()
await expect(
provider.spawn({
sessionId: 'live',
attachOnly: true,
cols: 80,
rows: 24,
expectedIncarnationId: 'live-incarnation',
expectedIncarnationIsAuthoritative: true
})
).resolves.toMatchObject({ id: 'live', incarnationId: 'live-incarnation', isReattach: true })
expect(current.request).not.toHaveBeenCalled()
expect(legacy.request).not.toHaveBeenCalled()
expect(current.spawn).not.toHaveBeenCalled()
expect(legacy.spawn).toHaveBeenCalledOnce()
})
it('retains authoritative incarnation mismatch refusal after another daemon changes', async () => {
const { current, legacy, provider } = await fixture()
legacy.setProcesses([processInfo('live', 'current-incarnation')])
await provider.discoverDaemonSessions()
await current.publishIdentity(1)
await expect(
provider.spawn({
sessionId: 'live',
attachOnly: true,
cols: 80,
rows: 24,
expectedIncarnationId: 'retired-incarnation',
expectedIncarnationIsAuthoritative: true
})
).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError)
expect(current.spawn).not.toHaveBeenCalled()
expect(legacy.spawn).not.toHaveBeenCalled()
})
})