From 46adc1d4059f3fd9490d714bcdfecc5f89fe7963 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 00:17:01 -0700 Subject: [PATCH 1/2] feat(orcad): bound the desktop slot cache and prune proven-stopped orcad versions after each managed deploy --- .../orcad-artifact-cache-retention.test.ts | 95 ++++++++++++++++ .../orcad/orcad-artifact-cache-retention.ts | 106 ++++++++++++++++++ .../orcad-local-serve-selection-entry.ts | 5 + src/main/ssh/orcad-artifact-materializer.ts | 12 +- src/main/ssh/orcad-managed-version-gc.test.ts | 89 +++++++++++++++ src/main/ssh/orcad-managed-version-gc.ts | 49 ++++++++ src/main/ssh/orcad-runtime-deployment.ts | 7 ++ src/main/ssh/orcad-runtime-maintenance.ts | 10 +- .../startup/main-process-ready-runtime.ts | 6 + 9 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 src/main/orcad/orcad-artifact-cache-retention.test.ts create mode 100644 src/main/orcad/orcad-artifact-cache-retention.ts create mode 100644 src/main/ssh/orcad-managed-version-gc.test.ts create mode 100644 src/main/ssh/orcad-managed-version-gc.ts diff --git a/src/main/orcad/orcad-artifact-cache-retention.test.ts b/src/main/orcad/orcad-artifact-cache-retention.test.ts new file mode 100644 index 000000000000..53b96f9eb2f3 --- /dev/null +++ b/src/main/orcad/orcad-artifact-cache-retention.test.ts @@ -0,0 +1,95 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock' +import { + liveLocalOrcadServeVersion, + pruneOrcadArtifactCache +} from './orcad-artifact-cache-retention' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function cacheRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orcad-artifact-cache-')) + roots.push(root) + return root +} + +/** A slot dir last used `ageMinutes` ago. */ +function slot(root: string, target: string, name: string, ageMinutes: number): string { + const path = join(root, target, name) + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'orcad.js'), '') + const at = new Date(Date.now() - ageMinutes * 60_000) + utimesSync(path, at, at) + return path +} + +describe('orcad artifact cache retention', () => { + it('keeps the three most recently used slots per target and evicts older ones', async () => { + const root = cacheRoot() + const kept = [1, 2, 3].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age)) + const evicted = [4, 5].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age)) + const otherTarget = slot(root, 'darwin-arm64', 'v9', 9) + + expect((await pruneOrcadArtifactCache(root)).sort()).toEqual(evicted.sort()) + for (const path of [...kept, otherTarget]) { + expect(existsSync(path)).toBe(true) + } + for (const path of evicted) { + expect(existsSync(path)).toBe(false) + } + }) + + it('never evicts an in-use version, a repair copy of it, or a staging directory', async () => { + const root = cacheRoot() + for (const age of [1, 2, 3]) { + slot(root, 'linux-x64-glibc', `v${age}`, age) + } + const inUse = slot(root, 'linux-x64-glibc', 'v-old.repair-1', 50) + const staging = slot(root, 'linux-x64-glibc', '.staging-1-abc', 60) + + expect(await pruneOrcadArtifactCache(root, { inUseVersions: new Set(['v-old']) })).toEqual([]) + expect(existsSync(inUse)).toBe(true) + expect(existsSync(staging)).toBe(true) + }) + + it('leaves the runtime archive cache and a missing root alone', async () => { + const root = cacheRoot() + const archives = join(root, 'node', 'archives') + mkdirSync(archives, { recursive: true }) + for (const age of [1, 2, 3, 4]) { + slot(root, 'node', `archive-${age}`, age) + } + expect(await pruneOrcadArtifactCache(root)).toEqual([]) + expect(await pruneOrcadArtifactCache(join(root, 'missing'))).toEqual([]) + }) + + it('names the slot a live local orcad serve runs from, and nothing for the desktop', () => { + const userData = cacheRoot() + const lock = (role: string) => + writeFileSync( + join(userData, ORCAD_LOCK_FILE_NAME), + JSON.stringify({ + pid: process.pid, + startedAtMs: null, + nonce: 'nonce', + identity: 'uid', + version: '0.1.0+abc', + acquiredAt: new Date().toISOString(), + role + }) + ) + lock('orcad') + expect(liveLocalOrcadServeVersion(userData)).toBe('0.1.0+abc') + expect(liveLocalOrcadServeVersion(userData, () => false)).toBeNull() + lock('desktop') + expect(liveLocalOrcadServeVersion(userData)).toBeNull() + }) +}) diff --git a/src/main/orcad/orcad-artifact-cache-retention.ts b/src/main/orcad/orcad-artifact-cache-retention.ts new file mode 100644 index 000000000000..281d83739c28 --- /dev/null +++ b/src/main/orcad/orcad-artifact-cache-retention.ts @@ -0,0 +1,106 @@ +/** + * Bounds the desktop's `/orcad-artifacts` slot cache, the way VS Code bounds its + * server downloads: per target, keep the most recently used slots and evict the rest at + * startup. The cache survives uninstall (it lives in userData); this is what keeps it small. + * + * Never evicted: a slot this process materialized (an SSH deploy may be reading it) and the + * slot a live local orcad serve runs from, named by the profile's instance lock. + */ +import { readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' +import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer' + +/** The current slot plus the two most recent others. */ +export const ORCAD_ARTIFACT_CACHE_KEEP = 3 + +const REPAIR_SUFFIX = /\.repair-\d+$/u + +export type OrcadArtifactCachePruneOptions = { + keep?: number + /** Slot versions to keep whatever their age. */ + inUseVersions?: ReadonlySet +} + +/** Removes the stale slots under `cacheRoot` and returns their paths. Best effort per entry. */ +export async function pruneOrcadArtifactCache( + cacheRoot: string, + options: OrcadArtifactCachePruneOptions = {} +): Promise { + const keep = options.keep ?? ORCAD_ARTIFACT_CACHE_KEEP + const inUse = options.inUseVersions ?? new Set() + const removed: string[] = [] + for (const target of await listNames(cacheRoot)) { + // Only target directories hold slots; `node/` is the runtime archive cache. + if (!Object.hasOwn(NODE_RUNTIME_ASSETS, target)) { + continue + } + const targetRoot = join(cacheRoot, target) + const slots = await Promise.all( + // Dot entries are staging directories another process may be filling right now. + (await listNames(targetRoot)) + .filter((name) => !name.startsWith('.')) + .map(async (name) => ({ name, mtimeMs: await mtimeOf(join(targetRoot, name)) })) + ) + const byRecency = slots + .filter((slot): slot is { name: string; mtimeMs: number } => slot.mtimeMs !== null) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + for (const [index, slot] of byRecency.entries()) { + if (index < keep || inUse.has(slot.name.replace(REPAIR_SUFFIX, ''))) { + continue + } + const path = join(targetRoot, slot.name) + try { + await rm(path, { recursive: true, force: true }) + removed.push(path) + } catch (error) { + console.warn(`[orcad-artifacts] could not evict ${path}:`, error) + } + } + } + return removed +} + +/** The desktop's startup pass over its own `/orcad-artifacts`. */ +export function pruneDesktopOrcadArtifactCache(userDataPath: string): Promise { + const live = liveLocalOrcadServeVersion(userDataPath) + return pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), { + inUseVersions: new Set([...materializedOrcadArtifactVersions(), ...(live ? [live] : [])]) + }) +} + +/** The slot version a live local orcad serve runs from, or null when none holds the profile. */ +export function liveLocalOrcadServeVersion( + userDataPath: string, + isAlive: (pid: number) => boolean = processIsAlive +): string | null { + const record = readOrcadInstanceLockRecord(join(userDataPath, ORCAD_LOCK_FILE_NAME)) + return record && record.role !== 'desktop' && isAlive(record.pid) ? record.version : null +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return error instanceof Error && 'code' in error && error.code === 'EPERM' + } +} + +async function listNames(directory: string): Promise { + try { + return await readdir(directory) + } catch { + return [] + } +} + +async function mtimeOf(path: string): Promise { + try { + const info = await stat(path) + return info.isDirectory() ? info.mtimeMs : null + } catch { + return null + } +} diff --git a/src/main/orcad/orcad-local-serve-selection-entry.ts b/src/main/orcad/orcad-local-serve-selection-entry.ts index 6d90e7c65c3a..1cee48d08cfc 100644 --- a/src/main/orcad/orcad-local-serve-selection-entry.ts +++ b/src/main/orcad/orcad-local-serve-selection-entry.ts @@ -10,6 +10,7 @@ import { ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS } from '../../shared/orcad-local-serve-selection' import { selectServeRuntime } from './orcad-local-serve-selection' +import { pruneOrcadArtifactCache } from './orcad-artifact-cache-retention' function flagValue(argv: readonly string[], flag: string): string | null { const index = argv.indexOf(flag) @@ -32,6 +33,10 @@ async function run(argv: readonly string[]): Promise { ], usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff) }) + // orcad serve never starts the desktop's startup pass, so the slot cache is bounded here too. + await pruneOrcadArtifactCache(join(userDataPath, 'orcad-artifacts'), { + inUseVersions: new Set(selection.kind === 'orcad' ? [selection.version] : []) + }).catch(() => []) process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0)) } diff --git a/src/main/ssh/orcad-artifact-materializer.ts b/src/main/ssh/orcad-artifact-materializer.ts index 5ae3f0985ed6..1fe5d70711bf 100644 --- a/src/main/ssh/orcad-artifact-materializer.ts +++ b/src/main/ssh/orcad-artifact-materializer.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto' import { createReadStream, existsSync } from 'node:fs' -import { chmod, copyFile, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, mkdir, readFile, rename, rm, utimes, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { z } from 'zod' import { getAppEnvironment } from '../../shared/app-environment' @@ -49,6 +49,12 @@ type MaterializeOptions = PinnedRuntimeMaterializeOptions & { } const materializations = new Map>() +// Slots handed out this session: a deploy may still be reading one, so cache retention keeps them. +const materializedVersions = new Set() + +export function materializedOrcadArtifactVersions(): ReadonlySet { + return materializedVersions +} /** A verified slot directory for `target`; its runtime is referenced, not included (design D2). */ export async function materializeOrcadArtifact( @@ -88,7 +94,11 @@ export async function assembleOrcadArtifact(args: { (path) => isCompleteArtifact(path, fullVersion, sources, sourceHashes) ) const targetDir = cached.path + materializedVersions.add(fullVersion) if (cached.verified) { + // Retention evicts by recency, so a reused slot counts as recently used. + const now = new Date() + await utimes(targetDir, now, now).catch(() => undefined) return targetDir } await mkdir(targetRoot, { recursive: true }) diff --git a/src/main/ssh/orcad-managed-version-gc.test.ts b/src/main/ssh/orcad-managed-version-gc.test.ts new file mode 100644 index 000000000000..9e6cbf7ef957 --- /dev/null +++ b/src/main/ssh/orcad-managed-version-gc.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ServeReadiness } from '../server/serve-readiness' + +const { gcMock, readRecordMock } = vi.hoisted(() => ({ + gcMock: vi.fn(), + readRecordMock: vi.fn() +})) + +vi.mock('./orcad-remote-gc', () => ({ gcOldOrcadVersions: gcMock })) +vi.mock('./orcad-activation-record-store', () => ({ readOrcadActivationRecord: readRecordMock })) + +import { provenLiveDaemonVersion, pruneManagedOrcadVersions } from './orcad-managed-version-gc' + +function readiness(terminalDaemon: unknown): ServeReadiness { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only health.terminalDaemon is read. + return { health: terminalDaemon === undefined ? undefined : { terminalDaemon } } as ServeReadiness +} + +const slot = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: GC is mocked and never touches the connection. + conn: {} as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: GC is mocked; the slot's host is only passed through. + host: { os: 'linux', pathFlavor: 'posix', commandDialect: 'posix' } as never, + remoteHome: '/home/u', + nodePath: 'node', + userDataDir: '/home/u/.orca', + bindHost: '127.0.0.1', + port: 0 +} + +afterEach(() => { + vi.resetAllMocks() +}) + +describe('managed orcad version GC after a deploy', () => { + it('pins only a daemon version the readiness proves', () => { + expect(provenLiveDaemonVersion(readiness({ state: 'live', buildVersion: '1.0.0+a' }))).toBe( + '1.0.0+a' + ) + expect(provenLiveDaemonVersion(readiness({ state: 'absent', buildVersion: null }))).toBeNull() + expect( + provenLiveDaemonVersion(readiness({ state: 'degraded', buildVersion: '1' })) + ).toBeUndefined() + expect( + provenLiveDaemonVersion(readiness({ state: 'live', buildVersion: null })) + ).toBeUndefined() + expect(provenLiveDaemonVersion(readiness(undefined))).toBeUndefined() + }) + + it('runs GC with the live daemon pinned, and keeps everything when it cannot say', async () => { + readRecordMock.mockResolvedValue({ active: '2.0.0+b' }) + await pruneManagedOrcadVersions({ + slot, + serverTarget: 'linux-x64-glibc', + activeVersion: '2.0.0+b', + readiness: readiness({ state: 'live', buildVersion: '1.0.0+a' }) + }) + expect(gcMock).toHaveBeenCalledWith( + expect.objectContaining({ + liveDaemonVersion: '1.0.0+a', + record: { active: '2.0.0+b' }, + nodeRuntimePins: [expect.stringMatching(/^[0-9a-f]{64}$/u)] + }) + ) + + gcMock.mockClear() + await pruneManagedOrcadVersions({ + slot, + serverTarget: 'linux-x64-glibc', + activeVersion: '2.0.0+b', + readiness: readiness({ state: 'degraded', buildVersion: null }) + }) + expect(gcMock).not.toHaveBeenCalled() + }) + + it('never fails the deploy it follows', async () => { + readRecordMock.mockResolvedValue({ active: '2.0.0+b' }) + gcMock.mockRejectedValue(new Error('ssh dropped')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect( + pruneManagedOrcadVersions({ + slot, + serverTarget: 'linux-x64-glibc', + activeVersion: '2.0.0+b', + readiness: readiness({ state: 'absent', buildVersion: null }) + }) + ).resolves.toBeUndefined() + }) +}) diff --git a/src/main/ssh/orcad-managed-version-gc.ts b/src/main/ssh/orcad-managed-version-gc.ts new file mode 100644 index 000000000000..2ad50223385d --- /dev/null +++ b/src/main/ssh/orcad-managed-version-gc.ts @@ -0,0 +1,49 @@ +/** + * After a managed deploy or update, removes this host's older orcad version dirs that are + * proven stopped (design D10). Pins come from gcOldOrcadVersions: the active version, the + * rollback target, journaled versions, and the version the live terminal daemon was forked + * from. Anything the host cannot answer about is kept. + */ +import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import type { ServeReadiness } from '../server/serve-readiness' +import { readOrcadActivationRecord } from './orcad-activation-record-store' +import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' +import { gcOldOrcadVersions } from './orcad-remote-gc' + +/** The daemon's build when the readiness proves it; undefined when it cannot say. */ +export function provenLiveDaemonVersion(readiness: ServeReadiness): string | null | undefined { + const daemon = readiness.health?.terminalDaemon + if (daemon?.state === 'live' && daemon.buildVersion) { + return daemon.buildVersion + } + // A daemon that answered as absent forked from nothing; degraded or unreported proves nothing. + return daemon?.state === 'absent' ? null : undefined +} + +export async function pruneManagedOrcadVersions(args: { + slot: OrcadSlotOptions + serverTarget: ServerTarget + activeVersion: string + readiness: ServeReadiness +}): Promise { + const liveDaemonVersion = provenLiveDaemonVersion(args.readiness) + // Why skip: without the daemon's version, GC could remove the tree a live daemon runs from. + if (liveDaemonVersion === undefined) { + return + } + try { + await gcOldOrcadVersions({ + conn: args.slot.conn, + host: args.slot.host, + remoteHome: args.slot.remoteHome, + currentDirAbsPath: orcadSlotDir(args.slot, args.activeVersion), + record: await readOrcadActivationRecord(args.slot), + liveDaemonVersion, + nodeRuntimePins: [NODE_RUNTIME_ASSETS[args.serverTarget].executableSha256], + signal: args.slot.signal + }) + } catch (error) { + // Best effort: the deploy already succeeded, and the next connect tries again. + console.warn('[orcad-gc] Pruning old orcad versions failed:', error) + } +} diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index 086525e13b81..8eedcccbafed 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -24,6 +24,7 @@ import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { resolveOrcadRemoteContext } from './orcad-remote-context' import { deployOrcad } from './orcad-remote-deploy' +import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' @@ -139,6 +140,12 @@ export async function createManagedOrcadEnvironment( deployResult.fullVersion, args.signal ) + await pruneManagedOrcadVersions({ + slot, + serverTarget: context.serverTarget, + activeVersion: deployResult.fullVersion, + readiness + }) const localPort = await startOrcadManagedTunnel( environmentId, claimed, diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 94d685f89ab4..142b926d9407 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -35,6 +35,7 @@ import { } from './orcad-managed-runtime-context' import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' import { deployOrcad } from './orcad-remote-deploy' +import { pruneManagedOrcadVersions } from './orcad-managed-version-gc' import { rollbackOrcad } from './orcad-remote-rollback' import { collectManagedTerminalCensus } from './orcad-terminal-census-client' import { findIncompleteManagedOrcadMigration } from './orcad-managed-migration-status' @@ -86,8 +87,9 @@ export function updateManagedOrcadEnvironment( const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { signal: args.signal }) + const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), + ...slot, localOrcadDir, target: context.serverTarget, census, @@ -111,6 +113,12 @@ export function updateManagedOrcadEnvironment( result.fullVersion, args.signal ) + await pruneManagedOrcadVersions({ + slot, + serverTarget: context.serverTarget, + activeVersion: result.fullVersion, + readiness + }) const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) return { outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index f3ea135b6a73..ac62231fa314 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -36,6 +36,7 @@ import { loadWorktreeRemovalRecords } from '../worktree-background-removal' import { runAfterFirstWindowShown } from './first-window-deferral' import { logStartupMilestone } from './startup-diagnostics' import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' +import { pruneDesktopOrcadArtifactCache } from '../orcad/orcad-artifact-cache-retention' // Headless serve never opens a window, so the sweep still has to run off a timer there. const WORKTREE_TRASH_SWEEP_FALLBACK_MS = 15_000 @@ -97,6 +98,11 @@ export async function initializeReadyRuntimeServices(): Promise { runAfterFirstWindowShown(() => { refreshInstalledOpenCodeStatusPlugins(store.getSettings()) }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) + runAfterFirstWindowShown(() => { + void pruneDesktopOrcadArtifactCache(app.getPath('userData')).catch((error) => { + console.warn('[orcad-artifacts] Failed to bound the slot cache:', error) + }) + }, WORKTREE_TRASH_SWEEP_FALLBACK_MS) nativeTheme.themeSource = store.getSettings().theme ?? 'system' // Why: the real-home ensure stays ordered before managed-hook reconciliation, so its // in-slot conversion lands before the managed install's retired-form sweep removes From 082dbe0777acb49a244019db3fa172ac34d1ebd6 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 00:20:47 -0700 Subject: [PATCH 2/2] fix(orcad): keep the in-use slot plus the two most recent others, and prove same-version reuse survives eviction --- .../orcad-artifact-cache-retention.test.ts | 16 ++++++----- .../orcad/orcad-artifact-cache-retention.ts | 16 +++++++---- .../ssh/orcad-artifact-materializer.test.ts | 27 ++++++++++++++++++- 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/main/orcad/orcad-artifact-cache-retention.test.ts b/src/main/orcad/orcad-artifact-cache-retention.test.ts index 53b96f9eb2f3..3ce9291c12f7 100644 --- a/src/main/orcad/orcad-artifact-cache-retention.test.ts +++ b/src/main/orcad/orcad-artifact-cache-retention.test.ts @@ -47,17 +47,19 @@ describe('orcad artifact cache retention', () => { } }) - it('never evicts an in-use version, a repair copy of it, or a staging directory', async () => { + it('keeps an in-use version (even a repair copy) plus the two most recent others', async () => { const root = cacheRoot() - for (const age of [1, 2, 3]) { - slot(root, 'linux-x64-glibc', `v${age}`, age) - } + const recent = [1, 2].map((age) => slot(root, 'linux-x64-glibc', `v${age}`, age)) + const third = slot(root, 'linux-x64-glibc', 'v3', 3) const inUse = slot(root, 'linux-x64-glibc', 'v-old.repair-1', 50) const staging = slot(root, 'linux-x64-glibc', '.staging-1-abc', 60) - expect(await pruneOrcadArtifactCache(root, { inUseVersions: new Set(['v-old']) })).toEqual([]) - expect(existsSync(inUse)).toBe(true) - expect(existsSync(staging)).toBe(true) + expect(await pruneOrcadArtifactCache(root, { inUseVersions: new Set(['v-old']) })).toEqual([ + third + ]) + for (const path of [...recent, inUse, staging]) { + expect(existsSync(path)).toBe(true) + } }) it('leaves the runtime archive cache and a missing root alone', async () => { diff --git a/src/main/orcad/orcad-artifact-cache-retention.ts b/src/main/orcad/orcad-artifact-cache-retention.ts index 281d83739c28..bb9038fee0d1 100644 --- a/src/main/orcad/orcad-artifact-cache-retention.ts +++ b/src/main/orcad/orcad-artifact-cache-retention.ts @@ -12,7 +12,7 @@ import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer' -/** The current slot plus the two most recent others. */ +/** The in-use slot plus the two most recent others (the newest three when none is in use). */ export const ORCAD_ARTIFACT_CACHE_KEEP = 3 const REPAIR_SUFFIX = /\.repair-\d+$/u @@ -43,11 +43,17 @@ export async function pruneOrcadArtifactCache( .filter((name) => !name.startsWith('.')) .map(async (name) => ({ name, mtimeMs: await mtimeOf(join(targetRoot, name)) })) ) - const byRecency = slots - .filter((slot): slot is { name: string; mtimeMs: number } => slot.mtimeMs !== null) + const present = slots.filter( + (slot): slot is { name: string; mtimeMs: number } => slot.mtimeMs !== null + ) + const isInUse = (name: string): boolean => inUse.has(name.replace(REPAIR_SUFFIX, '')) + // The in-use version counts toward `keep`; with none in use, the newest stands in for it. + const othersToKeep = present.some((slot) => isInUse(slot.name)) ? keep - 1 : keep + const others = present + .filter((slot) => !isInUse(slot.name)) .sort((left, right) => right.mtimeMs - left.mtimeMs) - for (const [index, slot] of byRecency.entries()) { - if (index < keep || inUse.has(slot.name.replace(REPAIR_SUFFIX, ''))) { + for (const [index, slot] of others.entries()) { + if (index < othersToKeep) { continue } const path = join(targetRoot, slot.name) diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index 4dd1998e09ce..f889e440ba4a 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -7,11 +7,13 @@ import { renameSync, rmSync, statSync, + utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { pruneOrcadArtifactCache } from '../orcad/orcad-artifact-cache-retention' import { z } from 'zod' import { NODE_RUNTIME_ASSETS, @@ -133,6 +135,29 @@ function rewriteManifest( } describe('assembleOrcadArtifact', () => { + it('reuses the same version from cache after retention evicts other versions', async () => { + const fixture = createTemplate() + const first = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + const entry = statSync(join(first, 'orcad.js')) + // Older versions of the same target, used before this one. + const older = [1, 2, 3].map((age) => { + const dir = join(fixture.cacheRoot, TARGET, `0.0.${age}+old`) + write(join(dir, 'orcad.js'), 'old') + const at = new Date(Date.now() - age * 60_000) + utimesSync(dir, at, at) + return dir + }) + const removed = await pruneOrcadArtifactCache(fixture.cacheRoot, { + inUseVersions: new Set([basename(first)]) + }) + + expect(removed).toEqual([older[2]]) + const second = await assembleOrcadArtifact({ ...fixture, target: TARGET }) + expect(second).toBe(first) + // A verified hit: the same files, not a re-copy. + expect(statSync(join(second, 'orcad.js')).ino).toBe(entry.ino) + }) + it.skipIf(process.platform === 'win32')( 'restores executable modes from a template copied without them', async () => {