Public: anyone can read this; the crew can change it.
Check run homes (/data/checks/*.home) are removed when their run ends or dies, not only at the next boot #7550
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7545-check-run-homes-data-checks-home-are-rem → main 3 commits fork 2 threads · 2 unresolved
2 files
changed +146
−5
src/checks/service.ts +40 −5
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import { pullDir, runDir } from "../core/pulls.ts"; |
| 15 | import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; | |
| 15 | import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; | |
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import type { App } from "../app.ts"; |
| @@ -285,9 +285,43 @@ export function requeueAbandonedChecks(app: App, staleMs: number): number { | ||
| 285 | 285 | appendRunLog(app.config.dataDir, run.id, run.summary); |
| 286 | 286 | announceRun(app, run); |
| 287 | 287 | } |
| 288 | // On the same timer: what a dead or lapsed run left on the disk. | |
| 289 | sweepCheckouts(app); | |
| 288 | 290 | return swept.requeued.length + swept.failed.length + swept.cancelled.length; |
| 289 | 291 | } |
| 290 | 292 | |
| 293 | /** | |
| 294 | * Remove a run's directory even when the cell left read-only files in it (a | |
| 295 | * bun cache is): `rmSync` cannot unlink inside a directory without write | |
| 296 | * permission, so permissions are restored and the removal tried again. A | |
| 297 | * removal that still fails is logged, never swallowed. | |
| 298 | */ | |
| 299 | export function removeRunDir(path: string): void { | |
| 300 | try { | |
| 301 | rmSync(path, { recursive: true, force: true }); | |
| 302 | return; | |
| 303 | } catch { | |
| 304 | /* fall through to restore permissions */ | |
| 305 | } | |
| 306 | const open = (p: string): void => { | |
| 307 | try { | |
| 308 | if (lstatSync(p).isSymbolicLink()) return; | |
The |
||
| 309 | if (lstatSync(p).isDirectory()) { | |
| 310 | chmodSync(p, 0o700); | |
| 311 | for (const n of readdirSync(p)) open(join(p, n)); | |
| 312 | } | |
| 313 | } catch { | |
| 314 | /* gone, or not ours: the retry says */ | |
| 315 | } | |
| 316 | }; | |
| 317 | open(path); | |
| 318 | try { | |
| 319 | rmSync(path, { recursive: true, force: true }); | |
| 320 | } catch (error) { | |
| 321 | log.error("could not remove a run directory", { path, error: String(error) }); | |
| 322 | } | |
| 323 | } | |
| 324 | ||
| 291 | 325 | /** Where a check's checkout lives while it runs. */ |
| 292 | 326 | const checksRoot = (dataDir: string): string => join(dataDir, "checks"); |
| 293 | 327 | |
| @@ -301,12 +335,13 @@ export function sweepCheckouts(app: App, now: number = Date.now()): void { | ||
| 301 | 335 | const root = checksRoot(app.config.dataDir); |
| 302 | 336 | if (!existsSync(root)) return; |
| 303 | 337 | for (const name of readdirSync(root)) { |
| 304 | const run = getRun(app.db, name); | |
| 338 | // `<id>.home` is the home of run `<id>`: it lives and dies with it. | |
| 339 | const run = getRun(app.db, name.endsWith(".home") ? name.slice(0, -5) : name); | |
| 305 | 340 | const live = |
| 306 | 341 | run !== undefined && |
| 307 | 342 | run.state === "running" && |
| 308 | 343 | !leaseLapsed(run, STALE_AFTER_MS, now); |
| 309 | if (!live) rmSync(join(root, name), { recursive: true, force: true }); | |
| 344 | if (!live) removeRunDir(join(root, name)); | |
| 310 | 345 | } |
| 311 | 346 | } |
| 312 | 347 | |
| @@ -1180,8 +1215,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1180 | 1215 | if (key !== undefined) recordProven(app.db, key, x.id); |
| 1181 | 1216 | return { ok: true }; |
| 1182 | 1217 | } finally { |
| 1183 | if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); | |
| 1184 | rmSync(home, { recursive: true, force: true }); | |
| 1218 | if (existsSync(dir)) removeRunDir(dir); | |
| 1219 | removeRunDir(home); | |
| 1185 | 1220 | } |
| 1186 | 1221 | } |
| 1187 | 1222 |
test/checks-home-sweep.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * A run's home (`<id>.home`) belongs to run `<id>` (#7545): the sweep removes | |
| 3 | * it once the run is finished or its lease lapsed, leaves a live run's alone, | |
| 4 | * and removes it even with read-only files in it. A torn-down preview takes | |
| 5 | * its home with it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { requeueAbandonedChecks, sweepCheckouts } from "../src/checks/service.ts"; | |
| 15 | import { ensureEnvData, removeEnv } from "../src/apps/data.ts"; | |
| 16 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 17 | import { STALE_AFTER_MS } from "../src/runs/lease.ts"; | |
| 18 | import { createUser } from "../src/core/users.ts"; | |
| 19 | ||
| 20 | let app: App; | |
| 21 | let dataDir: string; | |
| 22 | let repoId: string; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = mkdtempSync(join(tmpdir(), "forge-home-sweep-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | const a = await createUser(app.db, { | |
| 28 | origin: "seed", | |
| 29 | trusted: true, | |
| 30 | username: "alice", | |
| 31 | email: "[email protected]", | |
| 32 | password: "a-good-passphrase", | |
| 33 | adminIfFirst: true, | |
| 34 | }); | |
| 35 | if (!a.ok) throw new Error(a.error); | |
| 36 | const made = await createRepo(app, a.value, { name: "garden", visibility: "private" }); | |
| 37 | if (!made.ok) throw new Error(JSON.stringify(made.error)); | |
| 38 | repoId = made.value.id; | |
| 39 | }); | |
| 40 | afterAll(() => rmSync(dataDir, { recursive: true, force: true })); | |
| 41 | ||
| 42 | const home = (id: string): string => join(dataDir, "checks", `${id}.home`); | |
| 43 | const populate = (id: string, readOnly = false): void => { | |
| 44 | const deep = join(home(id), ".bun", "install", "cache"); | |
| 45 | mkdirSync(deep, { recursive: true }); | |
| 46 | writeFileSync(join(deep, "pkg"), "x"); | |
| 47 | if (readOnly) { | |
| 48 | chmodSync(join(deep, "pkg"), 0o444); | |
| 49 | chmodSync(deep, 0o555); | |
| 50 | } | |
| 51 | }; | |
| 52 | const run = () => createRun(app.db, { repoId, kind: "check", env: "x", commitOid: "a".repeat(40) }); | |
| 53 | ||
| 54 | test("a finished run's home is removed, a live run's survives", () => { | |
| 55 | const live = run(); | |
| 56 | claimRun(app.db, live.id); | |
| 57 | const done = run(); | |
| 58 | claimRun(app.db, done.id); | |
| 59 | finishRun(app.db, done.id, { state: "ok", summary: "ok" }); | |
| 60 | populate(live.id); | |
| 61 | populate(done.id); | |
| 62 | populate("no-such-run"); | |
| 63 | sweepCheckouts(app); | |
| 64 | expect(existsSync(home(live.id))).toBe(true); | |
| 65 | expect(existsSync(home(done.id))).toBe(false); | |
| 66 | expect(existsSync(home("no-such-run"))).toBe(false); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the abandoned-checks timer sweeps a finished run's home, not a live one's", () => { | |
| 70 | const live = run(); | |
| 71 | claimRun(app.db, live.id); | |
| 72 | const done = run(); | |
| 73 | claimRun(app.db, done.id); | |
| 74 | finishRun(app.db, done.id, { state: "ok", summary: "ok" }); | |
| 75 | populate(live.id); | |
| 76 | populate(done.id); | |
| 77 | requeueAbandonedChecks(app, STALE_AFTER_MS); | |
| 78 | expect(existsSync(home(live.id))).toBe(true); | |
| 79 | expect(existsSync(home(done.id))).toBe(false); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a lapsed run's home is removed", () => { | |
| 83 | const r = run(); | |
| 84 | claimRun(app.db, r.id); | |
| 85 | populate(r.id); | |
| 86 | sweepCheckouts(app, Date.now() + 24 * 3600_000); | |
| 87 | expect(existsSync(home(r.id))).toBe(false); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a home with a read-only file is still removed", () => { | |
| 91 | const r = run(); | |
| 92 | claimRun(app.db, r.id); | |
| 93 | finishRun(app.db, r.id, { state: "failed", summary: "red" }); | |
| 94 | populate(r.id, true); | |
| 95 | sweepCheckouts(app); | |
| 96 | expect(existsSync(home(r.id))).toBe(false); | |
| 97 | }); | |
| 98 | ||
| 99 | test("tearing a preview down removes its home", () => { | |
| 100 | ensureEnvData(dataDir, "app1", "pull-1", "empty"); | |
| 101 | const h = join(dataDir, "apps", "app1", "pull-1", "home"); | |
| 102 | mkdirSync(h, { recursive: true }); | |
| 103 | writeFileSync(join(h, "cache"), "x"); | |
| 104 | removeEnv(dataDir, "app1", "pull-1"); | |
| 105 | expect(existsSync(h)).toBe(false); | |
| 106 | }); |
new above
offline
This call is the fix, and it is untested. Add a test that goes through
requeueAbandonedChecks.