|
| 1 | +import { lstat, opendir, readFile, readdir, realpath, rm, stat } from "node:fs/promises" |
| 2 | +import { tmpdir } from "node:os" |
| 3 | +import { basename, join, resolve, sep } from "node:path" |
| 4 | + |
| 5 | +export type TempRuntimeRetentionReason = "recent" | "live-process" | "active-lease" | "recent-run" | "changed-before-cleanup" |
| 6 | + |
| 7 | +export interface TempRuntimeCandidate { |
| 8 | + path: string |
| 9 | + kind: string |
| 10 | + ownershipEvidence: string |
| 11 | + ageSeconds: number |
| 12 | + allocatedBytes: number |
| 13 | + state: "candidate" | "retained" | "removed" | "failed" |
| 14 | + retainedReason?: TempRuntimeRetentionReason |
| 15 | + error?: string |
| 16 | +} |
| 17 | + |
| 18 | +export interface TempRuntimeInventory { |
| 19 | + tempRoot: string |
| 20 | + scannedCount: number |
| 21 | + ownedCount: number |
| 22 | + candidateCount: number |
| 23 | + retainedCount: number |
| 24 | + retainedReasons: Record<string, number> |
| 25 | + estimatedAllocatedBytes: number |
| 26 | + reclaimedAllocatedBytes: number |
| 27 | + entries: TempRuntimeCandidate[] |
| 28 | +} |
| 29 | + |
| 30 | +interface TempRuntimeCleanupOptions { |
| 31 | + cleanup: boolean |
| 32 | + staleAfterSeconds: number |
| 33 | + runRegistryRoots: string[] |
| 34 | + now?: number |
| 35 | + processRows?: ProcessRow[] |
| 36 | +} |
| 37 | + |
| 38 | +interface ProcessRow { pid: number; command: string; references: string[] } |
| 39 | +interface DirectoryIdentity { device: number; inode: number } |
| 40 | + |
| 41 | +const OWNED_PREFIXES: ReadonlyArray<readonly [prefix: string, kind: string]> = [ |
| 42 | + ["wp-codebox-agent-task-artifacts-", "agent-task-artifacts"], |
| 43 | + ["wp-codebox-agent-task-recipe-", "agent-task-recipe"], |
| 44 | + ["wp-codebox-dependency-consumer-", "dependency-consumer"], |
| 45 | + ["wp-codebox-dependency-overlay-", "dependency-overlay"], |
| 46 | + ["wp-codebox-fuzz-command-", "fuzz-command"], |
| 47 | + ["wp-codebox-fuzz-workload-", "fuzz-workload"], |
| 48 | + ["wp-codebox-git-head-", "git-head"], |
| 49 | + ["wp-codebox-input-mount-baseline-", "input-mount-baseline"], |
| 50 | + ["wp-codebox-mariadb-", "native-mariadb"], |
| 51 | + ["wp-codebox-overlay-php-ai-client-", "runtime-overlay"], |
| 52 | + ["wp-codebox-plugin-", "plugin-overlay"], |
| 53 | + ["wp-codebox-readonly-mounts-", "readonly-mounts"], |
| 54 | + ["wp-codebox-release-coverage-", "release-coverage"], |
| 55 | + ["wp-codebox-release-", "release-package"], |
| 56 | + ["wp-codebox-source-package-", "source-package"], |
| 57 | + ["wp-codebox-source-", "source"], |
| 58 | + ["wp-codebox-staged-file-", "staged-file"], |
| 59 | + ["wp-codebox-workload-cli-", "workload-cli"], |
| 60 | + ["wp-codebox-workspace-preload-", "workspace-preload"], |
| 61 | +] |
| 62 | + |
| 63 | +export async function inventoryTempRuntimeDirectories(options: TempRuntimeCleanupOptions): Promise<TempRuntimeInventory> { |
| 64 | + const tempRoot = await realpath(tmpdir()) |
| 65 | + const rootStat = await stat(tempRoot) |
| 66 | + const now = options.now ?? Date.now() |
| 67 | + const processes = options.processRows ?? await runtimeProcessRows() |
| 68 | + const globalProtection = await globalRuntimeProtection(processes, options.runRegistryRoots, now, options.staleAfterSeconds) |
| 69 | + const entries: TempRuntimeCandidate[] = [] |
| 70 | + let scannedCount = 0 |
| 71 | + |
| 72 | + const directory = await opendir(tempRoot) |
| 73 | + for await (const entry of directory) { |
| 74 | + scannedCount++ |
| 75 | + if (!entry.isDirectory() || entry.isSymbolicLink()) continue |
| 76 | + const path = join(tempRoot, entry.name) |
| 77 | + const ownership = await ownershipEvidence(path, entry.name) |
| 78 | + if (!ownership) continue |
| 79 | + |
| 80 | + const before = await stat(path) |
| 81 | + if (!before.isDirectory() || before.dev !== rootStat.dev || !isDirectChild(tempRoot, path)) continue |
| 82 | + const usage = await allocatedUsage(path) |
| 83 | + const ageSeconds = Math.max(0, Math.floor((now - usage.latestMtimeMs) / 1000)) |
| 84 | + const row: TempRuntimeCandidate = { |
| 85 | + path, |
| 86 | + kind: ownership.kind, |
| 87 | + ownershipEvidence: ownership.evidence, |
| 88 | + ageSeconds, |
| 89 | + allocatedBytes: usage.allocatedBytes, |
| 90 | + state: "candidate", |
| 91 | + } |
| 92 | + const retainedReason = ageSeconds < options.staleAfterSeconds |
| 93 | + ? "recent" |
| 94 | + : processReferencesPath(processes, path) |
| 95 | + ? "live-process" |
| 96 | + : globalProtection |
| 97 | + if (retainedReason) { |
| 98 | + row.state = "retained" |
| 99 | + row.retainedReason = retainedReason |
| 100 | + } else if (options.cleanup) { |
| 101 | + await removeCandidate(row, { device: before.dev, inode: before.ino }, tempRoot) |
| 102 | + } |
| 103 | + entries.push(row) |
| 104 | + } |
| 105 | + |
| 106 | + const retainedReasons: Record<string, number> = {} |
| 107 | + for (const row of entries) { |
| 108 | + if (row.retainedReason) retainedReasons[row.retainedReason] = (retainedReasons[row.retainedReason] ?? 0) + 1 |
| 109 | + } |
| 110 | + return { |
| 111 | + tempRoot, |
| 112 | + scannedCount, |
| 113 | + ownedCount: entries.length, |
| 114 | + candidateCount: entries.filter((row) => row.state === "candidate" || row.state === "removed" || row.state === "failed").length, |
| 115 | + retainedCount: entries.filter((row) => row.state === "retained").length, |
| 116 | + retainedReasons, |
| 117 | + estimatedAllocatedBytes: entries.filter((row) => row.state !== "retained").reduce((total, row) => total + row.allocatedBytes, 0), |
| 118 | + reclaimedAllocatedBytes: entries.filter((row) => row.state === "removed").reduce((total, row) => total + row.allocatedBytes, 0), |
| 119 | + entries, |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +async function ownershipEvidence(path: string, name: string): Promise<{ kind: string; evidence: string } | undefined> { |
| 124 | + const owned = OWNED_PREFIXES.find(([prefix]) => name.startsWith(prefix) && name.length > prefix.length) |
| 125 | + if (owned) return { kind: owned[1], evidence: `registered-producer-prefix:${owned[0]}` } |
| 126 | + if (!name.startsWith("node-playground-cli-site-") || name.length === "node-playground-cli-site-".length) return undefined |
| 127 | + const children = new Set(await readdir(path).catch(() => [])) |
| 128 | + if (!children.has("wp-content") || (!children.has("wp-includes") && !children.has("wp-config.php"))) return undefined |
| 129 | + return { kind: "playground-site", evidence: "registered-upstream-prefix+wordpress-layout" } |
| 130 | +} |
| 131 | + |
| 132 | +async function allocatedUsage(root: string): Promise<{ allocatedBytes: number; latestMtimeMs: number }> { |
| 133 | + const metadata = await stat(root) |
| 134 | + let allocatedBytes = metadata.blocks * 512 |
| 135 | + let latestMtimeMs = metadata.mtimeMs |
| 136 | + const directory = await opendir(root) |
| 137 | + for await (const entry of directory) { |
| 138 | + const path = join(root, entry.name) |
| 139 | + const child = await lstat(path).catch(() => undefined) |
| 140 | + if (!child) continue |
| 141 | + allocatedBytes += child.blocks * 512 |
| 142 | + latestMtimeMs = Math.max(latestMtimeMs, child.mtimeMs) |
| 143 | + if (entry.isDirectory() && !entry.isSymbolicLink()) { |
| 144 | + const nested = await allocatedUsage(path) |
| 145 | + allocatedBytes += nested.allocatedBytes - child.blocks * 512 |
| 146 | + latestMtimeMs = Math.max(latestMtimeMs, nested.latestMtimeMs) |
| 147 | + } |
| 148 | + } |
| 149 | + return { allocatedBytes, latestMtimeMs } |
| 150 | +} |
| 151 | + |
| 152 | +async function removeCandidate(row: TempRuntimeCandidate, identity: DirectoryIdentity, tempRoot: string): Promise<void> { |
| 153 | + try { |
| 154 | + const canonical = await realpath(row.path) |
| 155 | + const current = await stat(row.path) |
| 156 | + if (canonical !== resolve(row.path) || !isDirectChild(tempRoot, canonical) || current.dev !== identity.device || current.ino !== identity.inode) { |
| 157 | + row.state = "retained" |
| 158 | + row.retainedReason = "changed-before-cleanup" |
| 159 | + return |
| 160 | + } |
| 161 | + await rm(row.path, { recursive: true }) |
| 162 | + row.state = "removed" |
| 163 | + } catch (error) { |
| 164 | + row.state = "failed" |
| 165 | + row.error = errorMessage(error) |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +async function globalRuntimeProtection(processes: ProcessRow[], registryRoots: string[], now: number, staleAfterSeconds: number): Promise<TempRuntimeRetentionReason | undefined> { |
| 170 | + if (processes.some((row) => row.pid !== process.pid && /wp-codebox/.test(row.command) && /(recipe-run|agent-task-run|run-agent-task|preview-lease-child)/.test(row.command))) return "live-process" |
| 171 | + for (const root of registryRoots) { |
| 172 | + const leaseProtection = await activeLeaseInRegistry(root, now) |
| 173 | + if (leaseProtection) return "active-lease" |
| 174 | + if (await recentRunInRegistry(root, now, staleAfterSeconds)) return "recent-run" |
| 175 | + } |
| 176 | + return undefined |
| 177 | +} |
| 178 | + |
| 179 | +async function activeLeaseInRegistry(root: string, now: number): Promise<boolean> { |
| 180 | + const leaseDirectory = join(resolve(root), "preview-leases") |
| 181 | + for (const name of await readdir(leaseDirectory).catch(() => [])) { |
| 182 | + if (!name.endsWith(".json")) continue |
| 183 | + const lease = await readJson(join(leaseDirectory, name)) |
| 184 | + if (!lease || !["starting", "available", "release_requested"].includes(String(lease.status))) continue |
| 185 | + const expiresAt = Date.parse(String(lease.expiresAt ?? "")) |
| 186 | + if (!Number.isFinite(expiresAt) || expiresAt > now) return true |
| 187 | + } |
| 188 | + return false |
| 189 | +} |
| 190 | + |
| 191 | +async function recentRunInRegistry(root: string, now: number, staleAfterSeconds: number): Promise<boolean> { |
| 192 | + for (const name of await readdir(resolve(root)).catch(() => [])) { |
| 193 | + if (!name.endsWith(".json")) continue |
| 194 | + const run = await readJson(join(resolve(root), name)) |
| 195 | + if (run?.schema !== "wp-codebox/run-registry-entry/v1") continue |
| 196 | + const heartbeat = Date.parse(String(run.heartbeatAt ?? run.updatedAt ?? "")) |
| 197 | + if (Number.isFinite(heartbeat) && now - heartbeat < staleAfterSeconds * 1000) return true |
| 198 | + } |
| 199 | + return false |
| 200 | +} |
| 201 | + |
| 202 | +async function readJson(path: string): Promise<Record<string, unknown> | undefined> { |
| 203 | + try { return JSON.parse(await readFile(path, "utf8")) as Record<string, unknown> } catch { return undefined } |
| 204 | +} |
| 205 | + |
| 206 | +async function runtimeProcessRows(): Promise<ProcessRow[]> { |
| 207 | + if (process.platform !== "linux") return [] |
| 208 | + const rows: ProcessRow[] = [] |
| 209 | + for (const name of await readdir("/proc").catch(() => [])) { |
| 210 | + if (!/^\d+$/.test(name)) continue |
| 211 | + const pid = Number.parseInt(name, 10) |
| 212 | + const command = (await readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).replace(/\0/g, " ").trim() |
| 213 | + if (!command) continue |
| 214 | + const references = [await realpath(`/proc/${pid}/cwd`).catch(() => "")] |
| 215 | + for (const fd of await readdir(`/proc/${pid}/fd`).catch(() => [])) references.push(await realpath(`/proc/${pid}/fd/${fd}`).catch(() => "")) |
| 216 | + rows.push({ pid, command, references: references.filter(Boolean) }) |
| 217 | + } |
| 218 | + return rows |
| 219 | +} |
| 220 | + |
| 221 | +function processReferencesPath(processes: ProcessRow[], path: string): boolean { |
| 222 | + const prefix = `${resolve(path)}${sep}` |
| 223 | + return processes.some((row) => row.pid !== process.pid && (row.command.includes(path) || row.references.some((reference) => reference === path || reference.startsWith(prefix)))) |
| 224 | +} |
| 225 | + |
| 226 | +function isDirectChild(root: string, path: string): boolean { |
| 227 | + return resolve(path).startsWith(`${resolve(root)}${sep}`) && basename(path) !== "" && resolve(join(path, "..")) === resolve(root) |
| 228 | +} |
| 229 | + |
| 230 | +function errorMessage(error: unknown): string { |
| 231 | + return error instanceof Error ? error.message : String(error) |
| 232 | +} |
0 commit comments