diff --git a/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts b/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts new file mode 100644 index 0000000000..407c0df2df --- /dev/null +++ b/packages/platform-apple/src/runner/__tests__/runner-cache-retention.test.ts @@ -0,0 +1,360 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; +import { resetAllProcessMemosForTests } from '@agent-device/kernel/ttl-memo'; +import { appleRunnerTestHost } from '../test-host.ts'; +import { + acquireRunnerXctestrunCacheLock, + resolveRunnerCacheMetadataPath, +} from '../runner-cache.ts'; +import { evictStaleRunnerCaches, resolveRunnerCacheKeepCount } from '../runner-cache-retention.ts'; +import { ensureXctestrunArtifact } from '../runner-xctestrun.ts'; +import { + cleanupRunnerLeasesForOwner, + writeRunnerLease, + type RunnerLease, +} from '../runner-lease.ts'; +import { appleToolchainProbeResult } from './apple-toolchain-fixtures.ts'; +import { IOS_SIMULATOR } from './device-fixtures.ts'; +import { seedRunnerProductBundle } from './runner-xctestrun.fixtures.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; + +const DAY_MS = 24 * 60 * 60_000; +const NOW_MS = Date.parse('2026-10-05T12:00:00Z'); + +let base: string; +let previousLeaseDir: string | undefined; +let previousDerivedOverride: string | undefined; + +beforeEach(() => { + base = mkdtempForTestSync('agent-device-runner-retention-'); + previousLeaseDir = process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR; + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = mkdtempForTestSync('agent-device-lease-root-'); + previousDerivedOverride = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH; + delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP; +}); + +afterEach(() => { + restoreEnv('AGENT_DEVICE_IOS_RUNNER_LEASE_DIR', previousLeaseDir); + restoreEnv('AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH', previousDerivedOverride); + delete process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP; +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +/** A cache key as a build leaves it: products plus the metadata file reuse touches. */ +function seedKey(name: string, idleDays: number | null): string { + const derived = path.join(base, name); + fs.mkdirSync(path.join(derived, 'Build', 'Products'), { recursive: true }); + if (idleDays !== null) { + const metadataPath = resolveRunnerCacheMetadataPath(derived); + fs.writeFileSync(metadataPath, '{}'); + const usedAt = new Date(NOW_MS - idleDays * DAY_MS); + fs.utimesSync(metadataPath, usedAt, usedAt); + } + return derived; +} + +function key(index: number): string { + return `cache-${index.toString(16).padStart(16, '0')}`; +} + +function remaining(): string[] { + return fs + .readdirSync(base) + .filter((name) => !name.endsWith('.lock')) + .sort(); +} + +function leaseFor(derived: string, overrides: Partial): RunnerLease { + return { + schemaVersion: 1, + deviceId: 'SIM-1', + ownerToken: 'owner-1', + ownerPid: 4242, + ownerStartTime: null, + sessionId: 'session-1', + runnerPid: null, + port: 8100, + xctestrunPath: path.join(derived, 'Build', 'Products', 'Runner.xctestrun'), + jsonPath: path.join(derived, 'runner.json'), + createdAtMs: NOW_MS, + ...overrides, + }; +} + +test('keeps the current key and the most recently used keys up to the keep count, evicts the idle rest', async () => { + const current = seedKey(key(1), 40); + seedKey(key(2), 2); + seedKey(key(3), 3); + seedKey(key(4), 4); + seedKey(key(5), 5); + seedKey(key(6), 30); + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '4'; + + const evicted = await evictStaleRunnerCaches(current, process.env, NOW_MS); + + assert.deepEqual(remaining(), [key(1), key(2), key(3), key(4)]); + assert.deepEqual(evicted.map((entry) => path.basename(entry)).sort(), [key(5), key(6)]); +}); + +test('keeps a key used within a day even when it ranks past the keep count', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), 0.5); + seedKey(key(3), 2); + + await evictStaleRunnerCaches(current, process.env, NOW_MS); + + assert.deepEqual(remaining(), [key(1), key(2)]); +}); + +test('evicts aborted-build stubs that carry no metadata', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), null); + + await evictStaleRunnerCaches(current, process.env, NOW_MS); + + assert.deepEqual(remaining(), [key(1)]); +}); + +test('leaves entries that are not keyed cache directories alone', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), 30); + fs.mkdirSync(path.join(base, 'Build')); + fs.mkdirSync(path.join(base, 'cache-notahash')); + fs.writeFileSync(path.join(base, key(3)), 'a file named like a key'); + + await evictStaleRunnerCaches(current, process.env, NOW_MS); + + assert.deepEqual(remaining(), ['Build', key(1), key(3), 'cache-notahash'].sort()); +}); + +test('keeps a key whose build lock is held', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + const building = seedKey(key(2), 30); + seedKey(key(3), 30); + const release = await acquireRunnerXctestrunCacheLock(building); + + try { + await evictStaleRunnerCaches(current, process.env, NOW_MS); + assert.deepEqual(remaining(), [key(1), key(2)]); + } finally { + await release(); + } +}); + +test('keeps a key a lease not proven dead points at, by path or cache key, and evicts one only a dead lease points at', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + const live = seedKey(key(2), 30); + const dead = seedKey(key(3), 30); + const handedOff = seedKey(key(4), 30); + const envDirLease = seedKey(key(5), 30); + const recycled = seedKey(key(6), 30); + const stateDirGone = seedKey(key(7), 30); + appleRunnerTestHost.update({ + classifyOwnerLiveness: ({ owner }) => { + if (owner.pid === 4242) return 'live'; + if (owner.pid === 4245) return 'owner-state-dir-gone'; + if (owner.pid === 9001) + return owner.startTime === 'runner-start' ? 'live' : 'owner-process-reused'; + if (owner.pid === 9002) return 'owner-process-reused'; + return 'owner-process-dead'; + }, + }); + writeRunnerLease(leaseFor(live, { deviceId: 'SIM-LIVE', ownerPid: 4242 })); + writeRunnerLease(leaseFor(stateDirGone, { deviceId: 'SIM-DIR-GONE', ownerPid: 4245 })); + writeRunnerLease( + leaseFor(recycled, { + deviceId: 'SIM-RECYCLED', + ownerPid: 4246, + runnerPid: 9002, + runnerStartTime: 'old-start', + }), + ); + writeRunnerLease(leaseFor(dead, { deviceId: 'SIM-DEAD', ownerPid: 4243 })); + writeRunnerLease( + leaseFor(handedOff, { + deviceId: 'SIM-HANDED-OFF', + ownerPid: 4244, + runnerPid: 9001, + runnerStartTime: 'runner-start', + }), + ); + + writeRunnerLease( + leaseFor(envDirLease, { + deviceId: 'SIM-ENV-DIR', + ownerPid: 4242, + cacheKey: key(5), + xctestrunPath: path.join(base, 'env-dir', 'Runner.env.xctestrun'), + }), + ); + + await evictStaleRunnerCaches(current, process.env, NOW_MS); + + assert.deepEqual(remaining(), [key(1), key(2), key(4), key(5), key(7)]); +}); + +test('evicts nothing when the lease directory cannot be listed', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), 30); + const leaseRoot = path.join(base, 'leases-is-a-file'); + fs.writeFileSync(leaseRoot, 'not a directory'); + process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR = leaseRoot; + + assert.deepEqual(await evictStaleRunnerCaches(current, process.env, NOW_MS), []); + assert.deepEqual(remaining(), [key(1), key(2), 'leases-is-a-file'].sort()); +}); + +test('evicts nothing when one lease file cannot be read', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), 30); + const leaseRoot = process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR!; + const unreadable = path.join(leaseRoot, 'unreadable.json'); + fs.writeFileSync(unreadable, '{}'); + fs.chmodSync(unreadable, 0o000); + + assert.deepEqual(await evictStaleRunnerCaches(current, process.env, NOW_MS), []); + assert.deepEqual(remaining(), [key(1), key(2)]); +}); + +test('owner cleanup still stops a readable lease next to an unreadable lease file', async () => { + const owned = leaseFor(seedKey(key(1), 0), { ownerPid: 4242, ownerStartTime: null }); + writeRunnerLease(owned); + const unreadable = path.join(process.env.AGENT_DEVICE_IOS_RUNNER_LEASE_DIR!, 'a-unreadable.json'); + fs.writeFileSync(unreadable, '{}'); + fs.chmodSync(unreadable, 0o000); + const cleanupTempFile = vi.fn(async () => {}); + + await cleanupRunnerLeasesForOwner( + { pid: 4242, startTime: null }, + { + cleanupRunnerProcessTree: async () => {}, + cleanupRunnerXcodebuildProcesses: async () => {}, + cleanupTempFile, + }, + ); + + assert.deepEqual(cleanupTempFile.mock.calls, [[owned.xctestrunPath], [owned.jsonPath]]); +}); + +test('a keep count of 0 turns eviction off', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '0'; + const current = seedKey(key(1), 0); + seedKey(key(2), 30); + + assert.deepEqual(await evictStaleRunnerCaches(current, process.env, NOW_MS), []); + assert.deepEqual(remaining(), [key(1), key(2)]); +}); + +test('a derived path override is not a keyed cache root and is never swept', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH = base; + const current = seedKey(key(1), 0); + seedKey(key(2), 30); + seedKey(key(3), 30); + seedKey(key(4), 30); + seedKey(key(5), 30); + + assert.deepEqual(await evictStaleRunnerCaches(current, process.env, NOW_MS), []); + assert.equal(remaining().length, 5); +}); + +test('the keep count falls back to 3 for anything but a non-negative integer', () => { + assert.equal(resolveRunnerCacheKeepCount({}), 3); + assert.equal(resolveRunnerCacheKeepCount({ AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP: '5' }), 5); + assert.equal(resolveRunnerCacheKeepCount({ AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP: '0' }), 0); + for (const invalid of ['-1', '2.5', 'many', '']) { + assert.equal( + resolveRunnerCacheKeepCount({ AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP: invalid }), + 3, + invalid, + ); + } +}); + +test('building a new runner cache key sweeps the keys beside it', async () => { + resetAllProcessMemosForTests(); + const projectRoot = mkdtempForTestSync('agent-device-runner-retention-root-'); + fs.mkdirSync( + path.join(projectRoot, 'apple', 'runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj'), + { recursive: true }, + ); + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const runCmdStreaming = vi.fn().mockImplementation(async (_command: string, args: string[]) => { + const symroot = args.find((arg) => arg.startsWith('SYMROOT='))!.slice('SYMROOT='.length); + await seedRunnerProductBundle(path.join(symroot, 'Debug-iphonesimulator', 'Runner.app')); + fs.writeFileSync( + path.join(symroot, 'Runner_iphonesimulator27.0-arm64.xctestrun'), + ` +ProductPaths +__TESTROOT__/Debug-iphonesimulator/Runner.app +`, + ); + return { exitCode: 0, stdout: '', stderr: '' }; + }); + appleRunnerTestHost.update({ + runCmdSync: vi.fn().mockImplementation(appleToolchainProbeResult), + runCmdStreaming, + findProjectRoot: () => projectRoot, + readVersion: () => '0.0.0-test', + }); + const home = process.env.HOME!; + const simulatorBase = path.join( + home, + '.agent-device', + 'apple-runner', + 'derived', + 'ios-simulator', + ); + base = simulatorBase; + const stale = seedKey(key(7), 30); + const unrelatedPlatform = path.join(path.dirname(simulatorBase), 'macos', key(8)); + fs.mkdirSync(unrelatedPlatform, { recursive: true }); + + const built = await ensureXctestrunArtifact(IOS_SIMULATOR, {}); + + assert.equal(built.artifact, 'rebuilt'); + await vi.waitFor(() => assert.equal(fs.existsSync(stale), false)); + assert.equal(fs.existsSync(built.derived), true); + assert.equal(fs.existsSync(unrelatedPlatform), true); +}); + +test('keeps a key that a runner reused after the listing, found fresh under the lock', async () => { + process.env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP = '1'; + const current = seedKey(key(1), 0); + seedKey(key(2), 5); + const reused = seedKey(key(3), 9); + const realRm = fs.promises.rm.bind(fs.promises); + let touched = false; + const rm = vi.spyOn(fs.promises, 'rm').mockImplementation(async (target, options) => { + if (!touched) { + touched = true; + const usedAt = new Date(NOW_MS); + fs.utimesSync(resolveRunnerCacheMetadataPath(reused), usedAt, usedAt); + } + return realRm(target, options); + }); + try { + const evicted = await evictStaleRunnerCaches(current, process.env, NOW_MS); + assert.deepEqual( + evicted.map((entry) => path.basename(entry)), + [key(2)], + ); + } finally { + rm.mockRestore(); + } + + assert.deepEqual(remaining(), [key(1), key(3)]); +}); diff --git a/packages/platform-apple/src/runner/runner-artifact.ts b/packages/platform-apple/src/runner/runner-artifact.ts index defe03ce6c..1a5e15da4a 100644 --- a/packages/platform-apple/src/runner/runner-artifact.ts +++ b/packages/platform-apple/src/runner/runner-artifact.ts @@ -7,6 +7,7 @@ import { runCmdStreaming, withKeyedLock, withProcessLock, + emitDiagnostic, emitRequestProgress, findProjectRoot, getRequestSignal, @@ -185,7 +186,7 @@ export async function ensureXctestrunArtifact( options.budget, ); const derived = resolveRunnerDerivedPath(device, expectedCacheMetadata); - return await withKeyedLock(runnerXctestrunBuildLocks, derived, async () => { + const artifact = await withKeyedLock(runnerXctestrunBuildLocks, derived, async () => { return await withProcessLock({ acquire: () => acquireRunnerXctestrunCacheLock(derived), task: () => @@ -199,6 +200,24 @@ export async function ensureXctestrunArtifact( }), }); }); + if (artifact.artifact === 'rebuilt') { + void evictStaleRunnerCachesBestEffort(derived); + } + return artifact; +} + +/** A new key is the only way the cache grows, so a build is when the old keys are swept, off the start path. */ +async function evictStaleRunnerCachesBestEffort(derived: string): Promise { + try { + const { evictStaleRunnerCaches } = await import('./runner-cache-retention.ts'); + await evictStaleRunnerCaches(derived); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'runner_xctestrun_cache_eviction_unavailable', + data: { derived, error: error instanceof Error ? error.message : String(error) }, + }); + } } function resolveExternalXctestrunArtifact( diff --git a/packages/platform-apple/src/runner/runner-cache-retention.ts b/packages/platform-apple/src/runner/runner-cache-retention.ts new file mode 100644 index 0000000000..5db90afcf0 --- /dev/null +++ b/packages/platform-apple/src/runner/runner-cache-retention.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { emitDiagnostic } from './host.ts'; +import { + acquireRunnerXctestrunCacheLock, + emitRunnerXctestrunDecision, + resolveRunnerCacheMetadataPath, +} from './runner-cache.ts'; +import { listActiveRunnerLeaseArtifacts } from './runner-lease.ts'; + +const DEFAULT_RUNNER_CACHE_KEEP = 3; +// CONSERVATIVE: A key used within a day is never evicted, so a runner that has passed the cache +// decision but not yet written its lease cannot lose its products. Revisit if start no longer +// leaves that gap. +const MIN_IDLE_MS = 24 * 60 * 60_000; +const CACHE_KEY_DIRECTORY = /^cache-[0-9a-f]{16}$/; + +/** How many runner cache keys per platform folder survive, the current one included; 0 keeps them all. */ +export function resolveRunnerCacheKeepCount(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP?.trim(); + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_RUNNER_CACHE_KEEP; + return Number(raw); +} + +/** + * Removes the keyed runner caches beside `currentDerived` that no runner can use any more: not the + * current key, not among the most recently used, idle for a day, not held by a build, and not named + * by a live runner lease. Keys only multiply, because a runner source change or an Xcode update + * mints a new one and the old one can never match again. + * + * Returns the evicted cache roots. Nothing is evicted when `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` + * overrides the layout: that directory is not the keyed cache root. + */ +export async function evictStaleRunnerCaches( + currentDerived: string, + env: NodeJS.ProcessEnv = process.env, + nowMs: number = Date.now(), +): Promise { + const keep = resolveRunnerCacheKeepCount(env); + if (keep === 0 || env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim()) return []; + const base = path.dirname(currentDerived); + const candidates = listCacheKeyDirectories(base) + .filter((entry) => entry.derived !== currentDerived) + .sort((left, right) => right.lastUsedMs - left.lastUsedMs) + .slice(keep - 1) + .filter((entry) => nowMs - entry.lastUsedMs >= MIN_IDLE_MS); + const evicted: string[] = []; + for (const { derived } of candidates) { + try { + if (await evictIfUnused(derived, nowMs)) evicted.push(derived); + } catch (error) { + emitEvictionFailure(derived, error); + } + } + return evicted; +} + +function emitEvictionFailure(derived: string, error: unknown): void { + emitDiagnostic({ + level: 'warn', + phase: 'runner_xctestrun_cache_eviction_failed', + data: { derived, error: error instanceof Error ? error.message : String(error) }, + }); +} + +type CacheKeyDirectory = { derived: string; lastUsedMs: number }; + +/** Last use is the mtime of the metadata file, which every reuse rewrites; a stub has none. */ +function listCacheKeyDirectories(base: string): CacheKeyDirectory[] { + let names: string[]; + try { + names = fs + .readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && CACHE_KEY_DIRECTORY.test(entry.name)) + .map((entry) => entry.name); + } catch { + return []; + } + return names.map((name) => { + const derived = path.join(base, name); + return { derived, lastUsedMs: lastUsedMs(derived) }; + }); +} + +function lastUsedMs(derived: string): number { + try { + return fs.statSync(resolveRunnerCacheMetadataPath(derived)).mtimeMs; + } catch { + return 0; + } +} + +async function evictIfUnused(derived: string, nowMs: number): Promise { + let release: () => Promise; + try { + release = await acquireRunnerXctestrunCacheLock(derived, 0); + } catch { + return false; + } + try { + // Re-read under the lock: a reuse that finished after the listing refreshed the key, and its + // runner may not have written a lease yet. + if (nowMs - lastUsedMs(derived) < MIN_IDLE_MS) return false; + // Leases are read under the lock: a runner needs this lock to resolve the key before it writes one. + const key = path.basename(derived); + const leased = listActiveRunnerLeaseArtifacts().some( + ({ xctestrunPath, cacheKey }) => + cacheKey === key || xctestrunPath.startsWith(`${derived}${path.sep}`), + ); + if (leased) return false; + // Without its metadata a half-deleted key is never a hit; the next build for this key overwrites it. + await fs.promises.rm(resolveRunnerCacheMetadataPath(derived), { force: true }); + await fs.promises.rm(derived, { recursive: true, force: true }); + emitRunnerXctestrunDecision('clean', 'stale_cache_evicted', { derived }); + return true; + } finally { + await release().catch(() => undefined); + } +} diff --git a/packages/platform-apple/src/runner/runner-cache.ts b/packages/platform-apple/src/runner/runner-cache.ts index 71499098ea..b69917752c 100644 --- a/packages/platform-apple/src/runner/runner-cache.ts +++ b/packages/platform-apple/src/runner/runner-cache.ts @@ -120,6 +120,7 @@ export async function markRunnerXctestrunArtifactBadForRun( export async function acquireRunnerXctestrunCacheLock( derived: string, + timeoutMs: number = RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS, ): Promise<() => Promise> { return await acquireProcessLock({ lockDirPath: resolveRunnerXctestrunCacheLockPath(derived), @@ -128,7 +129,7 @@ export async function acquireRunnerXctestrunCacheLock( startTime: readProcessStartTime(process.pid), acquiredAtMs: Date.now(), }, - timeoutMs: RUNNER_XCTESTRUN_CACHE_LOCK_TIMEOUT_MS, + timeoutMs, pollMs: RUNNER_XCTESTRUN_CACHE_LOCK_POLL_MS, description: 'iOS runner cache lock', }); @@ -380,7 +381,8 @@ export function emitRunnerXctestrunDecision( | 'built_new' | 'external_xctestrun' | 'external_bad_artifact' - | 'uncertifiable_products', + | 'uncertifiable_products' + | 'stale_cache_evicted', data: Record, ): void { emitDiagnostic({ diff --git a/packages/platform-apple/src/runner/runner-lease.ts b/packages/platform-apple/src/runner/runner-lease.ts index 198cabe500..9e9cdd5a86 100644 --- a/packages/platform-apple/src/runner/runner-lease.ts +++ b/packages/platform-apple/src/runner/runner-lease.ts @@ -444,28 +444,74 @@ function listRunnerLeasesForOwner(owner: { pid: number; startTime?: string | null; }): RunnerLease[] { - let entries: fs.Dirent[]; + return readAllRunnerLeases({ strict: false }).filter( + (lease) => + lease.ownerPid === owner.pid && + (owner.startTime === undefined || lease.ownerStartTime === owner.startTime), + ); +} + +/** + * Reads every lease file. Strict reads throw unless the directory and every file could be read, with + * only ENOENT meaning absent; non-strict reads skip each unreadable file on its own. + */ +function readAllRunnerLeases(options: { strict: boolean }): RunnerLease[] { + const read = options.strict ? readOptionalSync : readSkippingErrors; const root = resolveRunnerLeaseRoot(); - try { - entries = fs.readdirSync(root, { withFileTypes: true }); - } catch { - return []; - } + const entries = read(() => fs.readdirSync(root, { withFileTypes: true })) ?? []; const leases: RunnerLease[] = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.json')) continue; - const lease = readRunnerLeaseFile(path.join(root, entry.name)); - if (!lease) continue; - if (lease.ownerPid !== owner.pid) continue; - if (owner.startTime !== undefined && lease.ownerStartTime !== owner.startTime) continue; - leases.push(lease); + const contents = read(() => fs.readFileSync(path.join(root, entry.name), 'utf8')); + const lease = contents === null ? null : parseRunnerLease(contents); + if (lease) leases.push(lease); } return leases; } -function readRunnerLeaseFile(filePath: string): RunnerLease | null { +function readSkippingErrors(read: () => T): T | null { + try { + return read(); + } catch { + return null; + } +} + +function readOptionalSync(read: () => T): T | null { + try { + return read(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +/** + * The `.xctestrun` path and cache key of every lease that is not proven dead, for cache eviction. + * A lease is proven dead only when its owner process is gone (dead or recycled) and its leased runner + * is absent, gone or recycled. An owner whose state directory is gone is still a live process, and a + * runner whose start time cannot be read is treated as live. Throws when the leases cannot be listed, + * so a caller never mistakes an unreadable lease directory for an empty one. + */ +export function listActiveRunnerLeaseArtifacts(): { xctestrunPath: string; cacheKey?: string }[] { + return readAllRunnerLeases({ strict: true }) + .filter((lease) => !isLeaseProvenDead(lease)) + .map(({ xctestrunPath, cacheKey }) => ({ xctestrunPath, cacheKey })); +} + +function isLeaseProvenDead(lease: RunnerLease): boolean { + const state = classifyRunnerLease(lease); + if (state.type !== 'stale' || state.staleReason === 'owner-state-dir-gone') return false; + if (lease.runnerPid === null) return true; + const runner = classifyOwnerLiveness({ + owner: { pid: lease.runnerPid, startTime: lease.runnerStartTime ?? null }, + }); + return runner === 'owner-process-dead' || runner === 'owner-process-reused'; +} + +function parseRunnerLease(contents: string): RunnerLease | null { try { - const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Partial; + const parsed = JSON.parse(contents) as Partial; const deviceId = readNonEmptyString(parsed.deviceId); return deviceId ? normalizeRunnerLease(parsed, deviceId) : null; } catch { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 0c8b66c295..475f3e92c2 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -281,6 +281,7 @@ agent-device prepare ios-runner --platform ios --timeout 240000 - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild. - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version. - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache. +- Every runner source or Xcode change creates a new cache key under `~/.agent-device/apple-runner/derived//`, where the folder names the platform and device kind (`ios-simulator`, `ios-device`, `tvos-simulator`, `tvos-device`, `macos`, `visionos-simulator`, `visionos-device`). After a build, Agent Device deletes, on a best-effort basis and without delaying the start, keys beside the new one in the same folder that are neither among the 3 most recently used (the new key included) nor used in the last day, and never one a runner lease not proven dead points at or a build is holding. A key that cannot be deleted is left in place and reported as a `runner_xctestrun_cache_eviction_failed` warning diagnostic. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP=` changes that count and `0` keeps every key. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never swept. - Certification is fail-closed: when a product tree cannot be certified at all — a product escaping the derived-data root, an unreadable subtree, a file over 128 MB, or a non-regular entry such as a socket — the build fails with `runner_cache_uncertifiable` naming the path instead of launching uncertified bytes. Point `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` at a plain directory the current user owns; replacing the tree (the error's hint says how) clears a refusal. - Runner build/start output is written to the session's `runner.log`. The top-level `daemon.log` is reserved for daemon lifecycle/startup issues. diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 9e524a5da8..2082ad0626 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -130,7 +130,7 @@ These env vars are the supported user-facing configuration surface. Other `AGENT | Local daemon storage | `AGENT_DEVICE_STATE_DIR` | Public | | Metro and install helpers | `AGENT_DEVICE_METRO_BEARER_TOKEN`, `AGENT_DEVICE_BUNDLETOOL_JAR` | Public | | App hooks and logs | `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`, `AGENT_DEVICE_APP_LOG_MAX_BYTES`, `AGENT_DEVICE_APP_LOG_MAX_FILES`, `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS`, `AGENT_DEVICE_EVENT_LOG_MAX_BYTES` | Public. Byte caps take whole integers (`5242880`), not `5MB`. | -| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED` | Public operator controls. Cleanup is only automatic for override paths under project `.tmp/`. | +| Apple runner setup | `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`, `AGENT_DEVICE_IOS_BUNDLE_ID`, `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH`, `AGENT_DEVICE_IOS_CLEAN_DERIVED`, `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` | Public operator controls. Cleanup of an `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` override is only automatic for paths under project `.tmp/`; keys in the managed runner cache are swept after a build. `AGENT_DEVICE_IOS_RUNNER_CACHE_KEEP` is the number of most-recent runner cache keys per platform folder (for example `ios-simulator` and `ios-device` are counted separately) guaranteed to survive a new build, the new key included (default 3, `0` keeps all). Keys used in the last day, held by a build, or named by a live runner lease also survive, so it is not a hard disk-usage cap. | | Install/update and platform helpers | `AGENT_DEVICE_NO_UPDATE_NOTIFIER`, `AGENT_DEVICE_MACOS_HELPER_BIN`, `AGENT_DEVICE_ANDROID_SNAPSHOT_HELPER_SESSION` | Public operator controls | | macOS app backend | `AGENT_DEVICE_MACOS_APP_BACKEND`, `AGENT_DEVICE_MACOS_GHOST_CURSOR` | Public operator controls, read by the daemon. `native` drives macOS app sessions through the macOS helper instead of XCTest; see [Commands](/docs/commands). Unset or `xctest` keeps the runner. The drawn agent pointer adds about 0.3 s to each native click, fill, type, and scroll; `AGENT_DEVICE_MACOS_GHOST_CURSOR=0` turns it off. Restart the daemon after changing either value. |