From 14f56edbcf1ec8b9cfca2e4924212d4ec8d126de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:30:06 -0700 Subject: [PATCH] Scope host prune evidence per version --- .../daemon/daemon-host-relocation.test.ts | 27 +++++++++++--- src/main/daemon/daemon-host-relocation.ts | 37 +++++++++++-------- src/main/daemon/daemon-process-inspection.ts | 16 ++++++++ 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 0396a921d44..e2ae3fe7c6e 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -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' }] + ]) + }) }) }) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 835878ceb2f..edb8de9981b 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -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 } + | { status: 'complete'; versionLiveness: ReadonlyMap } | { 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() + const versionLiveness = new Map() 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 { diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 933947d523f..c293f98957c 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -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 { try { return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') }