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

A rollout must not turn in-flight gates red: an interrupted gate re-runs as the same candidate, and red (check) retries once before bisecting #6732

Merged opened this in answer to

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

Merged

mason/forge:mason/6722-a-rollout-must-not-turn-in-flight-gates → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/6722-a-rollout-must-not-turn-in-flight-gates was merged into main as f86257d.

Review

Sign in to review this change.
10 files changed +334 −20
charts/app/templates/deployment.yaml +4 −4
@@ -32,11 +32,11 @@ spec:
3232 annotations:
3333 plat.sh/user-env-rev: {{ .Values.userEnvRev | default "init" | quote }}
3434 spec:
35 # An hour for an SSH session to end on its own before the old pod is
36 # killed anyway.
3735 # The crew drains for three minutes, then runs are cut and resume on
38 # the next pod; a little more than that here.
39 terminationGracePeriodSeconds: 300
36 # the next pod. Checks and merge-queue gates under way drain for
37 # FORGE_CHECKS_DRAIN_S (660 s, a full gate's ten minutes and more), so
38 # a roll does not cut a gate off; a minute more than that here (#6722).
39 terminationGracePeriodSeconds: 720
4040 {{- with .Values.imagePullSecrets }}
4141 imagePullSecrets:
4242 {{- toYaml . | nindent 8 }}
docs/APPS.md +1 −1
@@ -1456,7 +1456,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
14561456 | `FORGE_CREW_CONCURRENCY` | 1 per 3 GiB above 2 GiB, 2–16 | agent runs at once (live: Settings → Capacity) |
14571457 | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) |
14581458 | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) |
1459 | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first |
1459 | `FORGE_CHECKS_DRAIN_S` | `660` | seconds a roll lets running checks finish first |
14601460 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
14611461 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
14621462 | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only |
docs/ops/ROLL.md +20
@@ -152,6 +152,26 @@ Nothing is committed: a commit to main would void every gate in flight
152152 (#6640). `src/core/releases-before-tags.json` keeps the releases cut before
153153 this, frozen.
154154
155 ## Gates in flight across a roll (#6722)
156
157 A roll does not turn a merge-queue gate red. On SIGTERM the old forge
158 starts no new gate and lets the gates under way finish for
159 `FORGE_CHECKS_DRAIN_S` (660 s: a full gate takes about ten minutes). The
160 chart's `terminationGracePeriodSeconds` is 720 for that reason, a minute
161 past the drain; lower one and lower the other with it, or the kubelet kills
162 the pod while its gates still run. The old and new pods share the node for
163 up to those 12 minutes, which section 1's rule already sizes for.
164
165 What the drain does not finish is no verdict, never red:
166
167 - a gate cut off at the drain's end, or one whose check a signal from
168 outside ended (the pod going, the kernel's OOM killer, exit 137 or 143),
169 is `interrupted`: no red batch, no suspect, and the new pod gates the same
170 candidate again (`src/checks/queue.ts`, #6611);
171 - a gate that came back `red (check)` — red with no test and no type error
172 named — is gated once more before the queue bisects it, as an install red
173 is. Red again, it is searched as the code's.
174
155175 ## The gate of record (#2794)
156176
157177 A release ships on two proofs, and nothing else holds it:
src/apps/process.ts +20
@@ -207,6 +207,23 @@ const VERSION_BANNER_RE = /^\S+ v[\d.]+ *\(/;
207207 */
208208 const ERROR_LINE_RE = /^[A-Za-z]*Error\b/i;
209209 const TAIL_LINES = 20;
210 /** A shell's code for a child a signal ended: 128 + the signal's number. */
211 const SIGNAL_CODES: Readonly<Record<number, NodeJS.Signals>> = {
212 129: "SIGHUP",
213 130: "SIGINT",
214 137: "SIGKILL",
215 143: "SIGTERM",
216 };
217 /** What `runStep` says of a step a signal no one here sent ended (#6722). */
218 const KILLED = / was killed by SIG[A-Z]+$/;
219
220 /**
221 * Whether a step's error is a signal from outside ending it — the pod
222 * going, the kernel's OOM killer — not the step's own exit (#6722).
223 */
224 export function killedStep(error: string): boolean {
225 return KILLED.test(error);
226 }
210227 const REASON_MAX = 300;
211228
212229 /**
@@ -330,6 +347,9 @@ export async function runStep(
330347 // The kernel's OOM kill at the cell's cap is a breach no watch tick saw.
331348 const kernel = code === 0 ? undefined : cell?.breach();
332349 if (kernel !== undefined) return err(`${name} stopped: it ${kernel}`);
350 // Ended by a signal this run never sent: no verdict of the step's own.
351 const killer = code === 0 ? undefined : (proc.signalCode ?? SIGNAL_CODES[code]);
352 if (killer !== undefined) return err(`${name} was killed by ${killer}`);
333353 if (code !== 0) {
334354 const reason = recent.length > 0 ? ` — ${failureReason(recent)}` : "";
335355 return err(`${name} exited with code ${code}${reason}`);
src/checks/queue.ts +35 −7
@@ -46,8 +46,13 @@
4646 * `typecheck` or `check` is the code's and searched as above; `install`,
4747 * `setup` or `timeout` names no pull's code, so the gate runs once more and
4848 * red that way twice is infrastructure — no verdict, nothing handed back,
49 * nothing bisected. A pull that conflicts with an earlier one of its batch
50 * is never red: it is left out, the pair named, and the rest gate at once.
49 * nothing bisected. A `check` red at the line's own gate (a batch or a
50 * suspect, not a search's prefix) is gated once more before it is searched
51 * (#6722): a check cut short that printed no reason is not yet a red. A
52 * check a signal from outside ended — the pod going, the OOM killer — is
53 * no verdict at all, as a gate cut off is not. A pull that conflicts with
54 * an earlier one of its batch is never red: it is left out, the pair named,
55 * and the rest gate at once.
5156 *
5257 * What is queued is not stored: it is the pulls `landIfGreen` admitted, and
5358 * every sweep admits them again. The queue only orders them and remembers
@@ -649,6 +654,15 @@ function machines(verdict: LandVerdict): verdict is Red {
649654 return !verdict.lands && !("interrupted" in verdict) && !searched(verdict.reason);
650655 }
651656
657 /**
658 * A red that names neither a test nor a type error (#6722): the check
659 * exited red and said nothing a pull could be searched for — a check cut
660 * short by a reason it did not print. Gated once more before it is searched.
661 */
662 function unproven(verdict: LandVerdict): verdict is Red {
663 return !verdict.lands && !("interrupted" in verdict) && verdict.reason?.kind === "check";
664 }
665
652666 /** Gate runs a repository's line has made since boot. */
653667 export function gateRuns(repoId: string): number {
654668 return lines.get(repoId)?.gates ?? 0;
@@ -658,13 +672,26 @@ export function gateRuns(repoId: string): number {
658672 * The line's gate (`attempt`), and once more when it was red for a reason
659673 * that names no pull's code (#5276, #5093): an install, the gate's own
660674 * setup, a timeout. Red that way twice is infrastructure — no verdict, as a
661 * gate cut off is not: no pull is handed back and nothing is searched.
675 * gate cut off is not: no pull is handed back and nothing is searched. A
676 * line's own `red (check)` — no test named, no type error — is gated once
677 * more too (#6722), and red again it is searched; a `search` gate is not.
662678 */
663 function counted(line: Line, gate: GateCandidate, adopt: QueueOptions["adopt"]): Gated {
679 function counted(line: Line, gate: GateCandidate, adopt: QueueOptions["adopt"], search: boolean): Gated {
664680 const once = attempt(line, gate, adopt);
665681 return async (candidate, signal, resumed) => {
666682 const first = await once(candidate, signal, resumed);
667 if (!machines(first) || signal?.aborted === true) return first;
683 // A stopped line gates nothing more: its verdict is kept for the next.
684 if (signal?.aborted === true || line.stopped) return first;
685 // The line's own red (check) is gated once more before the search
686 // spends a gate on it (#6722); a search's prefix is not.
687 if (!search && unproven(first)) {
688 log.info(`gated ${pullsWord(candidate.steps.length)}: red (check) named no test, gating once more`, {
689 repo: line.repoId,
690 pulls: candidate.steps.map((s) => `#${s.entry.pull.number}`).join(","),

This retry costs a whole second gate for every real check red. Consider gating the retry fast, or only when the first run ended under a second or so, since a check cut short with no reason is usually quick to fail.

691 });
692 return once(candidate, signal);
693 }
694 if (!machines(first)) return first;
668695 const again = await once(candidate, signal);
669696 if (!machines(again)) return again;
670697 log.warn(`gated ${pullsWord(candidate.steps.length)}: infrastructure (${reasonWords(again.reason)}, twice)`, {
@@ -796,7 +823,8 @@ const ARRIVED = Symbol("arrived");
796823 */
797824 async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Promise<void> {
798825 line.draining = true;
799 const gated = counted(line, gate, options.adopt);
826 const gated = counted(line, gate, options.adopt, false);
827 const searching = counted(line, gate, options.adopt, true);
800828 const depth = Math.max(1, Math.floor(options.depth ?? DEFAULT_DEPTH));
801829 const room: Room = options.room ?? (() => 0);
802830 const ahead: Flight[] = [];
@@ -845,7 +873,7 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr
845873 ahead.shift();
846874 let left: Waiting[];
847875 try {
848 left = await settleHead({ line, gated, room, onBatch: options.onBatch }, head, verdict, ahead);
876 left = await settleHead({ line, gated: searching, room, onBatch: options.onBatch }, head, verdict, ahead);
849877 } catch (error) {
850878 log.warn("merge queue round failed", { error: String(error) });
851879 for (const w of head.steps) w.settle(false);
src/checks/service.ts +11 −6
@@ -71,7 +71,7 @@ import { exportTree, readFileAt } from "../apps/checkout.ts";
7171 import { parseCheck } from "../apps/manifest.ts";
7272 import { workerCpus } from "../core/capacity.ts";
7373 import { accountActive } from "../core/accounts.ts";
74 import { childEnv, runStep } from "../apps/process.ts";
74 import { childEnv, killedStep, runStep } from "../apps/process.ts";
7575 import { noteReached } from "../apps/egress_report.ts";
7676 import {
7777 capacityCount,
@@ -737,6 +737,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
737737 infrastructure?: boolean;
738738 /** Where it stopped red (#5276); none is before any step ran. */
739739 stage?: GateStage;
740 /** The check step was ended by a signal from outside: no verdict (#6722). */
741 killed?: boolean;
740742 }
741743 > {
742744 const { bare, repo, write, signal } = x;
@@ -1041,7 +1043,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
10411043 checkLines,
10421044 ran.error,
10431045 );
1044 return { ok: false, error: summary, detail, stage: checkStage(ran.error) };
1046 const killed = killedStep(ran.error);
1047 return { ok: false, error: killed ? ran.error : summary, detail, stage: checkStage(ran.error), ...(killed ? { killed } : {}) };
10451048 }
10461049 // Only a green check of the default branch is kept for the next.
10471050 if (
@@ -1278,13 +1281,15 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12781281 });
12791282 if (done.ok) return { ok: true, error: "", lines };
12801283 const stage = done.stage ?? "setup";
1281 // Cut off by this forge stopping, the Job lost under it, or an
1282 // install the machine failed twice: the code was not judged, so
1283 // this is no red. A gate the drain lets finish is judged (#6611).
1284 // Cut off by this forge stopping, the Job lost under it, the check
1285 // killed by a signal from outside (the pod going, the OOM killer), or
1286 // an install the machine failed twice: the code was not judged, so
1287 // this is no red. A gate the drain lets finish is judged (#6611, #6722).
12841288 if (
12851289 controller.signal.aborted ||
12861290 done.error === JOB_VANISHED ||
1287 done.infrastructure === true
1291 done.infrastructure === true ||
1292 done.killed === true
12881293 )
12891294 return { ok: false, error: done.error, lines, interrupted: true, stage };
12901295 return { ok: false, error: done.error, lines, stage };
src/main.ts +4 −2
@@ -358,8 +358,10 @@ const checks =
358358 concurrency: positive(process.env.FORGE_CHECKS_CONCURRENCY, Number(capacityDefault("checks.concurrency"))),
359359 redHoldMs: RED_HOLD_MS,
360360 landSettleMs: LAND_SETTLE_MS,
361 // A roll lets running checks finish this long before cutting them off.
362 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 300) * 1000,
361 // A roll lets running checks and merge-queue gates finish this long
362 // before cutting them off: a full gate takes about ten minutes, and
363 // the pod's grace (charts/app, 720 s) is longer still (#6722).
364 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 660) * 1000,
363365 });
364366 // Migrated repositories kept as a read-only mirror push to their source.
365367 const mirrorOut = startMirrorOut(app);
test/gate-killed.test.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * A gate whose check a signal from outside ended — the pod going, the
3 * kernel's OOM killer — is no verdict (#6722): the check never said red,
4 * so the gate answers `interrupted`, as one the forge stopping cut off
5 * does, and the merge queue hands no one back and marks no suspect. A
6 * check that exits red on its own is still red.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { killedStep, runStep } from "../src/apps/process.ts";
16 import { gateCandidate } from "../src/checks/landing.ts";
17 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
18 import { getPull } from "../src/core/pulls.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { repoDir } from "../src/git/repos.ts";
21
22 describe("a step a signal from outside ended", () => {
23 const step = (command: string) =>
24 runStep("check", command, { cwd: tmpdir(), env: { PATH: process.env["PATH"] ?? "" }, log: () => {}, signal: new AbortController().signal });
25
26 test("says it was killed, by which signal, whether the shell or its child took it", async () => {
27 const own = await step("kill -KILL $$");
28 expect(own).toEqual({ ok: false, error: "check was killed by SIGKILL" });
29 const child = await step("sh -c 'kill -TERM $$'; exit $?");
30 expect(child).toEqual({ ok: false, error: "check was killed by SIGTERM" });
31 expect(killedStep(own.ok ? "" : own.error)).toBe(true);
32 });
33
34 test("a step that exits red on its own was not killed", async () => {
35 const red = await step("exit 1");
36 expect(red.ok).toBe(false);
37 expect(killedStep(red.ok ? "" : red.error)).toBe(false);
38 });
39 });
40
41 describe("a gate on land whose check was killed", () => {
42 let dataDir: string;
43 let app: App;
44 let checks: ChecksHandle;
45 let alice: User;
46 const cells = process.env["FORGE_CELLS"];
47
48 beforeAll(async () => {
49 process.env["FORGE_CELLS"] = "off";
50 dataDir = await mkdtemp(join(tmpdir(), "forge-gate-killed-"));
51 app = createApp({ dataDir, port: 0 });
52 checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 2 });
53 const a = await createUser(app.db, {
54 origin: "seed",
55 trusted: true,
56 username: "alice",
57 email: "[email protected]",
58 password: "a-good-passphrase",
59 adminIfFirst: true,
60 });
61 if (!a.ok) throw new Error(a.error);
62 alice = a.value;
63 });
64
65 afterAll(async () => {
66 await checks.stop({ drain: false });
67 app.close();
68 if (cells === undefined) delete process.env["FORGE_CELLS"];
69 else process.env["FORGE_CELLS"] = cells;
70 await rm(dataDir, { recursive: true, force: true });
71 });
72
73 /** Gate a pull of a repository whose full check is `full`. */
74 async function gateWith(name: string, full: string) {
75 const made = await createRepo(app, alice, { name, visibility: "public" });
76 if (!made.ok) throw new Error(String(made.error));
77 const repo = made.value;
78 const seeded = await commitToBranch(app, alice, repo, {
79 message: "Start",
80 files: [{ path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true", full } }) }],
81 });
82 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
83 const branched = await commitToBranch(app, alice, repo, {
84 branch: "feature",
85 message: "Touch b",
86 files: [{ path: "b.txt", content: "b\n" }],
87 });
88 if (!branched.ok) throw new Error(JSON.stringify(branched.error));
89 const opened = await openPull(app, alice, repo, { title: "Feature", baseBranch: "main", headBranch: "feature" });
90 if (!opened.ok) throw new Error(String(opened.error));
91 const pull = getPull(app.db, repo.id, opened.value.number)!;
92 return gateCandidate(app, repo, pull, repoDir(app.reposRoot, repo.ownerName, repo.name), pull.headOid);
93 }
94
95 test("answers interrupted, not red (check)", async () => {
96 expect(await gateWith("killed", "echo test/slow.test.ts:; kill -KILL $$")).toEqual({ lands: false, interrupted: true });
97 }, 30_000);
98
99 test("a check that exits red on its own is red (check)", async () => {
100 const verdict = await gateWith("exited", "echo test/slow.test.ts:; exit 1");
101 expect(verdict).toMatchObject({ lands: false, reason: { kind: "check" } });
102 }, 30_000);
103 });
test/merge-queue-check-retry.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * A `red (check)` — the check exited red and named no test and no type
3 * error — is gated once more before the queue searches it (#6722): a check
4 * cut short that printed no reason is not yet a red. Green the second time,
5 * the batch lands whole with nothing bisected; red again, it is searched,
6 * and the search's own prefixes are not gated twice.
7 */
8 import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { type Candidate, enqueue, type Expect, type Queued } from "../src/checks/queue.ts";
13 import type { LandVerdict } from "../src/checks/tiers.ts";
14 import type { Pull } from "../src/core/pulls.ts";
15
16 describe("the merge queue gates a red (check) once more before it searches", () => {
17 let dir: string;
18 const told: number[] = [];
19 let logged: string[] = [];
20 const spies: { mockRestore(): void }[] = [];
21
22 const git = (...args: string[]) =>
23 Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args]).stdout.toString().trim();
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-merge-queue-check-retry-"));
27 Bun.spawnSync(["git", "init", "-q", "-b", "main", dir]);
28 git("commit", "-q", "--allow-empty", "-m", "start");
29 for (const level of ["info", "warn"] as const)
30 spies.push(spyOn(console, level).mockImplementation((...args: unknown[]) => void logged.push(args.join(" "))));
31 });
32
33 afterEach(() => {
34 logged = [];
35 told.length = 0;
36 });
37
38 afterAll(async () => {
39 for (const s of spies) s.mockRestore();
40 await rm(dir, { recursive: true, force: true });
41 });
42
43 async function headWriting(branch: string, file: string): Promise<string> {
44 git("checkout", "-q", "-B", branch, "main");
45 git("reset", "-q", "--hard", "main");
46 await Bun.write(join(dir, file), `${file}\n`);
47 git("add", file);
48 git("commit", "-q", "-m", file);
49 git("checkout", "-q", "--detach");
50 return git("rev-parse", branch);
51 }
52
53 function entry(prefix: string, number: number, headOid: string): Queued {
54 return {
55 pull: { id: `${prefix}-${number}`, number, baseBranch: "main", headOid } as Pull,
56 dir,
57 land: async (expect: Expect) => {
58 if (git("rev-parse", "refs/heads/main") !== expect.baseOid) return undefined;
59 const tip = git("commit-tree", expect.treeOid, "-p", expect.baseOid, "-m", `#${number}`);
60 git("update-ref", "refs/heads/main", tip);
61 return tip;
62 },
63 release: () => {},
64 handBack: () => told.push(number),
65 };
66 }
67
68 const pullsOf = (c: Candidate) => c.steps.map((s) => s.entry.pull.number);
69 const redCheck: LandVerdict = { lands: false, reopen: true, comment: "red", learn: [], reason: { kind: "check" } };
70
71 test("red (check) once, then green: the batch lands whole on the second gate, nothing bisected", async () => {
72 const pulls: Queued[] = [];
73 for (let i = 1; i <= 4; i++) pulls.push(entry("once", i, await headWriting(`a${i}`, `a${i}.txt`)));
74 const gates: number[][] = [];
75 const gate = async (c: Candidate): Promise<LandVerdict> => {
76 gates.push(pullsOf(c));
77 return gates.length === 1 ? redCheck : { lands: true };
78 };
79 const answers = await enqueue("check-retry-once", pulls, gate, { depth: 1, room: () => 3 });
80 expect(gates).toEqual([[1, 2, 3, 4], [1, 2, 3, 4]]);
81 expect(answers).toEqual([true, true, true, true]);
82 expect(told).toEqual([]);
83 expect(logged.some((l) => l.includes("red (check) named no test, gating once more"))).toBe(true);
84 expect(logged.some((l) => l.includes("gated alone"))).toBe(false);
85 });
86
87 test("red (check) twice is the code's: searched, each prefix gated once, the culprit handed back", async () => {
88 const pulls: Queued[] = [];
89 for (let i = 11; i <= 12; i++) pulls.push(entry("twice", i, await headWriting(`b${i}`, i === 12 ? "bad.txt" : `b${i}.txt`)));
90 const gates: number[][] = [];
91 const gate = async (c: Candidate): Promise<LandVerdict> => {
92 gates.push(pullsOf(c));
93 return git("ls-tree", "--name-only", c.tipOid).split("\n").includes("bad.txt") ? redCheck : { lands: true };
94 };
95 const answers = await enqueue("check-retry-twice", pulls, gate, { depth: 1, room: () => 0 });
96 // The batch twice, then the search's front half once: green, it lands, and #12 is the culprit.
97 expect(gates).toEqual([[11, 12], [11, 12], [11]]);
98 expect(answers).toEqual([true, false]);
99 expect(told).toEqual([12]);
100 });
101
102 test("a test red is searched at once, as before", async () => {
103 const pulls: Queued[] = [];
104 for (let i = 21; i <= 22; i++) pulls.push(entry("tests", i, await headWriting(`c${i}`, i === 22 ? "bad.txt" : `c${i}.txt`)));
105 const gates: number[][] = [];
106 const gate = async (c: Candidate): Promise<LandVerdict> => {
107 gates.push(pullsOf(c));
108 return git("ls-tree", "--name-only", c.tipOid).split("\n").includes("bad.txt")
109 ? { ...redCheck, reason: { kind: "tests", failed: 1 } }
110 : { lands: true };
111 };
112 const answers = await enqueue("check-retry-tests", pulls, gate, { depth: 1, room: () => 0 });
113 expect(gates).toEqual([[21, 22], [21]]);
114 expect(answers).toEqual([true, false]);
115 expect(told).toEqual([22]);
116 });
117 });
test/merge-queue-restart.test.ts +19
@@ -190,6 +190,25 @@ describe("the merge queue across a restart", () => {
190190 expect(git(repo, "rev-parse", "main^{tree}")).toBe(record.treeOid);
191191 });
192192
193 test("a red (check) the stopped forge's gate came back with is gated once more as the same candidate, nothing searched (#6722)", async () => {
194 const { repo, pulls, tip, record } = await killedMidGate("restart-red-check");
195 // The roll's drain answers red with no test named: kept, not acted on.
196 running.get(tip)!.go({ ok: false, error: "check exited with code 1", lines: [], stage: "check" });
197 for (let i = 0; i < 200 && gateRecords(app.db, repo.id)[0]?.verdict === undefined; i++) await Bun.sleep(5);
198 expect(gateRecords(app.db, repo.id)[0]?.verdict).toMatchObject({ lands: false, reason: { kind: "check" } });
199 lost = true;
200 const again = sweepGreen(app, repo);
201 await gating(1);
202 // The very commit that was gated before, gated once more before any search.
203 expect(started).toEqual([tip, tip]);
204 running.get(tip)!.go({ ok: true, error: "", lines: [] });
205 expect(await again).toBe(3);
206 expect(started).toEqual([tip, tip]);
207 for (const pull of pulls) expect(merged(repo, pull)).toBe(true);
208 expect(git(repo, "rev-parse", "main^{tree}")).toBe(record.treeOid);
209 expect(redMarks(app.db, repo.id).size).toBe(0);
210 });
211
193212 test("a recorded candidate whose base moved is forgotten, and the line is planned afresh", async () => {
194213 const { repo, pulls, tip } = await killedMidGate("restart-moved");
195214 running.get(tip)!.go({ ok: false, error: "cut off", lines: [], interrupted: true });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs