Scope host prune evidence per version

This commit is contained in:
Brennan Benson
2026-08-27 17:30:06 -07:00
parent bc176dafc0
commit 14f56edbcf
3 changed files with 59 additions and 21 deletions
+22 -5
View File
@@ -268,7 +268,10 @@ describe('pruneOldDaemonHosts', () => {
for (const v of ['9.9.9', '1.0.0', '2.0.0']) {
mkdirSync(join(root, v), { recursive: true })
}
pruneOldDaemonHosts({ status: 'complete', pinnedVersions: new Set(['2.0.0']) })
pruneOldDaemonHosts({
status: 'complete',
versionLiveness: new Map([['2.0.0', { status: 'live' }]])
})
expect(existsSync(join(root, '9.9.9'))).toBe(true)
expect(existsSync(join(root, '2.0.0'))).toBe(true)
expect(existsSync(join(root, '1.0.0'))).toBe(false)
@@ -288,6 +291,10 @@ describe('pruneOldDaemonHosts', () => {
})
const evidence = collectPinnedDaemonVersions(runtimeDir)
expect(evidence).toEqual({
status: 'complete',
versionLiveness: new Map([['8.0.0', { status: 'live' }]])
})
pruneOldDaemonHosts(evidence)
expect(existsSync(join(root, '8.0.0'))).toBe(true)
@@ -298,6 +305,7 @@ describe('pruneOldDaemonHosts', () => {
const root = join(localAppDataDir, 'Orca', 'daemon-host')
const runtimeDir = join(userDataDir, 'daemon')
mkdirSync(join(root, '7.0.0'), { recursive: true })
mkdirSync(join(root, '6.0.0'), { recursive: true })
mkdirSync(runtimeDir, { recursive: true })
writeFileSync(
join(runtimeDir, 'daemon-v7.pid'),
@@ -309,12 +317,15 @@ describe('pruneOldDaemonHosts', () => {
const evidence = collectPinnedDaemonVersions(runtimeDir)
expect(evidence).toEqual({
status: 'unverifiable',
reason: 'the daemon process could not be queried'
status: 'complete',
versionLiveness: new Map([
['7.0.0', { status: 'unverifiable', reason: 'the daemon process could not be queried' }]
])
})
pruneOldDaemonHosts(evidence)
expect(existsSync(join(root, '7.0.0'))).toBe(true)
expect(existsSync(join(root, '6.0.0'))).toBe(false)
killSpy.mockRestore()
})
@@ -323,7 +334,7 @@ describe('pruneOldDaemonHosts', () => {
mkdirSync(join(root, '1.0.0'), { recursive: true })
hostApp.appPath = join(installDir, 'resources', 'app')
installHostApp()
pruneOldDaemonHosts({ status: 'complete', pinnedVersions: new Set() })
pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map() })
// A Node host owns no daemon-host tree, so deleting under it would be reaching into a
// directory layout it never created.
expect(existsSync(join(root, '1.0.0'))).toBe(true)
@@ -343,6 +354,12 @@ describe('collectPinnedDaemonVersions', () => {
JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' })
)
const pinned = collectPinnedDaemonVersions(runtimeDir)
expect(pinned).toEqual({ status: 'complete', pinnedVersions: new Set(['7.0.0']) })
expect(pinned).toEqual({
status: 'complete',
versionLiveness: new Map([
['7.0.0', { status: 'live' }],
['6.0.0', { status: 'exited' }]
])
})
})
})
+21 -16
View File
@@ -11,8 +11,9 @@ import {
} from 'node:fs'
import { dirname, join, win32 as winPath } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types'
import { parseDaemonPidFile } from './daemon-pid-file-parse'
import { inspectProcessLiveness } from './daemon-process-inspection'
import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-process-inspection'
/**
* Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it
@@ -294,7 +295,7 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null {
}
export type PinnedDaemonVersionsEvidence =
| { status: 'complete'; pinnedVersions: ReadonlySet<string> }
| { status: 'complete'; versionLiveness: ReadonlyMap<string, ProcessLivenessVerdict> }
| { status: 'unverifiable'; reason: string }
/**
@@ -302,7 +303,7 @@ export type PinnedDaemonVersionsEvidence =
* host dir must not be reclaimed while alive. On win32 start-time can't verify, so a matching pid pins conservatively.
*/
export function collectPinnedDaemonVersions(runtimeDir: string): PinnedDaemonVersionsEvidence {
const pinned = new Set<string>()
const versionLiveness = new Map<string, ProcessLivenessVerdict>()
let entries
try {
entries = readdirSync(runtimeDir, { withFileTypes: true })
@@ -333,19 +334,12 @@ export function collectPinnedDaemonVersions(runtimeDir: string): PinnedDaemonVer
continue
}
const verdict = inspectProcessLiveness(parsed.pid)
switch (verdict.status) {
case 'live':
pinned.add(parsed.appVersion)
break
case 'exited':
break
case 'unverifiable':
return verdict
default:
verdict satisfies never
}
versionLiveness.set(
parsed.appVersion,
mergeProcessLivenessVerdict(versionLiveness.get(parsed.appVersion), verdict)
)
}
return { status: 'complete', pinnedVersions: pinned }
return { status: 'complete', versionLiveness }
}
/**
@@ -368,9 +362,20 @@ export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): voi
return
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === version || evidence.pinnedVersions.has(entry.name)) {
if (!entry.isDirectory() || entry.name === version) {
continue
}
// A complete runtime-dir listing with no pid record for this version proves it is unowned.
const verdict = evidence.versionLiveness.get(entry.name) ?? { status: 'exited' }
switch (verdict.status) {
case 'live':
case 'unverifiable':
continue
case 'exited':
break
default:
verdict satisfies never
}
try {
rmSync(join(root, entry.name), { recursive: true, force: true })
} catch {
@@ -48,6 +48,22 @@ export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict {
}
}
export function mergeProcessLivenessVerdict(
current: ProcessLivenessVerdict | undefined,
next: ProcessLivenessVerdict
): ProcessLivenessVerdict {
switch (next.status) {
case 'live':
return next
case 'unverifiable':
return current?.status === 'live' ? current : next
case 'exited':
return current ?? next
default:
return next satisfies never
}
}
export async function readLinuxStat(pid: number): Promise<LinuxStatEvidence> {
try {
return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') }