Public: anyone can read this; the crew can change it.
Evict the shared check install cache by age or size (<data>/check-install/<repo>/bun) #7659
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7574-evict-the-shared-check-install-cache-by → main 6 commits fork 3 threads · 3 unresolved
docs/APPS.md +11 −1
| @@ -717,7 +717,15 @@ fails and says `infrastructure`. A check in a cell installs into its own | ||
| 717 | 717 | home, so its bun cache is shared with nothing. An uncontained check uses |
| 718 | 718 | its repository's cache (`<data>/check-install/<repo>/bun`), and installs |
| 719 | 719 | into it take turns under a lock file, so two never extract one package |
| 720 | into the same directory. A `job` check installs in its own pod and is not | |
| 720 | into the same directory. That cache is bounded (`src/checks/install_evict.ts`, | |
| 721 | #7574): a fill, or an uncontained install, marks the packages its `bun.lock` | |
| 722 | names as lent, and under the same lock, in the background and at most hourly, | |
| 723 | the forge removes entries not lent for 30 days (`FORGE_CHECK_CACHE_DAYS`), then | |
| 724 | the oldest until the repository's cache is under 2 GB (`FORGE_CHECK_CACHE_MB`). | |
| 725 | An evicted package is fetched again by the next install. Only the text | |
| 726 | `bun.lock` is read: a repository on a binary `bun.lockb` lends nothing, so its | |
| 727 | packages age out after 30 days and are fetched again (a cell never fills from | |
| 728 | one at all). A `job` check installs in its own pod and is not | |
| 721 | 729 | retried. |
| 722 | 730 | |
| 723 | 731 | **An app environment installs from that same cache** (`src/apps/process.ts`, |
| @@ -1553,6 +1561,8 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1553 | 1561 | | `FORGE_CHECKS_DRAIN_S` | `10` | seconds a roll lets running checks finish first | |
| 1554 | 1562 | | `FORGE_CREW_DRAIN_S` | `10` | seconds a roll lets crew runs finish before they are queued again | |
| 1555 | 1563 | | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) | |
| 1564 | | `FORGE_CHECK_CACHE_DAYS` | `30` | days a package may go unlent before the check install cache drops it | | |
| 1565 | | `FORGE_CHECK_CACHE_MB` | `2048` | size one repository's check install cache is swept down to | | |
| 1556 | 1566 | | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) | |
| 1557 | 1567 | | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only | |
| 1558 | 1568 | | `FORGE_CREW_CPUS` | `4` | cores one crew run may use (`cpu.max` on its own cell cgroup, where the cpu controller is delegated) | |
src/checks/install.ts +4 −1
| @@ -21,6 +21,7 @@ import { tmpdir } from "node:os"; | ||
| 21 | 21 | import { dirname, join } from "node:path"; |
| 22 | 22 | import { formatBytes } from "../core/storage.ts"; |
| 23 | 23 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 24 | import { lend } from "./install_evict.ts"; | |
| 24 | 25 | |
| 25 | 26 | /** What an install prints when the machine, not the code, failed it. */ |
| 26 | 27 | export const TRANSIENT_INSTALL = /Fail extracting tarball|EINTEGRITY|ECONNRESET/; |
| @@ -172,7 +173,9 @@ export async function prefillInstallCache(options: { | ||
| 172 | 173 | try { |
| 173 | 174 | await Promise.all([lines(proc.stdout), lines(proc.stderr)]); |
| 174 | 175 | const code = await proc.exited; |
| 175 | return code === 0 ? ok(true) : err(`filling the install cache exited ${code}`); | |
| 176 | if (code !== 0) return err(`filling the install cache exited ${code}`); | |
| 177 | lend(cache, dir); | |
| 178 | return ok(true); | |
| 176 | 179 | } finally { |
| 177 | 180 | clearTimeout(timer); |
| 178 | 181 | signal.removeEventListener("abort", stop); |
src/checks/install_evict.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * The shared check install cache has a bound (#7574): it is filled from every | |
| 3 | * pull's lockfile and would otherwise grow by the union of every version any | |
| 4 | * checked commit locked. | |
| 5 | * | |
| 6 | * - **A lend** is a fill, or an uncontained install, that found the lockfile's | |
| 7 | * packages: it touches their entries ({@link lend}), so an entry's mtime is | |
| 8 | * when a check last used it. | |
| 9 | * - **Eviction** removes an entry not lent for {@link CACHE_MAX_AGE_DAYS} | |
| 10 | * days, then the oldest until the cache is under {@link CACHE_MAX_BYTES}. | |
| 11 | * It runs under the cache's lock, so a fill never sees a half-removed | |
| 12 | * package, and off the request path ({@link evictSoon}, at most hourly). | |
| 13 | * A cell reads the cache without the lock; an entry lent within the age | |
| 14 | * bound is only taken by the size cap, and only after every older one. | |
| 15 | */ | |
| 16 | ||
| 17 | import { existsSync, lutimesSync, readdirSync, readFileSync } from "node:fs"; | |
| 18 | import { lstat, readdir, rm } from "node:fs/promises"; | |
| 19 | import { join } from "node:path"; | |
| 20 | import { withLock } from "./install.ts"; | |
| 21 | ||
| 22 | export const CACHE_MAX_AGE_DAYS = 30; | |
| 23 | export const CACHE_MAX_BYTES = 2 * 1024 ** 3; | |
| 24 | const SWEEP_EVERY_MS = 60 * 60_000; | |
| 25 | ||
| 26 | export interface Bound { | |
| 27 | readonly maxAgeDays: number; | |
| 28 | readonly maxBytes: number; | |
| 29 | } | |
| 30 | ||
| 31 | /** The bound, from `FORGE_CHECK_CACHE_DAYS` / `FORGE_CHECK_CACHE_MB` when they are positive numbers. */ | |
| 32 | export function boundFrom(env: Record<string, string | undefined>): Bound { | |
| 33 | const num = (v: string | undefined): number | undefined => { | |
| 34 | const n = Number(v); | |
| 35 | return v !== undefined && Number.isFinite(n) && n > 0 ? n : undefined; | |
| 36 | }; | |
| 37 | const mb = num(env["FORGE_CHECK_CACHE_MB"]); | |
| 38 | return { | |
| 39 | maxAgeDays: num(env["FORGE_CHECK_CACHE_DAYS"]) ?? CACHE_MAX_AGE_DAYS, | |
| 40 | maxBytes: mb === undefined ? CACHE_MAX_BYTES : mb * 1024 ** 2, | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | function entries(cache: string): string[] { | |
| 45 | return existsSync(cache) ? readdirSync(cache).filter((n) => !n.startsWith(".")) : []; | |
| 46 | } | |
| 47 | ||
| 48 | async function sizeOf(path: string): Promise<number> { | |
| 49 | const st = await lstat(path).catch(() => undefined); | |
| 50 | if (st === undefined) return 0; | |
| 51 | if (!st.isDirectory()) return st.size; | |
| 52 | let total = 0; | |
| 53 | for (const name of await readdir(path).catch(() => [])) total += await sizeOf(join(path, name)); | |
| 54 | return total; | |
| 55 | } | |
| 56 | ||
| 57 | /** Mark the entries the lockfile in `dir` names as used now. Hold the cache lock. */ | |
| 58 | export function lend(cache: string, dir: string, now = Date.now()): void { | |
| 59 | try { | |
| 60 | lendAll(cache, dir, now); | |
| 61 | } catch { | |
| 62 | // A lend is a hint to the sweep; an entry gone under it must not fail the fill. | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | function lendAll(cache: string, dir: string, now: number): void { | |
| 67 | const lock = join(dir, "bun.lock"); | |
| 68 | if (!existsSync(lock)) return; | |
| 69 | const ids = new Set<string>(); | |
| 70 | for (const m of readFileSync(lock, "utf8").matchAll(/"((?:@[^"/@]+\/)?[^"/@]+@\d[^"]*)"/g)) | |
| 71 | ids.add((m[1] ?? "").replace("/", "++") + "@@"); | |
| 72 | const at = new Date(now); | |
| 73 | for (const name of entries(cache)) { | |
| 74 | for (const id of ids) | |
| 75 | if (name.startsWith(id)) { | |
| 76 | lutimesSync(join(cache, name), at, at); | |
| 77 | break; | |
| 78 | } | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | /** | |
| 83 | * Evict by age, then by size. Entries are measured before the lock, off the | |
| 84 | * event loop; the lock covers only the removals, each re-checked for a lend | |
| 85 | * since. Answers the entries removed, or undefined when the lock was not held. | |
| 86 | */ | |
outdated
HIGH: sizeOf uses synchronous readdir and lstat over the whole cache (up to 2 GB, thousands of files), inside the lock. This blocks the event loop, which breaks the sub-50ms promise for every other request. It can also run past LOCK_STALE_MS, so another fill steals the lock mid-removal. Fix: size entries asynchronously (fs/promises) and yield between entries. Alternatively refresh the lock while sweeping, or keep a size index so you do not walk the tree. Add a test that a fill is not blocked past the stale window. |
||
| 87 | export async function evictInstallCache(options: { | |
| 88 | readonly cache: string; | |
| 89 | readonly bound: Bound; | |
| 90 | readonly signal: AbortSignal; | |
| 91 | readonly now?: number | undefined; | |
| 92 | }): Promise<string[] | undefined> { | |
| 93 | const { cache, bound, signal } = options; | |
| 94 | const now = options.now ?? Date.now(); | |
| 95 | const left: { name: string; mtime: number; size: number }[] = []; | |
| 96 | for (const name of entries(cache)) { | |
| 97 | const st = await lstat(join(cache, name)).catch(() => undefined); | |
| 98 | if (st !== undefined) left.push({ name, mtime: st.mtimeMs, size: await sizeOf(join(cache, name)) }); | |
| 99 | } | |
| 100 | left.sort((a, b) => a.mtime - b.mtime); | |
| 101 | const cutoff = now - bound.maxAgeDays * 86_400_000; | |
| 102 | let total = left.reduce((sum, e) => sum + e.size, 0); | |
| 103 | const held = await withLock( | |
| 104 | `${cache}.lock`, | |
| 105 | async (): Promise<string[]> => { | |
| 106 | const removed: string[] = []; | |
| 107 | for (const e of left) { | |
| 108 | if (e.mtime >= cutoff && total <= bound.maxBytes) break; | |
| 109 | const st = await lstat(join(cache, e.name)).catch(() => undefined); | |
outdated
MEDIUM: lastSweep is stamped before the sweep runs, and the sweep uses the check signal passed from service.ts. If the check finishes or aborts, the lock wait aborts, the sweep returns [] silently, and no sweep runs for another hour. Use a process-lifetime signal for the background sweep, and stamp lastSweep only after a completed run. Test that an aborted sweep does not suppress the next one. |
||
| 110 | if (st === undefined) continue; | |
| 111 | if (st.mtimeMs !== e.mtime) continue; // lent since it was measured | |
| 112 | await rm(join(cache, e.name), { recursive: true, force: true }); | |
| 113 | total -= e.size; | |
| 114 | removed.push(e.name); | |
| 115 | } | |
| 116 | return removed; | |
| 117 | }, | |
| 118 | { signal }, | |
| 119 | ); | |
| 120 | return held.ok ? held.value : undefined; | |
| 121 | } | |
| 122 | ||
| 123 | const lastSweep = new Map<string, number>(); | |
| 124 | const sweeping = new Set<string>(); | |
| 125 | const lifetime = new AbortController().signal; | |
| 126 | ||
| 127 | /** | |
| 128 | * Sweep `cache` in the background, at most hourly after a completed sweep; | |
| 129 | * never throws, never awaited by a check. It outlives the check that started | |
| 130 | * it, so it answers to no check's signal. Resolves when the sweep is over. | |
| 131 | */ | |
| 132 | export function evictSoon( | |
| 133 | cache: string, | |
| 134 | env: Record<string, string | undefined>, | |
| 135 | signal: AbortSignal = lifetime, | |
| 136 | ): Promise<void> { | |
| 137 | const now = Date.now(); | |
| 138 | if (sweeping.has(cache) || now - (lastSweep.get(cache) ?? 0) < SWEEP_EVERY_MS) return Promise.resolve(); | |
| 139 | sweeping.add(cache); | |
| 140 | return evictInstallCache({ cache, bound: boundFrom(env), signal }) | |
| 141 | .then((removed) => { | |
| 142 | if (removed !== undefined) lastSweep.set(cache, now); | |
| 143 | }) | |
| 144 | .catch(() => {}) | |
| 145 | .finally(() => sweeping.delete(cache)); | |
| 146 | } |
src/checks/service.ts +12 −1
| @@ -114,6 +114,7 @@ import { | ||
| 114 | 114 | prefillInstallCache, |
| 115 | 115 | withLock, |
| 116 | 116 | } from "./install.ts"; |
| 117 | import { evictSoon, lend } from "./install_evict.ts"; | |
| 117 | 118 | import { meterEgress } from "../apps/egress.ts"; |
| 118 | 119 | import { checkStage, type GateStage } from "./gate_reason.ts"; |
| 119 | 120 | import { proofsInFlight } from "./proof.ts"; |
| @@ -1247,11 +1248,21 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1247 | 1248 | }); |
| 1248 | 1249 | if (!fill.ok) return fill; |
| 1249 | 1250 | filled = fill.value; |
| 1251 | void evictSoon(warm, process.env); | |
| 1250 | 1252 | } |
| 1251 | 1253 | return once(log); |
| 1252 | 1254 | } |
| 1253 | 1255 | mkdirSync(shared, { recursive: true }); |
| 1254 | const held = await withLock(`${shared}.lock`, () => once(log), { signal }); | |
| 1256 | const held = await withLock( | |
| 1257 | `${shared}.lock`, | |
| 1258 | async () => { | |
| 1259 | const done = await once(log); | |
| 1260 | if (done.ok) lend(shared, dir); | |
| 1261 | return done; | |
| 1262 | }, | |
| 1263 | { signal }, | |
| 1264 | ); | |
| 1265 | if (held.ok && held.value.ok) void evictSoon(shared, process.env); | |
| 1255 | 1266 | return held.ok ? held.value : held; |
| 1256 | 1267 | }, |
| 1257 | 1268 | { log: write, signal, retryMs: options.installRetryMs }, |
test/check-install.test.ts +135 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | * machine's — queued again, never the pull's red. |
| 5 | 5 | */ |
| 6 | 6 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; |
| 7 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 7 | import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"; | |
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| @@ -22,6 +22,7 @@ import { | ||
| 22 | 22 | transientLine, |
| 23 | 23 | withLock, |
| 24 | 24 | } from "../src/checks/install.ts"; |
| 25 | import { boundFrom, evictInstallCache, evictSoon, lend } from "../src/checks/install_evict.ts"; | |
| 25 | 26 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 26 | 27 | import { INFRA_SUMMARY, listRuns, type Run } from "../src/core/runs.ts"; |
| 27 | 28 | import { createUser } from "../src/core/users.ts"; |
| @@ -159,6 +160,7 @@ describe("a check whose install fails on the machine", () => { | ||
| 159 | 160 | let checks: ChecksHandle; |
| 160 | 161 | let flaky: Repo; |
| 161 | 162 | let broken: Repo; |
| 163 | let lender: Repo; | |
| 162 | 164 | |
| 163 | 165 | // Fails once with bun's own line, then installs: the marker lives in the |
| 164 | 166 | // checkout, which the retry shares and the next check does not. |
| @@ -181,7 +183,7 @@ describe("a check whose install fails on the machine", () => { | ||
| 181 | 183 | adminIfFirst: true, |
| 182 | 184 | }); |
| 183 | 185 | if (!a.ok) throw new Error(a.error); |
| 184 | const repoWith = async (name: string, script: string): Promise<Repo> => { | |
| 186 | const repoWith = async (name: string, script: string, more: { path: string; content: string }[] = []): Promise<Repo> => { | |
| 185 | 187 | const made = await createRepo(app, a.value, { name, visibility: "public" }); |
| 186 | 188 | if (!made.ok) throw new Error(String(made.error)); |
| 187 | 189 | const c = await commitToBranch(app, a.value, made.value, { |
| @@ -189,6 +191,7 @@ describe("a check whose install fails on the machine", () => { | ||
| 189 | 191 | files: [ |
| 190 | 192 | { path: "forge.json", content: forgeJson }, |
| 191 | 193 | { path: "install.sh", content: script }, |
| 194 | ...more, | |
| 192 | 195 | ], |
| 193 | 196 | }); |
| 194 | 197 | if (!c.ok) throw new Error(JSON.stringify(c.error)); |
| @@ -196,6 +199,10 @@ describe("a check whose install fails on the machine", () => { | ||
| 196 | 199 | }; |
| 197 | 200 | flaky = await repoWith("flaky", once); |
| 198 | 201 | broken = await repoWith("broken", always); |
| 202 | // Leaves a package the lockfile names, and one it does not, both untouched for years. | |
| 203 | const aged = | |
| 204 | 'for p in [email protected]@@@1 [email protected]@@@1; do mkdir -p "$BUN_INSTALL_CACHE_DIR/$p"; touch -d 2000-01-01 "$BUN_INSTALL_CACHE_DIR/$p"; done\n'; | |
| 205 | lender = await repoWith("lender", aged, [{ path: "bun.lock", content: '{"packages": {"a": ["[email protected]"]}}\n' }]); | |
| 199 | 206 | }); |
| 200 | 207 | afterAll(async () => { |
| 201 | 208 | await checks.stop(); |
| @@ -227,6 +234,16 @@ describe("a check whose install fails on the machine", () => { | ||
| 227 | 234 | ).toBe(false); |
| 228 | 235 | }); |
| 229 | 236 | |
| 237 | test("an uncontained install lends what its lockfile names, and the sweep takes the rest (#7574)", async () => { | |
| 238 | const runs = await settled(lender, (rs) => rs.some((r) => r.state === "ok" || r.state === "failed")); | |
| 239 | expect(runs[0]?.state).toBe("ok"); | |
| 240 | const cache = checkInstallCache(dataDir, lender.id); | |
| 241 | const deadline = Date.now() + 3_000; | |
| 242 | while (existsSync(join(cache, "[email protected]@@@1")) && Date.now() < deadline) await Bun.sleep(50); | |
| 243 | expect(existsSync(join(cache, "[email protected]@@@1"))).toBe(false); | |
| 244 | expect(statSync(join(cache, "[email protected]@@@1")).mtimeMs).toBeGreaterThan(Date.now() - 86_400_000); | |
| 245 | }); | |
| 246 | ||
| 230 | 247 | test("failing twice is infrastructure: queued again, not red; a second time it fails saying so", async () => { |
| 231 | 248 | const runs = await settled(broken, (rs) => rs.some((r) => r.state === "failed")); |
| 232 | 249 | const first = runs.find((r) => r.attempt === 0)!; |
| @@ -347,6 +364,122 @@ describe("prefillInstallCache (#7543)", () => { | ||
| 347 | 364 | } |
| 348 | 365 | }); |
| 349 | 366 | |
| 367 | test("eviction drops the entry not lent for days, keeps the lent one, and the next install is offline (#7574)", async () => { | |
| 368 | const reg = await registry(); | |
| 369 | try { | |
| 370 | const co = await checkout(reg.url, reg.dir); | |
| 371 | const cache = join(reg.dir, "cache"); | |
| 372 | const fill = () => prefillInstallCache({ cache, dir: co, env: envFor(reg.dir, reg.url), log: () => {}, signal: never }); | |
| 373 | expect(await fill()).toEqual({ ok: true, value: true }); | |
| 374 | const stale = join(cache, "[email protected]@@@1"); | |
| 375 | mkdirSync(stale); | |
| 376 | writeFileSync(join(stale, "package.json"), "{}"); | |
| 377 | const long = new Date(Date.now() - 90 * 86_400_000); | |
| 378 | utimesSync(stale, long, long); | |
| 379 | // The locked package is old too, until a fill lends it. | |
| 380 | const lent = readdirSync(cache).filter((n) => n.startsWith("[email protected]")); | |
| 381 | expect(lent.length).toBeGreaterThan(0); | |
| 382 | for (const n of lent) utimesSync(join(cache, n), long, long); | |
| 383 | expect(await fill()).toEqual({ ok: true, value: true }); | |
| 384 | const removed = await evictInstallCache({ cache, bound: boundFrom({}), signal: never }); | |
| 385 | expect(removed).toEqual(["[email protected]@@@1"]); | |
| 386 | expect(existsSync(stale)).toBe(false); | |
| 387 | expect(existsSync(`${cache}.lock`)).toBe(false); | |
| 388 | reg.stop(); | |
| 389 | expect(await fill()).toEqual({ ok: true, value: true }); | |
| 390 | } finally { | |
| 391 | reg.stop(); | |
| 392 | await rm(reg.dir, { recursive: true, force: true }); | |
| 393 | } | |
| 394 | }); | |
| 395 | ||
| 396 | test("past the size cap the oldest go first; the bound reads its env", async () => { | |
| 397 | const dir = await scratch(); | |
| 398 | try { | |
| 399 | const cache = join(dir, "cache"); | |
| 400 | const now = Date.now(); | |
| 401 | for (const [i, name] of ["a@1@@@1", "b@1@@@1", "c@1@@@1"].entries()) { | |
| 402 | mkdirSync(join(cache, name), { recursive: true }); | |
| 403 | writeFileSync(join(cache, name, "f"), "x".repeat(1000)); | |
| 404 | const t = new Date(now - (3 - i) * 1000); | |
| 405 | utimesSync(join(cache, name), t, t); | |
| 406 | } | |
| 407 | const removed = await evictInstallCache({ cache, bound: { maxAgeDays: 30, maxBytes: 2500 }, signal: never, now }); | |
| 408 | expect(removed).toEqual(["a@1@@@1"]); | |
| 409 | expect(boundFrom({ FORGE_CHECK_CACHE_DAYS: "2", FORGE_CHECK_CACHE_MB: "5" })).toEqual({ maxAgeDays: 2, maxBytes: 5 * 1024 ** 2 }); | |
| 410 | expect(boundFrom({ FORGE_CHECK_CACHE_DAYS: "x", FORGE_CHECK_CACHE_MB: "-1" }).maxAgeDays).toBe(30); | |
| 411 | } finally { | |
| 412 | await rm(dir, { recursive: true, force: true }); | |
| 413 | } | |
| 414 | }); | |
| 415 | ||
| 416 | test("a failing lend leaves the fill alone", async () => { | |
| 417 | const dir = await scratch(); | |
| 418 | try { | |
| 419 | writeFileSync(join(dir, "bun.lock"), '"[email protected]"'); | |
| 420 | // A cache that is a file: reading it throws ENOTDIR inside the lend. | |
| 421 | const cache = join(dir, "c"); | |
| 422 | writeFileSync(cache, ""); | |
| 423 | expect(() => lend(cache, dir)).not.toThrow(); | |
| 424 | } finally { | |
| 425 | await rm(dir, { recursive: true, force: true }); | |
| 426 | } | |
| 427 | }); | |
| 428 | ||
| 429 | test("a sweep runs once an hour per cache, and a second while one runs collapses", async () => { | |
| 430 | const dir = await scratch(); | |
| 431 | try { | |
| 432 | const cache = join(dir, "cache"); | |
| 433 | const old = new Date(Date.now() - 40 * 86_400_000); | |
| 434 | const stale = (name: string): string => { | |
| 435 | mkdirSync(join(cache, name), { recursive: true }); | |
| 436 | utimesSync(join(cache, name), old, old); | |
| 437 | return join(cache, name); | |
| 438 | }; | |
| 439 | const a = stale("a@1@@@1"); | |
| 440 | // Hold the cache's lock so the first sweep waits under it. | |
| 441 | const held = await withLock( | |
| 442 | `${cache}.lock`, | |
| 443 | async () => { | |
| 444 | const first = evictSoon(cache, {}); | |
| 445 | const second = evictSoon(cache, {}); | |
| 446 | // A second sweep would wait on this lock too; it must be over at once. | |
| 447 | const over = await Promise.race([second.then(() => true), Bun.sleep(200).then(() => false)]); | |
| 448 | expect(existsSync(a)).toBe(true); | |
| 449 | return { first, over }; | |
| 450 | }, | |
| 451 | { signal: never }, | |
| 452 | ); | |
| 453 | expect(held.ok && held.value.over).toBe(true); | |
| 454 | if (held.ok) await held.value.first; | |
| 455 | expect(existsSync(a)).toBe(false); | |
| 456 | const b = stale("b@1@@@1"); | |
| 457 | await evictSoon(cache, {}); | |
| 458 | expect(existsSync(b)).toBe(true); | |
| 459 | } finally { | |
| 460 | await rm(dir, { recursive: true, force: true }); | |
| 461 | } | |
| 462 | }); | |
| 463 | ||
| 464 | test("an aborted sweep does not suppress the next one", async () => { | |
| 465 | const dir = await scratch(); | |
| 466 | try { | |
| 467 | const cache = join(dir, "cache"); | |
| 468 | mkdirSync(join(cache, "a@1@@@1"), { recursive: true }); | |
| 469 | writeFileSync(join(cache, "a@1@@@1", "f"), "x"); | |
| 470 | const old = new Date(Date.now() - 40 * 86_400_000); | |
| 471 | utimesSync(join(cache, "a@1@@@1"), old, old); | |
| 472 | const gone = new AbortController(); | |
| 473 | gone.abort(); | |
| 474 | expect(await evictInstallCache({ cache, bound: boundFrom({}), signal: gone.signal })).toBeUndefined(); | |
| 475 | expect(existsSync(join(cache, "a@1@@@1"))).toBe(true); | |
| 476 | await evictSoon(cache, {}); | |
| 477 | expect(existsSync(join(cache, "a@1@@@1"))).toBe(false); | |
| 478 | } finally { | |
| 479 | await rm(dir, { recursive: true, force: true }); | |
| 480 | } | |
| 481 | }); | |
| 482 | ||
| 350 | 483 | test("a fill's timeout is clamped under the lock's staleness", () => { |
| 351 | 484 | expect(fillTimeoutMs(undefined)).toBe(LOCK_STALE_MS / 2); |
| 352 | 485 | expect(fillTimeoutMs(LOCK_STALE_MS * 4)).toBe(LOCK_STALE_MS / 2); |
test/tui-egress.test.ts +4 −2
| @@ -41,7 +41,8 @@ describe("private destinations in the terminal", () => { | ||
| 41 | 41 | listEvents(forge.db, { repoId: repo.id }).filter((e) => e.kind.startsWith("app.egress.")); |
| 42 | 42 | |
| 43 | 43 | function boot(user: User) { |
| 44 | const terminal = headlessTerminal(120, 32); | |
| 44 | // Wide enough that the About card's hint line reaches `e egress`. | |
| 45 | const terminal = headlessTerminal(180, 32); | |
| 45 | 46 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; |
| 46 | 47 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); |
| 47 | 48 | app.start(); |
| @@ -205,7 +206,8 @@ describe("private destinations in the terminal", () => { | ||
| 205 | 206 | const before = changes().length; |
| 206 | 207 | const view = await egressView(forge, erin, repo); |
| 207 | 208 | expect(view.ok && view.value.rows.length).toBe(2); |
| 208 | const terminal = headlessTerminal(120, 32); | |
| 209 | // Wide enough that the About card's hint line reaches `e egress`. | |
| 210 | const terminal = headlessTerminal(180, 32); | |
| 209 | 211 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: erin }; |
| 210 | 212 | const { app, session } = createForgeApp(forge, identity, { terminal, exitHooks: false }); |
| 211 | 213 | app.start(); |
MEDIUM: lend runs inside the fill and is not guarded. A lutimesSync or readFileSync error, for example an entry removed concurrently by a cell or by bun, throws out of a fill that succeeded, and the pull sees an infrastructure failure. Wrap it in try/catch and ignore errors, then test that a failing lend leaves the fill ok.