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

Self-deploy, step 3: a green release swaps in by handing over the listening socket, health read before the old process stops, the old one kept on failure #1740

Merged opened this

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

Merged

scout/forge:scout/1663-self-deploy-step-3-a-green-release-swaps → main 4 commits fork 6 threads · 6 unresolved

  • Merged. scout/1663-self-deploy-step-3-a-green-release-swaps was merged into main as 9fc7d1f.

Review

Sign in to review this change.
10 files changed +594 −1
README.md +1 −1
@@ -38,7 +38,7 @@ bun run src/main.ts # web on :3000, ssh on :2222 (FORGE_SSH_PORT=0 turns it off
3838 bun src/main.ts --preflight # can this tree boot? config parses, migrations run on a copy of the database; one line, exit 0 or 1, no socket opened. Run it before the swap, not under load: the copy is not atomic against a live writer, and a `preflight-*` directory a killed run left behind is swept by the next run once an hour old, and a missing data directory is created, as boot would
3939 ```
4040
41 Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands there is written to `$FORGE_DATA/releases/<sha>/` (the newest `FORGE_RELEASES_KEEP`, default 5, are kept), preflighted with that tree's own `--preflight`, and recorded in the `release` table (sha, landed pull, verdict, its line, when). A red preflight is asked again once (the copy of the live database can tear under load); a second red is logged and told to the landed pull. Other repositories are untouched, and nothing is swapped. `FORGE_REPO` also names the parent code that spawned child forges fork.
41 Set `FORGE_REPO=owner/name` to the forge's own code and a pull the crew lands there is written to `$FORGE_DATA/releases/<sha>/` (the newest `FORGE_RELEASES_KEEP`, default 5, are kept), preflighted with that tree's own `--preflight`, and recorded in the `release` table (sha, landed pull, verdict, its line, when). A red preflight is asked again once (the copy of the live database can tear under load); a second red is logged and told to the landed pull. Other repositories are untouched, and nothing is swapped unless `FORGE_SELF_SWAP=1`: then the forge binds its port with SO_REUSEPORT, a green release is started from `releases/<sha>/` beside it on the same port (`FORGE_COMMIT` set to the sha), `/api/v1/health` is read until an answer names that sha and is well (`FORGE_SWAP_BUDGET_S`, default 60), and only then does this process drain as a SIGTERM does. A release not healthy in its budget is killed, the old keeps serving, and the log and the landed pull say so. One swap per process: a landing while another swap is in its budget, or after one succeeded, is released and recorded but not started (the new process runs it from the next restart). The SSH terminal is not handed over yet (the new process cannot bind the old one's port), so a process holding the SSH listener refuses the swap and tells the landed pull; run with `FORGE_SSH_PORT=0` to swap. The swap also needs `net.ipv4.tcp_migrate_req=1` (Linux 5.14+): without it the kernel resets the connections still queued on the old listener when it closes, so a swap is refused (and told to the landed pull) rather than failing requests. The release is a child of the old process, so the swap is for a forge run directly, not under a supervisor that kills its children when the old process exits (PID 1 of a container, a systemd unit with the default `KillMode=control-group`). SO_REUSEPORT lets any process of the forge's user bind the same port and take a share of its connections, so `FORGE_SELF_SWAP=1` assumes nothing else runs as the forge's user on the host network (agents and apps are sandboxed or in cells). `FORGE_REPO` also names the parent code that spawned child forges fork.
outdated

"or behind the platform to swap": the issue keeps the platform path untouched, and under the platform the old process exiting takes the release with it. Drop it. Also name the tcp_migrate_req requirement here.

4242
4343 If your shell already has `FORGE_URL` set — a crew workspace sets it to the
4444 forge you work for — this forge takes it as its own canonical address and
docs/THREATS.md +9
@@ -116,6 +116,15 @@ Wants: accounts, spam, resources, or a way to exhaust the forge.
116116
117117 ## Internal calls
118118
119 `FORGE_SELF_SWAP=1` binds the web port with SO_REUSEPORT so a release can share
120 it, which also lets any process running as the forge's user join the listener
121 and take a share of its connections, login posts included. It assumes nothing
122 else runs as that user on the host network (agents and apps are sandboxed or in
123 cells). Only one swap is made per process, so two landings never leave two
124 releases serving. The swap is refused unless `net.ipv4.tcp_migrate_req=1`, for
125 without it the old listener closing resets queued connections.
126 Test: `test/release-swap.test.ts`
127
119128 How each part proves itself to the next, today.
120129
121130 | Call | Authenticated by | Test |
scripts/gate.ts +1
@@ -69,6 +69,7 @@ export const GATE: readonly string[] = [
6969 "test/recovery-doc.test.ts",
7070 "test/release-checkout.test.ts",
7171 "test/release-preflight.test.ts",
72 "test/release-swap.test.ts",
7273 "test/threats-doc.test.ts",
7374 "test/returns.test.ts",
7475 "test/review-route.test.ts",
src/app.ts +8
@@ -161,6 +161,14 @@ export interface ForgeConfig {
161161 parentLoopback?: boolean;
162162 /** How many `releases/<sha>/` checkouts to keep (`FORGE_RELEASES_KEEP`). @default 5 */
163163 releasesKeep?: number;
164 /**
165 * A green release of the forge's own code takes over the port instead of
166 * waiting for a restart (`FORGE_SELF_SWAP=1`; `release/swap.ts`, #1663).
167 * The listener is bound with SO_REUSEPORT so the new process can share it.
168 */
169 selfSwap?: boolean;
170 /** How long a swapped-in release has to read healthy (`FORGE_SWAP_BUDGET_S`). @default 60000 */
171 swapBudgetMs?: number;
164172 }
165173
166174 export interface App {
src/checks/landing.ts +57
@@ -19,6 +19,8 @@ import {
1919 type Verdict,
2020 pruneReleases,
2121 } from "../release/checkout.ts";
22 import { beginSwap, endSwap, handover, startRelease } from "../release/swap.ts";
23 import { running } from "../kernel/version.ts";
2224 import { recordRelease, type Release } from "../release/record.ts";
2325 import type { Pull } from "../core/pulls.ts";
2426 import type { Repo } from "../core/repos.ts";
@@ -144,5 +146,60 @@ async function cut(
144146 `The landed tree ${sha.slice(0, 12)} failed its preflight, so it will not be released: ${verdict.line}`,
145147 );
146148 }
149 if (verdict.ok && out.ok) await swapIn(app, by, repo, pull, sha, out.value);
147150 return release;
148151 }
152
153 /**
154 * Hand the port to a green release and drain this process, once the new one
155 * has read healthy. Off unless `FORGE_SELF_SWAP=1`. A release that does not
156 * come up is stopped, the old keeps serving, and the lane and the landed pull
157 * are told.
158 */
159 async function swapIn(
160 app: App,
161 by: User,
162 repo: Repo,
163 pull: Pull,
164 sha: string,
165 dir: string,
166 ): Promise<void> {
167 const drain = handover.drain;

High: nothing stops a second landing's swapIn from starting another release while this one is in its budget, or after this process has drained. Both releases end up serving on the port for good. Guard it: allow one swap per process, and skip once draining.

168 const port = app.config.port ?? 0;
169 if (app.config.selfSwap !== true || drain === undefined || port <= 0) return;
170 if (running().commit === sha) return;
171 const turn = beginSwap();
172 if (!turn.ok) {
173 app.log.warn("release not swapped in", { sha: sha.slice(0, 12), line: turn.line });
174 if (turn.tell)
175 addComment(
176 app,
177 by,
178 repo,
179 getIssueById(app.db, pull.id) ?? pull,
180 `The landed tree ${sha.slice(0, 12)} did not swap in: ${turn.line}. The running process keeps serving.`,
181 );
182 return;
183 }
184 const swapped = await startRelease({ dir, sha, port, env: process.env,
185 ...(app.config.swapBudgetMs === undefined ? {} : { budgetMs: app.config.swapBudgetMs }),
186 });
187 endSwap(swapped.ok);
188 if (swapped.ok) {
189 app.log.info("release swapped in; draining this process", { sha: sha.slice(0, 12), pid: swapped.pid });
190 drain();
191 return;
192 }
193 app.log.error("release swap failed; the old process keeps serving", {
194 sha: sha.slice(0, 12),
195 pull: pull.number,
196 line: swapped.line,
197 });
198 addComment(
199 app,
200 by,
201 repo,
202 getIssueById(app.db, pull.id) ?? pull,
203 `The landed tree ${sha.slice(0, 12)} did not swap in: ${swapped.line}. The running process keeps serving.`,
204 );
205 }
src/http/server.ts +2
@@ -1732,6 +1732,8 @@ export async function serve(app: App): Promise<Servers> {
17321732
17331733 const web = Bun.serve({
17341734 port: app.config.port ?? 3000,
1735 // A release swaps in beside this process on the same port (#1663).
1736 ...(app.config.selfSwap === true ? { reusePort: true } : {}),
17351737 idleTimeout: 120,
17361738 // The largest thing anyone may send — a git push. Every reader below it
17371739 // keeps its own, smaller cap (\`http/limits.ts\`); the runtime enforces
src/main.ts +4
@@ -4,6 +4,7 @@
44 * environment-only — a handful of variables, all with sane defaults.
55 */
66
7 import { handover } from "./release/swap.ts";
78 import { parentUrlProblem } from "./core/parent_releases.ts";
89 import { releaseEnv } from "./release/env.ts";
910 import { createApp } from "./app.ts";
@@ -433,6 +434,9 @@ const leaseTimer = setInterval(() => {
433434 releaseOrphans(app.db);
434435 }, 30_000);
435436
437 // A release that read healthy beside this process takes over (#1663).
438 handover.drain = () => shutdown("SIGTERM");
439 handover.ssh = ssh !== undefined;
436440 process.on("SIGINT", shutdown);
437441 process.on("SIGTERM", shutdown);
438442
src/release/env.ts +5
@@ -3,11 +3,16 @@
33 export function releaseEnv(env: Record<string, string | undefined>): {
44 forgeRepo?: string;
55 releasesKeep?: number;
6 selfSwap?: boolean;
7 swapBudgetMs?: number;
68 } {
79 const repo = env.FORGE_REPO ?? "";
10 const budget = Number(env.FORGE_SWAP_BUDGET_S);
811 const keep = Number(env.FORGE_RELEASES_KEEP);
912 return {
1013 ...(/^[\w.-]+\/[\w.-]+$/.test(repo) ? { forgeRepo: repo } : {}),
14 ...(env.FORGE_SELF_SWAP === "1" ? { selfSwap: true } : {}),
15 ...(Number.isInteger(budget) && budget >= 1 && budget <= 600 ? { swapBudgetMs: budget * 1000 } : {}),
1116 ...(Number.isInteger(keep) && keep >= 1 ? { releasesKeep: keep } : {}),
1217 };
1318 }
src/release/swap.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * A green release takes over the listening port (#1663).
3 *
4 * The way: SO_REUSEPORT on the port. Both processes bind it (`reusePort` in
5 * `serve`), the kernel hands each new connection to one of them, and the old
6 * one is drained as a SIGTERM drains it today. Fd passing on a unix socket
7 * would need the platform's router to speak it and a listener Bun cannot
8 * adopt; the port is what the platform and the checks already dial, and
9 * nothing is torn down between the two listeners, so no connection finds the
10 * port closed.
11 *
12 * The new process is started from `releases/<sha>/`, told its commit
13 * (`FORGE_COMMIT`), and asked `/api/v1/health` until an answer names that
14 * commit and says `ok`. Any answer from the old process names another, so
15 * only the new one can satisfy the read. A new process that does not within
16 * its budget is stopped and the old keeps serving.
17 */
18
19 import { existsSync, readFileSync } from "node:fs";
20 import { join } from "node:path";
21
22 export const SWAP_BUDGET_MS = 60_000;
23 const POLL_MS = 250;
24
25 export type Swapped = { ok: true; pid: number } | { ok: false; line: string };
26
27 export interface SwapInput {
28 /** `releases/<sha>/`. */
29 dir: string;
30 sha: string;
31 port: number;
32 /** The environment the new forge starts with: the old one's, as it is. */
33 env: Record<string, string | undefined>;
34 budgetMs?: number;
35 }
36
37 /** Whether `body` is a health answer from `sha` that is well and not leaving. */
38 export function healthyAs(body: unknown, sha: string): boolean {
39 if (typeof body !== "object" || body === null) return false;
40 const h = body as { ok?: unknown; commit?: unknown; draining?: unknown };
41 return h.ok === true && h.draining !== true && h.commit === sha;
42 }
43
44 async function readHealth(port: number): Promise<unknown> {
45 try {
46 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
47 headers: { connection: "close" },
48 signal: AbortSignal.timeout(2_000),
49 });
50 return res.ok ? await res.json() : undefined;
51 } catch {
52 return undefined;
53 }
54 }
55
56 /** Start the release beside the running forge and wait for it to say it is well. */
57 export async function startRelease(input: SwapInput): Promise<Swapped> {
58 const entry = join(input.dir, "src", "main.ts");
59 if (!existsSync(entry)) return { ok: false, line: "the tree has no src/main.ts" };
60 const env: Record<string, string> = {};
61 for (const [k, v] of Object.entries(input.env)) if (v !== undefined) env[k] = v;
62 env["FORGE_COMMIT"] = input.sha;
63 env["PORT"] = String(input.port);
64 env["FORGE_SELF_SWAP"] = "1";

High: the new process gets the old one's FORGE_SSH_PORT. It can't bind that port while the old one holds it, so it boots with the terminal off and still reads healthy. After the old process drains, the terminal is gone. Refuse the swap while SSH is listening, or retry the SSH bind in the new process.

65 const proc = Bun.spawn([process.execPath, entry], {

The release is a child of the old process in its process group and cgroup. If the old process is PID 1 or a systemd service, its exit after drain() kills this child too. Document that the swap is for a forge run directly.

66 cwd: input.dir,
67 env,
68 stdin: "ignore",
69 stdout: "inherit",
70 stderr: "inherit",
71 });
72 let exited: number | undefined;
73 void proc.exited.then((code) => {
74 exited = code;
75 });
76 const deadline = Date.now() + (input.budgetMs ?? SWAP_BUDGET_MS);
77 while (Date.now() < deadline) {
78 if (exited !== undefined)
79 return { ok: false, line: `the new process exited with ${exited} before it was healthy` };
80 if (healthyAs(await readHealth(input.port), input.sha)) {
81 proc.unref();
82 return { ok: true, pid: proc.pid };
83 }
84 await Bun.sleep(POLL_MS);
85 }
86 proc.kill("SIGKILL");
87 await proc.exited;
88 return {
89 ok: false,
90 line: `the new process was not healthy within ${Math.round((input.budgetMs ?? SWAP_BUDGET_MS) / 1000)}s`,
91 };
92 }
93
94 /**
95 * What the running forge does once the release is well: drain, as a SIGTERM
96 * does. Set at boot, with whether this process holds the SSH listener (a new
97 * process cannot bind that port while this one does, and a release must not
98 * quietly close the terminal).
99 */
100 export const handover: { drain?: () => void; ssh?: boolean; migrate?: boolean } = {};
101
102 const MIGRATE_SYSCTL = "/proc/sys/net/ipv4/tcp_migrate_req";
103
104 /**
105 * Whether the kernel moves connections waiting in a closing listener's accept
106 * queue to another listener of the group (Linux 5.14+, `tcp_migrate_req=1`).
107 * Without it, the old process closing resets what it had not yet accepted, so
108 * the swap would fail requests. `handover.migrate` stands in for the file in tests.
109 */
110 export function migratesQueuedConnections(): boolean {
111 if (handover.migrate !== undefined) return handover.migrate;
112 try {
113 return readFileSync(MIGRATE_SYSCTL, "utf8").trim() === "1";
114 } catch {
115 return false;
116 }
117 }
118
119 let underWay = false;
120
121 /**
122 * Take the one swap this process may make. Refused while another is in its
123 * budget or once one has succeeded (this process is then draining), so two
124 * landings close together never leave two releases serving; and refused while
125 * this process holds an SSH listener. `tell` says whether the landed pull
126 * should hear of it. Refused too unless the kernel migrates queued connections
127 * between listeners of the port.
128 *
129 * The release is a child of this process, not detached: the swap is for a forge
130 * run directly, not under a supervisor (PID 1 of a container, a systemd unit
131 * with KillMode=control-group) whose old process exiting takes the release too.
132 */
133 export function beginSwap(): { ok: true } | { ok: false; line: string; tell: boolean } {
134 if (underWay)
135 return { ok: false, line: "a swap is already under way or done in this process", tell: false };
136 if (handover.ssh === true)
137 return {
138 ok: false,
139 line: "this process holds the SSH terminal, which a new process cannot take over yet; restart the forge to run this tree",
140 tell: true,
141 };
142 if (!migratesQueuedConnections())
143 return {
144 ok: false,
145 line: "the kernel would reset connections queued on the old listener when it closes; set net.ipv4.tcp_migrate_req=1 (Linux 5.14+) to swap, or restart the forge to run this tree",
146 tell: true,
147 };
148 underWay = true;
149 return { ok: true };
150 }
151
152 /** A swap that failed gives the turn back; one that worked keeps it, for this process is leaving. */
153 export function endSwap(swapped: boolean): void {
154 if (!swapped) underWay = false;
155 }
test/release-swap.test.ts added +352
@@ -0,0 +1,352 @@
1 /**
2 * A green release takes over the listening port (#1663): the new process
3 * starts beside the old on SO_REUSEPORT, is read healthy as its own commit,
4 * and only then does the old one drain; an unhealthy one is stopped and the
5 * old keeps serving. The stand-in forge is a few lines that answer the
6 * health route as the commit they are told.
7 */
8
9 import { afterAll, describe, expect, test } from "bun:test";
10 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp } from "../src/app.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { openPull } from "../src/actions/pulls.ts";
17 import { releaseOnLand } from "../src/checks/landing.ts";
18 import { listComments } from "../src/core/issues.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { beginSwap, endSwap, healthyAs, handover, migratesQueuedConnections, startRelease } from "../src/release/swap.ts";
21 import { releaseEnv } from "../src/release/env.ts";
22
23 const OLD = "0".repeat(40);
24 const NEW = "1".repeat(40);
25 const BAD = "2".repeat(40);
26
27 const STAND_IN = `
28 if (process.argv.includes("--preflight")) { console.log("preflight ok: stand-in"); process.exit(0); }
29 Bun.serve({
30 port: Number(process.env.PORT),
31 reusePort: true,
32 fetch: () => Response.json({ ok: true, draining: false, commit: process.env.FORGE_COMMIT }),
33 });
34 `;
35 // Boots and listens, but answers unwell.
36 const SICK = STAND_IN.replace("ok: true", "ok: false");
37 // Never listens at all.
38 const DEAD = `if (process.argv.includes("--preflight")) process.exit(0);\nawait Bun.sleep(60000);\n`;
39
40 const dirs: string[] = [];
41 const pids: number[] = [];
42 afterAll(async () => {
43 for (const pid of pids) {
44 try {
45 process.kill(pid, "SIGKILL");
46 } catch {}
47 }
48 for (const d of dirs) await rm(d, { recursive: true, force: true });
49 });
50
51 async function tree(main: string): Promise<string> {
52 const dir = await mkdtemp(join(tmpdir(), "forge-swap-"));
53 dirs.push(dir);
54 mkdirSync(join(dir, "src"), { recursive: true });
55 writeFileSync(join(dir, "src", "main.ts"), main);
56 return dir;
57 }
58
59 async function freePort(): Promise<number> {
60 const s = Bun.serve({ port: 0, fetch: () => new Response("") });
61 const port = s.port ?? 0;
62 s.stop(true);
63 return port;
64 }
65
66 const commitAt = async (port: number): Promise<string | undefined> => {
67 try {
68 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
69 headers: { connection: "close" },
70 signal: AbortSignal.timeout(3_000),
71 });
72 return ((await res.json()) as { commit?: string }).commit;
73 } catch {
74 return undefined;
75 }
76 };
77
78 function oldForge(port: number) {
79 return Bun.serve({
80 port,
81 reusePort: true,
82 fetch: () => Response.json({ ok: true, draining: false, commit: OLD }),
83 });
84 }
85
86 describe("what counts as healthy", () => {
87 test("well, not leaving, and the commit that was started", () => {
88 expect(healthyAs({ ok: true, draining: false, commit: NEW }, NEW)).toBe(true);
89 expect(healthyAs({ ok: true, draining: false, commit: OLD }, NEW)).toBe(false);
90 expect(healthyAs({ ok: false, commit: NEW }, NEW)).toBe(false);
91 expect(healthyAs({ ok: true, draining: true, commit: NEW }, NEW)).toBe(false);
92 expect(healthyAs(null, NEW)).toBe(false);
93 expect(healthyAs("ok", NEW)).toBe(false);
94 });
95
96 test("the swap switch is exactly FORGE_SELF_SWAP=1", () => {
97 expect(releaseEnv({ FORGE_SELF_SWAP: "1" }).selfSwap).toBe(true);
98 expect(releaseEnv({ FORGE_SELF_SWAP: "true" }).selfSwap).toBeUndefined();
99 expect(releaseEnv({}).selfSwap).toBeUndefined();
100 expect(releaseEnv({ FORGE_SWAP_BUDGET_S: "30" }).swapBudgetMs).toBe(30_000);
101 expect(releaseEnv({ FORGE_SWAP_BUDGET_S: "0" }).swapBudgetMs).toBeUndefined();
102 });
103 });
104
105 // The zero-failure promise holds only where the kernel migrates queued connections.
106 const realMigrate = (() => {
107 try {
108 return readFileSync("/proc/sys/net/ipv4/tcp_migrate_req", "utf8").trim() === "1";
109 } catch {
110 return false;
111 }
112 })();
113 const whenMigrating = realMigrate ? test : test.skip;
114 // Everything else here exercises the flow, not the kernel: pretend it is on.
115 handover.migrate = true;
116
117 describe("swapping on the listening port", () => {
118 test("the swap is refused unless the kernel migrates queued connections", async () => {
119 handover.migrate = false;
120 try {
121 const turn = beginSwap();
122 expect(turn.ok).toBe(false);
123 if (!turn.ok) {
124 expect(turn.line).toContain("tcp_migrate_req=1");
125 expect(turn.tell).toBe(true);
126 }
127 handover.migrate = true;
128 expect(beginSwap().ok).toBe(true);
129 endSwap(false);
130 } finally {
131 handover.migrate = true;
132 }
133 expect(migratesQueuedConnections()).toBe(true);
outdated

This failed on the first full-file run here (Received: 1). Linux resets the connections waiting in the old listener's accept queue when it closes, unless net.ipv4.tcp_migrate_req=1. Gate the swap on that sysctl and skip this test, named as skipped, when it is off.

134 });
135
136 whenMigrating("a client looping through the swap sees no failure, then only the new sha", async () => {
137 const port = await freePort();
138 const old = oldForge(port);
139 const dir = await tree(STAND_IN);
140 let stop = false;
141 let failed = 0;
142 let total = 0;
143 const seen = new Set<string>();
144 const client = (async () => {
145 while (!stop) {
146 total++;
147 const c = await commitAt(port);
148 if (c === undefined) failed++;
149 else seen.add(c);
150 await Bun.sleep(2);
151 }
152 })();
153 const out = await startRelease({ dir, sha: NEW, port, env: process.env, budgetMs: 20_000 });
154 expect(out.ok).toBe(true);
155 if (out.ok) pids.push(out.pid);
156 // The old drains: it stops taking connections, finishing what it holds.
157 old.stop();
158 await Bun.sleep(300);
159 const afterOld = total;
160 await Bun.sleep(300);
161 stop = true;
162 await client;
163 expect(failed).toBe(0);
164 expect(total).toBeGreaterThan(afterOld);
165 expect(seen.has(NEW)).toBe(true);
166 expect(await commitAt(port)).toBe(NEW);
167 }, 40_000);
168
169 test("a process that listens but is unwell is stopped, and the old sha keeps serving", async () => {
170 const port = await freePort();
171 const old = oldForge(port);
172 const dir = await tree(SICK);
173 const out = await startRelease({ dir, sha: BAD, port, env: process.env, budgetMs: 1_500 });
174 expect(out.ok).toBe(false);
175 if (!out.ok) expect(out.line).toContain("not healthy within");
176 for (let i = 0; i < 20; i++) expect(await commitAt(port)).toBe(OLD);
177 old.stop(true);
178 }, 20_000);
179
180 test("a process that dies on boot is reported as that, at once", async () => {
181 const port = await freePort();
182 const old = oldForge(port);
183 const dir = await tree("process.exit(3);\n");
184 const t = Date.now();
185 const out = await startRelease({ dir, sha: BAD, port, env: process.env, budgetMs: 20_000 });
186 expect(out).toEqual({ ok: false, line: "the new process exited with 3 before it was healthy" });
187 expect(Date.now() - t).toBeLessThan(5_000);
188 expect(await commitAt(port)).toBe(OLD);
189 old.stop(true);
190 }, 30_000);
191 });
192
193 describe("on landing", () => {
194 async function setup(selfSwap: boolean, port: number, main: string) {
195 const dataDir = await mkdtemp(join(tmpdir(), "forge-swap-land-"));
196 dirs.push(dataDir);
197 const app = createApp({ dataDir, port, forgeRepo: "alice/forge-code", selfSwap, swapBudgetMs: 5_000 });
198 const u = await createUser(app.db, {
199 trusted: true,
200 username: "alice",
201 email: "[email protected]",
202 password: "a-good-passphrase",
203 adminIfFirst: true,
204 });
205 if (!u.ok) throw new Error("user");
206 const r = await createRepo(app, u.value, { name: "forge-code", visibility: "public" });
207 if (!r.ok) throw new Error("repo");
208 await commitToBranch(app, u.value, r.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] });
209 await commitToBranch(app, u.value, r.value, { message: "b", branch: "change", files: [{ path: "b.txt", content: "b" }] });
210 const opened = await openPull(app, u.value, r.value, {
211 title: "t",
212 baseBranch: r.value.defaultBranch,
213 headBranch: "change",
214 });
215 if (!opened.ok) throw new Error("pull");
216 const made = await commitToBranch(app, u.value, r.value, {
217 message: "land",
218 files: [{ path: "src/main.ts", content: main }],
219 });
220 if (!made.ok) throw new Error("commit");
221 return { app, user: u.value, repo: r.value, pull: opened.value, sha: made.value.commitOid };
222 }
223
224 test("a release that never comes up leaves the old process and tells the pull", async () => {
225 const port = await freePort();
226 const old = oldForge(port);
227 let drained = 0;
228 handover.drain = () => {
229 drained++;
230 };
231 try {
232 // Dead after a green preflight: the swap fails, nothing drains.
233 const bad = await setup(true, port, DEAD);
234 const sha = bad.sha;
235 const run = releaseOnLand(bad.app, bad.user, bad.repo, bad.pull, sha);
236 const release = await run;
237 expect(release?.ok).toBe(true);
238 expect(drained).toBe(0);
239 const told = listComments(bad.app.db, bad.pull.id).map((c) => c.body);
240 expect(told.some((b) => b.includes("did not swap in") && b.includes(sha.slice(0, 12)))).toBe(true);
241 expect(await commitAt(port)).toBe(OLD);
242 bad.app.close();
243 } finally {
244 delete handover.drain;
245 old.stop(true);
246 }
247 }, 120_000);
248
249 test("a release that reads healthy drains the old process once", async () => {
250 const port = await freePort();
251 const old = oldForge(port);
252 let drained = 0;
253 handover.drain = () => {
254 drained++;
255 old.stop();
256 };
257 try {
258 // The stand-in leaves on its own, so the test strands nothing.
259 const s = await setup(true, port, `${STAND_IN}\nsetTimeout(() => process.exit(0), 6000);\n`);
260 await releaseOnLand(s.app, s.user, s.repo, s.pull, s.sha);
261 expect(drained).toBe(1);
262 expect(await commitAt(port)).toBe(s.sha);
263 s.app.close();
264 } finally {
265 endSwap(false);
266 delete handover.drain;
267 old.stop(true);
268 }
269 }, 60_000);
270
271 test("two landings close together leave exactly one new process serving", async () => {
272 const port = await freePort();
273 const old = oldForge(port);
274 let drained = 0;
275 handover.drain = () => {
276 drained++;
277 old.stop();
278 };
279 const long = `${STAND_IN}\nsetTimeout(() => process.exit(0), 8000);\n`;
280 try {
281 const a = await setup(true, port, long);
282 const b = await setup(true, port, long);
283 await Promise.all([
284 releaseOnLand(a.app, a.user, a.repo, a.pull, a.sha),
285 releaseOnLand(b.app, b.user, b.repo, b.pull, b.sha),
286 ]);
287 expect(drained).toBe(1);
288 const seen = new Set<string>();
289 for (let i = 0; i < 40; i++) {
290 const c = await commitAt(port);
291 if (c !== undefined) seen.add(c);
292 }
293 expect(seen.size).toBe(1);
294 expect([a.sha, b.sha]).toContain([...seen][0]!);
295 // This process is leaving: a later landing does not start another.
296 const c = await setup(true, port, long);
297 await releaseOnLand(c.app, c.user, c.repo, c.pull, c.sha);
298 expect(drained).toBe(1);
299 a.app.close();
300 b.app.close();
301 c.app.close();
302 } finally {
303 delete handover.drain;
304 endSwap(false);
305 old.stop(true);
306 }
307 }, 90_000);
308
309 test("a process holding the SSH terminal refuses the swap and tells the pull", async () => {
310 const port = await freePort();
311 const old = oldForge(port);
312 let drained = 0;
313 handover.drain = () => {
314 drained++;
315 };
316 handover.ssh = true;
317 try {
318 const s = await setup(true, port, STAND_IN);
319 await releaseOnLand(s.app, s.user, s.repo, s.pull, s.sha);
320 expect(drained).toBe(0);
321 expect(await commitAt(port)).toBe(OLD);
322 const told = listComments(s.app.db, s.pull.id).map((c) => c.body);
323 expect(told.some((b) => b.includes("did not swap in") && b.includes("SSH"))).toBe(true);
324 // Refusing took no turn: with the terminal off, the next swap goes.
325 handover.ssh = false;
326 expect(beginSwap().ok).toBe(true);
327 endSwap(false);
328 s.app.close();
329 } finally {
330 delete handover.drain;
331 delete handover.ssh;
332 old.stop(true);
333 }
334 }, 60_000);
335
336 test("with the switch off, a green landing swaps nothing", async () => {
337 const port = await freePort();
338 let drained = 0;
339 handover.drain = () => {
340 drained++;
341 };
342 try {
343 const s = await setup(false, port, STAND_IN);
344 await releaseOnLand(s.app, s.user, s.repo, s.pull, s.sha);
345 expect(drained).toBe(0);
346 expect(await commitAt(port)).toBeUndefined();
347 s.app.close();
348 } finally {
349 delete handover.drain;
350 }
351 }, 60_000);
352 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs