Public: anyone can read this; the crew can change it.

Concurrent check installs race on the shared bun cache ("Fail extracting tarball"): good pulls look red and stall the merge queue #5094

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/5093-concurrent-check-installs-race-on-the-sh → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/5093-concurrent-check-installs-race-on-the-sh was merged into main as 5725c03.

Review

Sign in to review this change.
5 files changed +495 −14
docs/APPS.md +12
@@ -420,6 +420,18 @@ A `check` that is present and wrong fails with the reason, so a commit is
420420 never silently unchecked. A check under way when the forge restarts is
421421 queued again, and the forge that carries on runs it from the start.
422422
423 **An install failure is not a test failure** (`src/checks/install.ts`,
424 #5093). An install that fails with `Fail extracting tarball`, `EINTEGRITY`
425 or `ECONNRESET` is tried once more after 2 s. If it fails that way again,
426 the check is `failed: infrastructure`. It is queued again, and the merge
427 queue counts it as cut off, not red. If the re-run fails the same way, it
428 fails and says `infrastructure`. A check in a cell installs into its own
429 home, so its bun cache is shared with nothing. An uncontained check uses
430 its repository's cache (`<data>/check-install/<repo>/bun`), and installs
431 into it take turns under a lock file, so two never extract one package
432 into the same directory. A `job` check installs in its own pod and is not
433 retried.
434
423435 **Placement.** `FORGE_CHECKS_PLACEMENT=process` (the default) runs a check
424436 in a cell on the forge's machine. `job` runs install and check as one
425437 Kubernetes Job (`src/cluster/jobs.ts`) instead: `FORGE_CHECKS_IMAGE` is its
src/checks/install.ts added +194
@@ -0,0 +1,194 @@
1 /**
2 * A check's install step (#5093): the package manager's own flake is not the
3 * pull's fault. Concurrent installs into one bun cache race on extracting the
4 * same tarball (`Fail extracting tarball`), and a registry stream cut short
5 * reads the same way, or as `EINTEGRITY` / `ECONNRESET`.
6 *
7 * - **No racing on a shared cache.** A check in a cell has a home of its own,
8 * so its cache is its own: nothing is shared, and a pull's install never
9 * fills a cache another check reads. A check that runs uncontained would
10 * otherwise share the forge's `~/.bun` with every check of every
11 * repository; it gets its repository's own cache instead
12 * (`<data>/check-install/<repo>/bun`), and installs into it one at a time,
13 * under a lock file beside it — across checks, gates and forges alike.
14 * - **A transient failure is retried once**, after {@link INSTALL_RETRY_MS}.
15 * Failing the same way twice is {@link INFRASTRUCTURE}: the service queues
16 * the check again and the merge queue reads it as cut off, never as red.
17 */
18
19 import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20 import { dirname, join } from "node:path";
21 import { err, ok, type Result } from "../kernel/result.ts";
22
23 /** What an install prints when the machine, not the code, failed it. */
24 export const TRANSIENT_INSTALL = /Fail extracting tarball|EINTEGRITY|ECONNRESET/;
25
26 /** How long a transient install failure waits before its one retry. */
27 export const INSTALL_RETRY_MS = 2_000;
28
29 /** The error prefix of an install the machine failed twice: not the pull's red. */
30 export const INFRASTRUCTURE = "infrastructure";
31
32 /** A lock older than this is a holder that died: a whole install never takes so long. */
33 export const LOCK_STALE_MS = 15 * 60_000;
34
35 const LOCK_POLL_MS = 50;
36
37 export type InstallOutcome =
38 | { readonly ok: true }
39 | { readonly ok: false; readonly error: string; readonly infrastructure: boolean };
40
41 /** The first line of an install's output that says the failure was transient. */
42 export function transientLine(lines: readonly string[]): string | undefined {
43 return lines.find((line) => TRANSIENT_INSTALL.test(line));
44 }
45
46 /** A repository's bun cache for its uncontained checks; the lock file sits beside it. */
47 export function checkInstallCache(dataDir: string, repoId: string): string {
48 return join(dataDir, "check-install", repoId.replace(/[^A-Za-z0-9_-]/g, "_"), "bun");
49 }
50
51 /**
52 * Run `attempt` (one install, its output lines handed to `log`), and once
53 * more after `retryMs` when it failed with a {@link TRANSIENT_INSTALL} line.
54 * A failure without one is the code's, answered at once.
55 */
56 export async function installWithRetry(
57 attempt: (log: (line: string) => void) => Promise<Result<void, string>>,
58 options: {
59 readonly log: (line: string) => void;
60 readonly signal: AbortSignal;
61 readonly retryMs?: number | undefined;
62 },
63 ): Promise<InstallOutcome> {
64 let transient: string | undefined;
65 for (let tries = 0; tries < 2; tries++) {
66 const lines: string[] = [];
67 const ran = await attempt((line) => {
68 lines.push(line);
69 options.log(line);
70 });
71 if (ran.ok) return { ok: true };
72 transient = transientLine(lines);
73 if (transient === undefined || options.signal.aborted)
74 return { ok: false, error: ran.error, infrastructure: false };
75 if (tries === 0) {
76 const wait = options.retryMs ?? INSTALL_RETRY_MS;
77 options.log(`install hit a transient failure (${transient.trim()}); retrying once in ${wait / 1000}s`);
78 if (!(await sleep(wait, options.signal)))
79 return { ok: false, error: ran.error, infrastructure: false };
80 }
81 }
82 return {
83 ok: false,
84 error: `${INFRASTRUCTURE}: install failed twice — ${transient?.trim() ?? ""}`,
85 infrastructure: true,
86 };
87 }
88
89 /**
90 * Run `fn` holding the lock file at `path`, made with `wx` so exactly one
91 * holder at a time, across processes. A lock whose holder is gone (its pid
92 * dead on this machine, or older than `staleMs`) is taken over. Answers an
93 * Err, without running `fn`, when `signal` aborts the wait.
94 */
95 export async function withLock<T>(
96 path: string,
97 fn: () => Promise<T>,
98 options: { readonly signal: AbortSignal; readonly staleMs?: number | undefined },
99 ): Promise<Result<T, string>> {
100 // Holders in this process queue in memory, so a release hands over at
101 // once; the file is what holds other processes out.
102 const before = local.get(path) ?? Promise.resolve();
103 let release = (): void => {};
104 const mine = new Promise<void>((resolve) => {
105 release = resolve;
106 });
107 const tail = before.then(() => mine);
108 local.set(path, tail);
109 try {
110 if (!(await settledBefore(before, options.signal)))
111 return err("cancelled waiting for the install lock");
112 return await holdFile(path, fn, options);
113 } finally {
114 release();
115 if (local.get(path) === tail) local.delete(path);
116 }
117 }
118
119 const local = new Map<string, Promise<void>>();
120
121 async function holdFile<T>(
122 path: string,
123 fn: () => Promise<T>,
124 options: { readonly signal: AbortSignal; readonly staleMs?: number | undefined },
125 ): Promise<Result<T, string>> {
126 mkdirSync(dirname(path), { recursive: true });
127 const staleMs = options.staleMs ?? LOCK_STALE_MS;
128 for (;;) {
129 if (options.signal.aborted) return err("cancelled waiting for the install lock");
130 try {
131 writeFileSync(path, `${process.pid} ${Date.now()}\n`, { flag: "wx" });
132 break;
133 } catch (error) {
134 if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
135 }
136 // Taking over a dead holder's lock can race another waiter doing the
137 // same; that only happens after a crash, and costs one overlap at worst.
138 if (stale(path, staleMs)) rmSync(path, { force: true });
139 else await sleep(LOCK_POLL_MS, options.signal);
140 }
141 try {
142 return ok(await fn());

Taking over a stale lock removes the file unconditionally, so two waiters can each remove the other's fresh lock and run installs together. That is the race this change exists to stop, only rarer. Rename the stale lock to a unique name first, or re-check the contents before removing it.

143 } finally {
144 rmSync(path, { force: true });
145 }
146 }
147
148 function stale(path: string, staleMs: number): boolean {
149 const stat = lstatSync(path, { throwIfNoEntry: false });
150 if (stat === undefined) return false;
151 if (Date.now() - stat.mtimeMs > staleMs) return true;
152 let pid = Number.NaN;
153 try {
154 pid = Number.parseInt(readFileSync(path, "utf8"), 10);
155 } catch {
156 return false;
157 }
158 if (!Number.isSafeInteger(pid) || pid <= 0) return false;
159 try {
160 process.kill(pid, 0);
161 return false;
162 } catch (error) {
163 return (error as NodeJS.ErrnoException).code === "ESRCH";
164 }
165 }
166
167 /** Wait for `before`; false when `signal` aborted it first. */
168 function settledBefore(before: Promise<void>, signal: AbortSignal): Promise<boolean> {
169 if (signal.aborted) return Promise.resolve(false);
170 return new Promise((resolve) => {
171 const stop = (): void => resolve(false);
172 signal.addEventListener("abort", stop, { once: true });
173 void before.then(() => {
174 signal.removeEventListener("abort", stop);
175 resolve(true);
176 });
177 });
178 }
179
180 /** Wait `ms`; false when `signal` aborted it first. */
181 function sleep(ms: number, signal: AbortSignal): Promise<boolean> {
182 if (signal.aborted) return Promise.resolve(false);
183 return new Promise((resolve) => {
184 const timer = setTimeout(() => {
185 signal.removeEventListener("abort", stop);
186 resolve(true);
187 }, ms);
188 const stop = (): void => {
189 clearTimeout(timer);
190 resolve(false);
191 };
192 signal.addEventListener("abort", stop, { once: true });
193 });
194 }
src/checks/service.ts +51 −14
@@ -37,6 +37,8 @@ import {
3737 setRunBase,
3838 vanishedBefore,
3939 VANISHED_SUMMARY,
40 infraBefore,
41 INFRA_SUMMARY,
4042 requeueCheck,
4143 REQUEUED_SUMMARY,
4244 sweepAbandonedChecks,
@@ -84,6 +86,7 @@ import { summariseCheckFailure } from "./summary.ts";
8486 import type { Result } from "../kernel/result.ts";
8587 import { placementFromEnv, type Placement } from "./placement.ts";
8688 import { JOB_VANISHED, runJobStep } from "./job_step.ts";
89 import { checkInstallCache, installWithRetry, withLock } from "./install.ts";
8790 import { extraGates } from "./queue.ts";
8891 import type { JobOptions } from "../cluster/jobs.ts";
8992 import type { CheckDetail } from "../core/runs.ts";
@@ -128,6 +131,8 @@ export interface ChecksOptions {
128131 readonly placement?: Placement | undefined;
129132 /** The kube client a `job` placement speaks through; tests stand one in. */
130133 readonly job?: JobOptions | undefined;
134 /** How long a transient install failure waits for its retry. @default INSTALL_RETRY_MS */
135 readonly installRetryMs?: number | undefined;
131136 }
132137
133138 export interface ChecksHandle {
@@ -594,6 +599,16 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
594599 return;
595600 }
596601 }
602 // The install failed twice on what the machine did, not the code: the
603 // check is queued once more and nothing reads it as red (#5093).
604 if (!outcome.ok && outcome.infrastructure === true && !infraBefore(app.db, claimed)) {
605 const moved = requeueCheck(app.db, run.id, undefined, INFRA_SUMMARY);
606 if (moved) {
607 write(INFRA_SUMMARY);
608 announceRun(app, moved.again, run.actorId ?? undefined);
609 return;
610 }
611 }
597612 const seconds = Math.round((Date.now() - started) / 1000);
598613 const done = finishRun(app.db, run.id, {
599614 state: outcome.ok ? "ok" : "failed",
@@ -620,7 +635,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
620635 signal: AbortSignal,
621636 ): Promise<
622637 | { ok: true; proven?: string }
623 | { ok: false; error: string; detail?: CheckDetail | undefined }
638 | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean }
624639 > {
625640 const bare = await runDir(app.db, app.reposRoot, repo, run.issueId);
626641 // A pull's check is the fast tier, judged against where the pull left
@@ -670,7 +685,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
670685 x: Execution,
671686 ): Promise<
672687 | { ok: true; proven?: string }
673 | { ok: false; error: string; detail?: CheckDetail | undefined }
688 | { ok: false; error: string; detail?: CheckDetail | undefined; infrastructure?: boolean }
674689 > {
675690 const { bare, repo, write, signal } = x;
676691 // A pull's fast check is of the pull as it would land: its head merged
@@ -896,15 +911,31 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
896911 });
897912 } else {
898913 if (manifest.install !== null) {
899 const installed = await runStep("install", manifest.install, {
900 cwd: dir,
901 env,
902 log: write,
903 signal,
904 timeoutMs: manifest.timeout * 1000,
905 cell,
906 });
907 if (!installed.ok) return { ok: false, error: installed.error };
914 const install = manifest.install;
915 // A cell's home is its own cache. Uncontained, the repository's
916 // cache is shared, so installs into it take turns (`checks/install.ts`).
917 const shared =
918 cell === undefined ? checkInstallCache(app.config.dataDir, repo.id) : undefined;
919 const once = (log: (line: string) => void): Promise<Result<void, string>> =>
920 runStep("install", install, {
921 cwd: dir,
922 env: shared === undefined ? env : { ...env, BUN_INSTALL_CACHE_DIR: shared },
923 log,
924 signal,
925 timeoutMs: manifest.timeout * 1000,
926 cell,
927 });
928 const installed = await installWithRetry(
929 async (log) => {
930 if (shared === undefined) return once(log);
931 mkdirSync(shared, { recursive: true });
932 const held = await withLock(`${shared}.lock`, () => once(log), { signal });
933 return held.ok ? held.value : held;
934 },
935 { log: write, signal, retryMs: options.installRetryMs },
936 );
937 if (!installed.ok)
938 return { ok: false, error: installed.error, infrastructure: installed.infrastructure };
908939 }
909940 // The check step's own lines are kept beside what `write` already
910941 // logs, so a failure can be summarised once here — never by reading
@@ -1185,9 +1216,15 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
11851216 signal: controller.signal,
11861217 });
11871218 if (done.ok) return { ok: true, error: "", lines };
1188 // Cut off by this forge stopping, or the Job lost under it: the
1189 // code was not judged, so this is no red.
1190 if (stopped || controller.signal.aborted || done.error === JOB_VANISHED)
1219 // Cut off by this forge stopping, the Job lost under it, or an
1220 // install the machine failed twice: the code was not judged, so
1221 // this is no red.
1222 if (
1223 stopped ||
1224 controller.signal.aborted ||
1225 done.error === JOB_VANISHED ||
1226 done.infrastructure === true
1227 )
11911228 return { ok: false, error: done.error, lines, interrupted: true };
11921229 return { ok: false, error: done.error, lines };
11931230 } finally {
src/core/runs.ts +8
@@ -459,6 +459,9 @@ export const REQUEUED_SUMMARY = "interrupted by a restart; queued again";
459459 /** What a check whose cluster Job vanished says as it is queued again. */
460460 export const VANISHED_SUMMARY = "interrupted: its cluster job vanished; queued again";
461461
462 /** What a check whose install the machine failed twice says as it is queued again (#5093). */
463 export const INFRA_SUMMARY = "failed: infrastructure — the install failed twice on a transient error; queued again";
464
462465 /**
463466 * How many times one check may be cut off by its forge going away and be
464467 * queued again. Past it the check fails once, saying so: a check that kills
@@ -521,6 +524,11 @@ export function vanishedBefore(db: Database, run: Run): boolean {
521524 return cutOffBefore(db, run, VANISHED_SUMMARY);
522525 }
523526
527 /** Whether the attempt just before this one failed on infrastructure: it is queued again once. */
528 export function infraBefore(db: Database, run: Run): boolean {
529 return cutOffBefore(db, run, INFRA_SUMMARY);
530 }
531
524532 function cutOffBefore(db: Database, run: Run, prefix: string): boolean {
525533 return (
526534 db
test/check-install.test.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * A check's install (#5093): installs into one cache take turns, a transient
3 * install failure is retried once, and failing the same way twice is the
4 * machine's — queued again, never the pull's red.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import {
14 checkInstallCache,
15 INFRASTRUCTURE,
16 installWithRetry,
17 transientLine,
18 withLock,
19 } from "../src/checks/install.ts";
20 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
21 import { INFRA_SUMMARY, listRuns, type Run } from "../src/core/runs.ts";
22 import { createUser } from "../src/core/users.ts";
23 import type { Repo } from "../src/core/repos.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { err, ok, type Result } from "../src/kernel/result.ts";
26
27 const never = new AbortController().signal;
28
29 describe("transientLine", () => {
30 test("names the three failures the machine causes, and nothing the code does", () => {
31 expect(transientLine(['error: Fail extracting tarball for "@typescript/typescript-linux-x64"'])).toContain("Fail extracting");
32 expect(transientLine(["npm ERR! code EINTEGRITY"])).toContain("EINTEGRITY");
33 expect(transientLine(["error: ECONNRESET reading registry.npmjs.org"])).toContain("ECONNRESET");
34 expect(transientLine(["error: No version matching \"9.9.9\" found", "exit 1"])).toBeUndefined();
35 });
36 });
37
38 describe("installWithRetry", () => {
39 const scripted = (outputs: (string | null)[]) => {
40 let calls = 0;
41 const attempt = async (log: (line: string) => void): Promise<Result<void, string>> => {
42 const line = outputs[calls++] ?? null;
43 if (line === null) return ok();
44 log(line);
45 return err(`install exited with code 1 — ${line}`);
46 };
47 return { attempt, calls: () => calls };
48 };
49
50 test("a transient failure is retried once and the retry's green is the answer", async () => {
51 const s = scripted(['error: Fail extracting tarball for "x"', null]);
52 const lines: string[] = [];
53 const done = await installWithRetry(s.attempt, { log: (l) => lines.push(l), signal: never, retryMs: 1 });
54 expect(done).toEqual({ ok: true });
55 expect(s.calls()).toBe(2);
56 expect(lines.some((l) => l.includes("retrying once"))).toBe(true);
57 });
58
59 test("the code's own failure is answered at once, and is not infrastructure", async () => {
60 const s = scripted(['error: No version matching "9.9.9"']);
61 const done = await installWithRetry(s.attempt, { log: () => {}, signal: never, retryMs: 1 });
62 expect(s.calls()).toBe(1);
63 expect(done.ok).toBe(false);
64 if (!done.ok) expect(done.infrastructure).toBe(false);
65 });
66
67 test("failing transiently twice is infrastructure, tried exactly twice", async () => {
68 const s = scripted(["EINTEGRITY", "ECONNRESET", null]);
69 const done = await installWithRetry(s.attempt, { log: () => {}, signal: never, retryMs: 1 });
70 expect(s.calls()).toBe(2);
71 expect(done.ok).toBe(false);
72 if (!done.ok) {
73 expect(done.infrastructure).toBe(true);
74 expect(done.error.startsWith(`${INFRASTRUCTURE}:`)).toBe(true);
75 }
76 });
77
78 test("a cancel during the wait does not retry", async () => {
79 const s = scripted(["ECONNRESET", null]);
80 const abort = new AbortController();
81 const pending = installWithRetry(s.attempt, { log: () => {}, signal: abort.signal, retryMs: 60_000 });
82 abort.abort();
83 const done = await pending;
84 expect(s.calls()).toBe(1);
85 expect(done.ok).toBe(false);
86 if (!done.ok) expect(done.infrastructure).toBe(false);
87 });
88 });
89
90 describe("withLock", () => {
91 let dir: string;
92 beforeAll(async () => {
93 dir = await mkdtemp(join(tmpdir(), "forge-install-lock-"));
94 });
95 afterAll(async () => {
96 await rm(dir, { recursive: true, force: true });
97 });
98
99 test("8 parallel installs on one cache, 20 rounds: none ever extracts beside another", async () => {
100 // Each install "extracts" the same package: a directory that exists only
101 // while one install is in it. `mkdir` fails when another is in there —
102 // the collision bun reports as `Fail extracting tarball`.
103 const cache = join(dir, "cache");
104 mkdirSync(cache, { recursive: true });
105 const install = async (): Promise<number> => {
106 const proc = Bun.spawn(
107 ["/bin/sh", "-c", 'mkdir "$1/pkg.extracting" && sleep 0.005 && rmdir "$1/pkg.extracting"', "sh", cache],
108 { stdin: "ignore", stdout: "ignore", stderr: "ignore" },
109 );
110 return proc.exited;
111 };
112 let failures = 0;
113 for (let round = 0; round < 20; round++) {
114 const codes = await Promise.all(
115 Array.from({ length: 8 }, () => withLock(`${cache}.lock`, install, { signal: never })),
116 );
117 for (const code of codes) if (!code.ok || code.value !== 0) failures++;
118 }
119 expect(failures).toBe(0);
120 expect(existsSync(`${cache}.lock`)).toBe(false);
121 }, 60_000);
122
123 test("a lock left by a holder that died is taken over", async () => {
124 const lock = join(dir, "dead.lock");
125 // A pid no process holds: the kernel's pid_max is far below it.
126 writeFileSync(lock, `${2 ** 30} ${Date.now()}\n`);
127 const held = await withLock(lock, async () => "ran", { signal: never });
128 expect(held).toEqual({ ok: true, value: "ran" });
129 });
130
131 test("a live holder's lock is waited for, and a cancel ends the wait without running", async () => {
132 const lock = join(dir, "live.lock");
133 writeFileSync(lock, `${process.pid} ${Date.now()}\n`);
134 const abort = new AbortController();
135 let ran = false;
136 const pending = withLock(lock, async () => {
137 ran = true;
138 }, { signal: abort.signal });
139 await Bun.sleep(120);
140 expect(ran).toBe(false);
141 abort.abort();
142 const held = await pending;
143 expect(held.ok).toBe(false);
144 expect(ran).toBe(false);
145 expect(existsSync(lock)).toBe(true);
146 });
147 });
148
149 describe("a check whose install fails on the machine", () => {
150 let dataDir: string;
151 let app: App;
152 let servers: Servers;
153 let checks: ChecksHandle;
154 let flaky: Repo;
155 let broken: Repo;
156
157 // Fails once with bun's own line, then installs: the marker lives in the
158 // checkout, which the retry shares and the next check does not.
159 const once =
160 'test -f .tried && exit 0; touch .tried; echo \'error: Fail extracting tarball for "@typescript/typescript-linux-x64"\'; exit 1\n';
161 const always = "echo 'error: Fail extracting tarball for \"x\"'; exit 1\n";
162 const forgeJson = '{"check": {"run": "exit 0", "install": "sh install.sh"}}\n';
163
164 beforeAll(async () => {
165 dataDir = await mkdtemp(join(tmpdir(), "forge-check-install-"));
166 app = createApp({ dataDir, port: 0 });
167 servers = await serve(app);
168 checks = startChecks(app, { forgeUrl: servers.url, concurrency: 2, installRetryMs: 10 });
169 const a = await createUser(app.db, {
170 origin: "seed",
171 trusted: true,
172 username: "alice",
173 email: "[email protected]",
174 password: "a-good-passphrase",
175 adminIfFirst: true,
176 });
177 if (!a.ok) throw new Error(a.error);
178 const repoWith = async (name: string, script: string): Promise<Repo> => {
179 const made = await createRepo(app, a.value, { name, visibility: "public" });
180 if (!made.ok) throw new Error(String(made.error));
181 const c = await commitToBranch(app, a.value, made.value, {
182 message: "Declare a check",
183 files: [
184 { path: "forge.json", content: forgeJson },
185 { path: "install.sh", content: script },
186 ],
187 });
188 if (!c.ok) throw new Error(JSON.stringify(c.error));
189 return made.value;
190 };
191 flaky = await repoWith("flaky", once);
192 broken = await repoWith("broken", always);
193 });
194 afterAll(async () => {
195 await checks.stop();
196 servers.stop();
197 app.close();
198 await rm(dataDir, { recursive: true, force: true });
199 });
200
201 const settled = async (repo: Repo, done: (runs: Run[]) => boolean): Promise<Run[]> => {
202 const deadline = Date.now() + 30_000;
203 let runs: Run[] = [];
204 while (Date.now() < deadline) {
205 runs = listRuns(app.db, repo.id, { kind: "check", limit: 50 });
206 if (done(runs)) break;
207 await Bun.sleep(50);
208 }
209 return runs;
210 };
211
212 test("one transient failure is retried in the same check, which passes", async () => {
213 const runs = await settled(flaky, (rs) => rs.some((r) => r.state === "ok" || r.state === "failed"));
214 expect(runs.length).toBe(1);
215 expect(runs[0]!.state).toBe("ok");
216 // Uncontained, the repository's own cache is the one bun is pointed at.
217 expect(existsSync(checkInstallCache(dataDir, flaky.id))).toBe(true);
218 });
219
220 test("failing twice is infrastructure: queued again, not red; a second time it fails saying so", async () => {
221 const runs = await settled(broken, (rs) => rs.some((r) => r.state === "failed"));
222 const first = runs.find((r) => r.attempt === 0)!;
223 const second = runs.find((r) => r.attempt === 1)!;
224 expect(first.state).toBe("cancelled");
225 expect(first.summary).toBe(INFRA_SUMMARY);
226 expect(second.state).toBe("failed");
227 expect(second.summary).toContain(`${INFRASTRUCTURE}:`);
228 expect(runs.length).toBe(2);
229 });
230 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs