import fs from 'node:fs' import path from 'node:path' import { isDisposableBrowserCacheDir } from './browser-cache' import { ensureRnxHome, readLiveRuntimeVersions, rnxHomeDir } from './home-paths' import { rnxRuntime } from './runtime-delivery' const AUTOMATIC_CLEANUP_GENERATION = 1 const AUTOMATIC_CLEANUP_COMPLETE_FILE = `automatic-cleanup-v${AUTOMATIC_CLEANUP_GENERATION}.json` const AUTOMATIC_CLEANUP_LOCK_FILE = `automatic-cleanup-v${AUTOMATIC_CLEANUP_GENERATION}.lock` const AUTOMATIC_CLEANUP_LOCK_STALE_MS = 6 * 60 * 60 * 1000 // a deferral is recorded so the retry does not happen on literally every // command. cleanup defers whenever anything is in use, and on a machine that // always has a sim or daemon running that is the permanent state: the complete // marker is never written, so every rnx invocation spawned a detached worker // that rescanned the home directory, reclaimed nothing, and printed the same // "N is in use and will be retried on a later run" line into the caller's // stderr. measured on this machine: an identical 662.2 MiB across every // invocation over several hours. the retry itself is right, its cadence was // not, so it is throttled rather than removed. const AUTOMATIC_CLEANUP_DEFERRED_FILE = `automatic-cleanup-v${AUTOMATIC_CLEANUP_GENERATION}.deferred.json` const AUTOMATIC_CLEANUP_DEFER_RETRY_MS = 30 * 60 * 1000 export type SootsimCleanupKind = | 'browser-cache' | 'camera-fixture' | 'recording' | 'runtime-archive' | 'runtime-version' export type SootsimCleanupState = 'aggressive' | 'in-use' | 'retained' | 'safe' export interface SootsimCleanupEntry { path: string bytes: number kind: SootsimCleanupKind state: SootsimCleanupState selected: boolean detail: string } export interface SootsimCleanupOptions { aggressive?: boolean } export interface SootsimCleanupPlan { home: string activeRuntime: string | null entries: SootsimCleanupEntry[] reclaimableBytes: number protectedBytes: number totalBytes: number } export interface SootsimCleanupResult extends SootsimCleanupPlan { removedPaths: string[] reclaimedBytes: number remainingBytes: number } export interface AutomaticSootsimCleanupResult { status: 'already-complete' | 'busy' | 'complete' | 'deferred' reclaimedBytes: number remainingBytes: number deferredInUseBytes: number } export function isAutomaticSootsimCleanupComplete(): boolean { const completePath = path.join(rnxHomeDir(), AUTOMATIC_CLEANUP_COMPLETE_FILE) try { const parsed = JSON.parse(fs.readFileSync(completePath, 'utf8')) as { generation?: unknown } return parsed.generation === AUTOMATIC_CLEANUP_GENERATION } catch { return false } } // what the CLI asks before spawning a worker: is there anything for one to do // right now. complete means never again; a recent deferral means not yet. export function shouldSkipAutomaticSootsimCleanup(now = Date.now()): boolean { if (isAutomaticSootsimCleanupComplete()) return true const deferredPath = path.join(rnxHomeDir(), AUTOMATIC_CLEANUP_DEFERRED_FILE) try { const parsed = JSON.parse(fs.readFileSync(deferredPath, 'utf8')) as { generation?: unknown attemptedAt?: unknown } if (parsed.generation !== AUTOMATIC_CLEANUP_GENERATION) return false if (typeof parsed.attemptedAt !== 'number') return false // a clock that moved backwards must not park cleanup forever const age = now - parsed.attemptedAt return age >= 0 && age < AUTOMATIC_CLEANUP_DEFER_RETRY_MS } catch { return false } } export function formatSootsimBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` const units = ['KiB', 'MiB', 'GiB', 'TiB'] let value = bytes / 1024 let unit = units[0] for (let i = 1; i < units.length && value >= 1024; i++) { value /= 1024 unit = units[i] } return `${value.toFixed(value >= 10 ? 1 : 2)} ${unit}` } function allocatedBytes(target: string): number { let stat: fs.Stats try { stat = fs.lstatSync(target) } catch { return 0 } const ownBytes = stat.blocks > 0 ? stat.blocks * 512 : stat.size if (!stat.isDirectory() || stat.isSymbolicLink()) return ownBytes let total = ownBytes try { for (const child of fs.readdirSync(target)) { total += allocatedBytes(path.join(target, child)) } } catch {} return total } function hasBrowserLock(profileRoot: string): boolean { for (const name of ['SingletonLock', 'SingletonSocket', 'lockfile']) { try { if (fs.lstatSync(path.join(profileRoot, name))) return true } catch {} } return false } function collectCacheDirs(root: string, profileRoot: string): SootsimCleanupEntry[] { let children: fs.Dirent[] try { children = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] } const inUse = hasBrowserLock(profileRoot) const entries: SootsimCleanupEntry[] = [] for (const child of children) { if (!child.isDirectory() || child.isSymbolicLink()) continue const childPath = path.join(root, child.name) if (isDisposableBrowserCacheDir(child.name)) { entries.push({ path: childPath, bytes: allocatedBytes(childPath), kind: 'browser-cache', state: inUse ? 'in-use' : 'safe', selected: !inUse, detail: inUse ? 'browser profile is open; close it before cleanup' : 'disposable browser HTTP, bytecode, or GPU cache', }) continue } entries.push(...collectCacheDirs(childPath, profileRoot)) } return entries } function collectPlaywrightCaches(home: string): SootsimCleanupEntry[] { const root = path.join(home, 'profiles', 'playwright') let profiles: fs.Dirent[] try { profiles = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] } return profiles.flatMap((profile) => { if (!profile.isDirectory() || profile.isSymbolicLink()) return [] const profileRoot = path.join(root, profile.name) return collectCacheDirs(profileRoot, profileRoot) }) } function collectElectronCaches(home: string): SootsimCleanupEntry[] { const root = path.join(home, 'electron', 'userData') return collectCacheDirs(root, root) } function collectRecordingEntries( home: string, aggressive: boolean, ): SootsimCleanupEntry[] { const root = path.join(home, 'recordings') let recordings: fs.Dirent[] try { recordings = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] } return recordings.map((recording) => { const recordingPath = path.join(root, recording.name) return { path: recordingPath, bytes: allocatedBytes(recordingPath), kind: 'recording', state: 'aggressive', selected: aggressive, detail: 'user-created recording or captured-frame output', } }) } // videos staged by `rnx camera play`. a fixture is only a copy of a file // the user still has, so it is cheap to reclaim, but the automatic sweep must // not pull one out from under a sim that is playing it right now. aggressive // only, matching recordings. function collectCameraFixtureEntries( home: string, aggressive: boolean, ): SootsimCleanupEntry[] { const root = path.join(home, 'camera-fixtures') let fixtures: fs.Dirent[] try { fixtures = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] } return fixtures.map((fixture) => { const fixturePath = path.join(root, fixture.name) return { path: fixturePath, bytes: allocatedBytes(fixturePath), kind: 'camera-fixture', state: 'aggressive', selected: aggressive, detail: 'staged camera fixture video', } }) } function runtimeEntries(aggressive: boolean): { activeRuntime: string | null entries: SootsimCleanupEntry[] } { const inUseVersions = new Set(readLiveRuntimeVersions()) const protectVersions = [...inUseVersions] const normal = rnxRuntime.planLocalRetention({ retainVersions: 2, protectVersions, }) const minimal = rnxRuntime.planLocalRetention({ retainVersions: 1, protectVersions, }) const normallyRemoved = new Set(normal.removeVersions) const aggressivelyRemoved = new Set(minimal.removeVersions) const allVersions = new Set([ ...normal.keepVersions, ...normal.removeVersions, ...minimal.keepVersions, ...minimal.removeVersions, ]) const entries: SootsimCleanupEntry[] = [] for (const version of [...allVersions].sort()) { const runtimePath = rnxRuntime.product.runtimeDir(version) let state: SootsimCleanupState = 'retained' let detail = 'retained runtime' if (inUseVersions.has(version) && version !== normal.activeVersion) { state = 'in-use' detail = 'runtime is serving a connected simulator' } else if (normallyRemoved.has(version)) { state = 'safe' detail = 'inactive runtime outside the two-version rollback window' } else if (aggressivelyRemoved.has(version)) { state = 'aggressive' detail = 'most recent rollback runtime' } else if (version === normal.activeVersion) { detail = 'active runtime; always protected' } entries.push({ path: runtimePath, bytes: allocatedBytes(runtimePath), kind: 'runtime-version', state, selected: state === 'safe' || (state === 'aggressive' && aggressive), detail, }) } for (const cacheFile of normal.removeCacheFiles) { entries.push({ path: cacheFile, bytes: allocatedBytes(cacheFile), kind: 'runtime-archive', state: 'safe', selected: true, detail: 'downloaded archive is unnecessary after successful extraction', }) } return { activeRuntime: normal.activeVersion, entries, } } export function planSootsimCleanup( options: SootsimCleanupOptions = {}, ): SootsimCleanupPlan { const aggressive = options.aggressive === true const home = rnxHomeDir() const runtimes = runtimeEntries(aggressive) const entries = [ ...runtimes.entries, ...collectPlaywrightCaches(home), ...collectElectronCaches(home), ...collectRecordingEntries(home, aggressive), ...collectCameraFixtureEntries(home, aggressive), ].sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)) return { home, activeRuntime: runtimes.activeRuntime, entries, reclaimableBytes: entries.reduce( (total, entry) => total + (entry.selected ? entry.bytes : 0), 0, ), protectedBytes: entries.reduce( (total, entry) => total + (!entry.selected ? entry.bytes : 0), 0, ), totalBytes: allocatedBytes(home), } } function assertInsideHome(home: string, target: string): void { const relative = path.relative(home, target) if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`refusing cleanup target outside the rnx data home: ${target}`) } } export function applySootsimCleanup( options: SootsimCleanupOptions = {}, ): SootsimCleanupResult { const plan = planSootsimCleanup(options) const retainVersions = options.aggressive === true ? 1 : 2 rnxRuntime.pruneLocalRetention({ retainVersions, protectVersions: readLiveRuntimeVersions(), }) for (const entry of plan.entries) { if (!entry.selected) continue if (entry.kind === 'runtime-archive' || entry.kind === 'runtime-version') continue if (entry.kind === 'browser-cache') { const relative = path.relative(plan.home, entry.path) const parts = relative.split(path.sep) const profileRoot = parts[0] === 'profiles' && parts[1] === 'playwright' && parts[2] ? path.join(plan.home, 'profiles', 'playwright', parts[2]) : parts[0] === 'electron' && parts[1] === 'userData' ? path.join(plan.home, 'electron', 'userData') : null if (profileRoot && hasBrowserLock(profileRoot)) { entry.state = 'in-use' entry.selected = false entry.detail = 'browser profile opened while cleanup was starting' continue } } assertInsideHome(plan.home, entry.path) fs.rmSync(entry.path, { recursive: true, force: true }) } const removedPaths = plan.entries .filter((entry) => entry.selected && !fs.existsSync(entry.path)) .map((entry) => entry.path) const remainingBytes = allocatedBytes(plan.home) return { ...plan, removedPaths, reclaimedBytes: Math.max(0, plan.totalBytes - remainingBytes), remainingBytes, } } export function applyAutomaticSootsimCleanup(): AutomaticSootsimCleanupResult { ensureRnxHome() const home = rnxHomeDir() const completePath = path.join(home, AUTOMATIC_CLEANUP_COMPLETE_FILE) const deferredPath = path.join(home, AUTOMATIC_CLEANUP_DEFERRED_FILE) const lockPath = path.join(home, AUTOMATIC_CLEANUP_LOCK_FILE) if (isAutomaticSootsimCleanupComplete()) { return { status: 'already-complete', reclaimedBytes: 0, remainingBytes: 0, deferredInUseBytes: 0, } } let lockFd: number | null = null for (let attempt = 0; attempt < 2 && lockFd === null; attempt++) { try { const candidateFd = fs.openSync(lockPath, 'wx') try { fs.writeFileSync( candidateFd, `${JSON.stringify({ pid: process.pid, startedAt: Date.now() })}\n`, 'utf8', ) lockFd = candidateFd } catch (error) { fs.closeSync(candidateFd) try { fs.unlinkSync(lockPath) } catch {} throw error } } catch (error) { const code = error instanceof Error && 'code' in error ? error.code : null if (code !== 'EEXIST') throw error let ownerPid = 0 let ownerStartedAt = 0 let originalStat: fs.Stats | null = null try { originalStat = fs.lstatSync(lockPath) const owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')) as { pid?: unknown startedAt?: unknown } ownerPid = typeof owner.pid === 'number' ? owner.pid : 0 ownerStartedAt = typeof owner.startedAt === 'number' ? owner.startedAt : 0 } catch {} if ( ownerPid > 0 && ownerStartedAt > 0 && Date.now() - ownerStartedAt < AUTOMATIC_CLEANUP_LOCK_STALE_MS ) { try { process.kill(ownerPid, 0) return { status: 'busy', reclaimedBytes: 0, remainingBytes: 0, deferredInUseBytes: 0, } } catch {} } if (!originalStat) continue try { const currentStat = fs.lstatSync(lockPath) if ( currentStat.dev !== originalStat.dev || currentStat.ino !== originalStat.ino ) { return { status: 'busy', reclaimedBytes: 0, remainingBytes: 0, deferredInUseBytes: 0, } } fs.unlinkSync(lockPath) } catch {} } } if (lockFd === null) { return { status: 'busy', reclaimedBytes: 0, remainingBytes: 0, deferredInUseBytes: 0, } } try { if (isAutomaticSootsimCleanupComplete()) { return { status: 'already-complete', reclaimedBytes: 0, remainingBytes: 0, deferredInUseBytes: 0, } } const result = applySootsimCleanup() const deferredInUseBytes = result.entries.reduce( (total, entry) => total + (entry.state === 'in-use' ? entry.bytes : 0), 0, ) if (deferredInUseBytes > 0) { const deferredTmp = `${deferredPath}.${process.pid}.tmp` fs.writeFileSync( deferredTmp, `${JSON.stringify({ generation: AUTOMATIC_CLEANUP_GENERATION, attemptedAt: Date.now(), deferredInUseBytes, })}\n`, 'utf8', ) fs.renameSync(deferredTmp, deferredPath) return { status: 'deferred', reclaimedBytes: result.reclaimedBytes, remainingBytes: result.remainingBytes, deferredInUseBytes, } } const tmp = `${completePath}.${process.pid}.tmp` fs.writeFileSync( tmp, `${JSON.stringify({ generation: AUTOMATIC_CLEANUP_GENERATION, completedAt: Date.now(), reclaimedBytes: result.reclaimedBytes, })}\n`, 'utf8', ) fs.renameSync(tmp, completePath) fs.rmSync(deferredPath, { force: true }) return { status: 'complete', reclaimedBytes: result.reclaimedBytes, remainingBytes: result.remainingBytes, deferredInUseBytes: 0, } } finally { fs.closeSync(lockFd) try { fs.unlinkSync(lockPath) } catch {} } }