Repository navigation
feat(orcad): bound the desktop slot cache and prune proven-stopped orcad versions after each managed deploy #24973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| 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('keeps an in-use version (even a repair copy) plus the two most recent others', async () => { | ||
| const root = cacheRoot() | ||
| 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([ | ||
| 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 () => { | ||
| 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() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| /** | ||
| * Bounds the desktop's `<userData>/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 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 | ||
|
|
||
| export type OrcadArtifactCachePruneOptions = { | ||
| keep?: number | ||
| /** Slot versions to keep whatever their age. */ | ||
| inUseVersions?: ReadonlySet<string> | ||
| } | ||
|
|
||
| /** Removes the stale slots under `cacheRoot` and returns their paths. Best effort per entry. */ | ||
| export async function pruneOrcadArtifactCache( | ||
| cacheRoot: string, | ||
| options: OrcadArtifactCachePruneOptions = {} | ||
| ): Promise<string[]> { | ||
| const keep = options.keep ?? ORCAD_ARTIFACT_CACHE_KEEP | ||
| const inUse = options.inUseVersions ?? new Set<string>() | ||
| 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 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 others.entries()) { | ||
| if (index < othersToKeep) { | ||
| 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 `<userData>/orcad-artifacts`. */ | ||
| export function pruneDesktopOrcadArtifactCache(userDataPath: string): Promise<string[]> { | ||
| 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<string[]> { | ||
| try { | ||
| return await readdir(directory) | ||
| } catch { | ||
| return [] | ||
| } | ||
| } | ||
|
|
||
| async function mtimeOf(path: string): Promise<number | null> { | ||
| try { | ||
| const info = await stat(path) | ||
| return info.isDirectory() ? info.mtimeMs : null | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<void> { | |
| ], | ||
| 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(() => []) | ||
|
Comment on lines
+37
to
+39
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The desktop pass pins the slot a live orcad serve runs from, via Technical details# Serve-entry prune ignores the live-serve lock
## Affected sites
- `src/main/orcad/orcad-local-serve-selection-entry.ts:37-39` — prunes with `inUseVersions` = `[selection.version]` only.
- `src/main/orcad/orcad-artifact-cache-retention.ts:72-76` — `pruneDesktopOrcadArtifactCache` shows the intended pin set: this session's materializations plus `liveLocalOrcadServeVersion(userDataPath)`.
## Required outcome
- The serve-entry prune must never remove the slot the profile's instance lock names while its PID is alive, the same invariant the desktop startup pass already upholds. `liveLocalOrcadServeVersion` is in the same module family; call it here and union it with `selection.version`.
## Open questions for the human
- Is the serve entry ever expected to run while a live orcad serve holds the profile lock, or is that combination considered impossible? If it is possible, the guard above is the fix; if not, a comment saying so would let a future reader trust the omission. |
||
| process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0)) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
linux-x64-glibc217is materialized into this same cache by rung B (viamaterializeOrcadArtifact), but it is not a key ofNODE_RUNTIME_ASSETS, so its slots are skipped here and never bounded — the opposite of this module's purpose.nodeRuntimeAsset(target)covers both the default and compat maps and still returnsundefinedfor thenode/archive dir.Technical details