Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/main/orcad/orcad-artifact-cache-retention.test.ts
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()
})
})
112 changes: 112 additions & 0 deletions src/main/orcad/orcad-artifact-cache-retention.ts
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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linux-x64-glibc217 is materialized into this same cache by rung B (via materializeOrcadArtifact), but it is not a key of NODE_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 returns undefined for the node/ archive dir.

Technical details
# Compat target slots are never pruned

## Affected sites
- `src/main/orcad/orcad-artifact-cache-retention.ts:36` — `Object.hasOwn(NODE_RUNTIME_ASSETS, target)` gates the target loop.
- `src/main/ssh/ssh-relay-pinned-node.ts:307-309` — rung B calls `materializeOrcadArtifact(target, ...)` with the real userData cache root for `CompatServerTarget` values.
- `src/shared/node-runtime-pin.ts:164-170` — `nodeRuntimeAsset` resolves both `NODE_RUNTIME_ASSETS` and `NODE_RUNTIME_COMPAT_ASSETS`.

## Required outcome
- Compat target directories under `<userData>/orcad-artifacts` are bounded by the same keep rule as default targets, while the `node/` runtime archive dir stays excluded.

## Suggested approach
- Replace the `Object.hasOwn` check with `nodeRuntimeAsset(target) !== undefined`, or an explicit `isCompatServerTarget(target) || isServerTarget(target)` predicate.

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
}
}
5 changes: 5 additions & 0 deletions src/main/orcad/orcad-local-serve-selection-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 liveLocalOrcadServeVersion, but this path does not. On a CLI/headless host a long-running orcad serve can keep serving from a slot older than the three newest ones this prune keeps, and ORCA_SERVE_RUNTIME is unset by default here, so selection.kind is electron and nothing is pinned. The prune can then remove the directory the live server — and the Node runtime linked inside it — is running from.

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))
}

Expand Down
27 changes: 26 additions & 1 deletion src/main/ssh/orcad-artifact-materializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion src/main/ssh/orcad-artifact-materializer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -49,6 +49,12 @@ type MaterializeOptions = PinnedRuntimeMaterializeOptions & {
}

const materializations = new Map<string, Promise<string>>()
// Slots handed out this session: a deploy may still be reading one, so cache retention keeps them.
const materializedVersions = new Set<string>()

export function materializedOrcadArtifactVersions(): ReadonlySet<string> {
return materializedVersions
}

/** A verified slot directory for `target`; its runtime is referenced, not included (design D2). */
export async function materializeOrcadArtifact(
Expand Down Expand Up @@ -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 })
Expand Down
89 changes: 89 additions & 0 deletions src/main/ssh/orcad-managed-version-gc.test.ts
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()
})
})
Loading
Loading