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

A roll never cuts an in-flight push: the old process stops listening at drain start, not at the end #2051

Merged opened this in answer to

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

Merged

mason/forge:mason/2040-a-roll-never-cuts-an-in-flight-push-the → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/2040-a-roll-never-cuts-an-in-flight-push-the was merged into main as c41efb3.

Review

Sign in to review this change.
6 files changed +351 −16
README.md +1 −1

Diff not shown — a line 3,327 characters wide (limit 2,000). +1 −1

View file
scripts/gate.ts +1
@@ -104,6 +104,7 @@ export const GATE: readonly string[] = [
104104 "test/release-preflight.test.ts",
105105 "test/release-rollback.test.ts",
106106 "test/release-swap.test.ts",
107 "test/release-handover.test.ts",
107108 "test/threats-doc.test.ts",
108109 "test/returns.test.ts",
109110 "test/review-route.test.ts",
src/http/server.ts +18
@@ -1639,6 +1639,13 @@ async function handleHook(app: App, request: Request): Promise<Response> {
16391639 export interface Servers {
16401640 readonly url: string;
16411641 readonly port: number;
1642 /**
1643 * Stop taking connections and let those in flight finish: what a process
1644 * handing its port to a release beside it does at drain start (#2040).
1645 * Resolves once no request is in flight, or `budgetMs` has passed; `stop`
1646 * then cuts whatever is left.
1647 */
1648 stopAccepting(budgetMs: number): Promise<void>;
16421649 stop(): void;
16431650 }
16441651
@@ -1665,6 +1672,9 @@ function withHeader(response: Response, name: string, value: string): Response {
16651672 }
16661673 }
16671674
1675 /** How often a draining listener looks whether its last request is answered. */
1676 const INFLIGHT_POLL_MS = 50;
1677
16681678 /** The hook sockets a data directory may hold: this forge's, and others'. */
16691679 const SOCKET_FILE = /^forge(?:-[a-z0-9]+)?\.sock$/;
16701680
@@ -1919,6 +1929,14 @@ export async function serve(app: App): Promise<Servers> {
19191929 return {
19201930 url: `http://localhost:${port}`,
19211931 port,
1932 async stopAccepting(budgetMs) {
1933 // Closing the listener leaves the port to the process beside this one;
1934 // the connections already here are answered to the end.
1935 web.stop(false).catch(() => {});
1936 const deadline = Date.now() + budgetMs;
1937 while (web.pendingRequests > 0 && Date.now() < deadline)
1938 await Bun.sleep(INFLIGHT_POLL_MS);
1939 },
19221940 stop() {
19231941 endOpen();
19241942 clearInterval(sweep);
src/main.ts +25 −12
@@ -6,7 +6,7 @@
66
77 import { startSampler } from "./boards/sampler.ts";
88 import { sweepStanding } from "./crew/standing.ts";
9 import { handover } from "./release/swap.ts";
9 import { drainOnHandover, handover } from "./release/swap.ts";
1010 import { parentUrlProblem } from "./core/parent_releases.ts";
1111 import { releaseEnv } from "./release/env.ts";
1212 import { createApp } from "./app.ts";
@@ -487,8 +487,14 @@ const leaseTimer = setInterval(() => {
487487 releaseOrphans(app.db);
488488 }, 30_000);
489489
490 // A release that read healthy beside this process takes over (#1663).
491 handover.drain = () => shutdown("SIGTERM");
490 // A release that read healthy beside this process takes over (#1663),
491 // and this one lets go of the port before it drains (#2040).
492 const handOver = drainOnHandover(servers, () => drain("SIGTERM"), close);
493 handover.drain = () => {
494 if (draining) return;
495 draining = true;
outdated

This wiring is the fix, and no test reaches it: revert it and release-handover.test.ts still passes. Move it into an exported function and test it through handover.drain().

496 void handOver();
497 };
492498 handover.ssh = ssh !== undefined;
493499 process.on("SIGINT", shutdown);
494500 process.on("SIGTERM", shutdown);
@@ -505,6 +511,11 @@ let draining = false;
505511 function shutdown(signal: NodeJS.Signals): void {
506512 if (draining) return;
507513 draining = true;
514 void drain(signal).finally(close);
515 }
516
517 /** Everything a leaving process finishes before it goes: `shutdown` above. */
518 function drain(signal: NodeJS.Signals): Promise<unknown> {
508519 const sessions = ssh?.sessions() ?? 0;
509520 startDraining(app.log, signal, { sshSessions: sessions });
510521 // Streams end now, with a resume hint: their browsers reconnect to the
@@ -518,7 +529,7 @@ function shutdown(signal: NodeJS.Signals): void {
518529 // The platform's SIGTERM: the listener stays open while routers learn
519530 // this process is going. A person's Ctrl-C does not wait for routers.
520531 const linger = signal === "SIGTERM" ? Bun.sleep(lingerMs ?? 0) : undefined;
521 Promise.all([
532 return Promise.all([
522533 linger,
523534 prompts.stop(),
524535 notifier.stop(),
@@ -538,12 +549,14 @@ function shutdown(signal: NodeJS.Signals): void {
538549 });
539550 return ssh?.drain();
540551 })
541 .catch(() => {})
542 .finally(() => {
543 app.log.info("forge shutting down");
544 ssh?.stop();
545 servers.stop();
546 app.close();
547 process.exit(0);
548 });
552 .catch(() => {});
553 }
554
555 /** The last step of a drain: cut what is left and exit. */
556 function close(): void {
557 app.log.info("forge shutting down");
558 ssh?.stop();
559 servers.stop();
560 app.close();
561 process.exit(0);
549562 }
src/release/swap.ts +32 −3
@@ -91,11 +91,40 @@ export async function startRelease(input: SwapInput): Promise<Swapped> {
9191 };
9292 }
9393
94 /**
95 * How long a process that handed its port over keeps answering what it had
96 * already taken — a long push or clone — before it goes (#2040).
97 */
98 export const HANDOVER_INFLIGHT_MS = 300_000;
99
100 /**

This was inserted between handover's doc comment (ending on line 99) and handover itself, so that comment no longer documents anything. Move the constant and its comment above the handover doc comment.

101 * The drain a handover runs (#2040). The release listens on this port too,
102 * so this process stops accepting at once: every new connection is the
103 * release's. What is in flight here is answered to the end (up to
104 * `budgetMs`) beside `rest` — the shutdown's other steps — and only once
105 * both are done does `close` cut what remains. Runs once.
106 */
107 export function drainOnHandover(
108 servers: { stopAccepting(budgetMs: number): Promise<void> },
109 rest: () => Promise<unknown>,
110 close: () => void,
111 budgetMs = HANDOVER_INFLIGHT_MS,
112 ): () => Promise<void> {
113 let done: Promise<void> | undefined;
114 return () =>
115 (done ??= (async () => {
116 const inFlight = servers.stopAccepting(budgetMs);
117 await Promise.all([inFlight, rest()]).catch(() => {});
118 close();
119 })());
120 }
121
94122 /**
95123 * 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).
124 * does, but letting go of the port first (`drainOnHandover`). Set at boot,
125 * with whether this process holds the SSH listener (a new process cannot
126 * bind that port while this one does, and a release must not quietly close
127 * the terminal).
99128 */
100129 export const handover: { drain?: () => void; ssh?: boolean; migrate?: boolean } = {};
101130
test/release-handover.test.ts added +274
@@ -0,0 +1,274 @@
1 /**
2 * A roll never cuts an in-flight push (#2040). Two forges on one data
3 * directory share the port (SO_REUSEPORT), as during a release swap: a push
4 * that started on the old one trickles in for thirty seconds while the new
5 * one comes up and the old one drains. The old stops accepting at drain
6 * start — new connections are the new forge's — and answers the push to the
7 * end before it goes.
8 */
9
10 import { afterAll, expect, test } from "bun:test";
11 import { readFileSync } from "node:fs";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createApp } from "../src/app.ts";
16 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser } from "../src/core/users.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { drainOnHandover, handover } from "../src/release/swap.ts";
21 import { serve } from "../src/http/server.ts";
22 import { gitClientEnv } from "./helpers/git-client.ts";
23
24 const PUSH_MS = 30_000;
25
26 const dirs: string[] = [];
27 afterAll(async () => {
28 for (const d of dirs) await rm(d, { recursive: true, force: true });
29 });
30
31 // Without it, what sat in the old listener's accept queue is reset when it
32 // closes — at drain start now, at the end before; either way the kernel's.
33 const migrates = (() => {
34 try {
35 return readFileSync("/proc/sys/net/ipv4/tcp_migrate_req", "utf8").trim() === "1";
36 } catch {
37 return false;
38 }
39 })();
40
41 async function git(args: string[], cwd: string, stdin?: string): Promise<Uint8Array> {
42 const out = Bun.spawnSync(["git", ...args], {
43 cwd,
44 env: gitClientEnv(),
45 ...(stdin === undefined ? {} : { stdin: new TextEncoder().encode(stdin) }),
46 });
47 if (out.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${out.stderr.toString()}`);
48 return new Uint8Array(out.stdout);
49 }
50
51 const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes).trim();
52
53 function pktLine(line: string): Uint8Array {
54 const body = new TextEncoder().encode(line);
55 const head = (body.length + 4).toString(16).padStart(4, "0");
56 return new Uint8Array([...new TextEncoder().encode(head), ...body]);
57 }
58
59 /** `bytes` sent in pieces spread over `ms`, so the request stays in flight that long. */
60 function trickle(bytes: Uint8Array, ms: number): ReadableStream<Uint8Array> {
61 const pieces = 30;
62 const size = Math.ceil(bytes.length / pieces);
63 let i = 0;
64 return new ReadableStream({
65 async pull(controller) {
66 if (i > 0) await Bun.sleep(ms / pieces);
67 const piece = bytes.subarray(i * size, (i + 1) * size);
68 i++;
69 if (piece.length > 0) controller.enqueue(piece);
70 if (i >= pieces) controller.close();
71 },
72 });
73 }
74
75 /**
76 * The old forge on a port of its own, alice's repository, and `push(ms)`: a
77 * receive-pack of one new commit on main, trickled in over `ms`.
78 */
79 async function forgeWithPush() {
80 const dataDir = await mkdtemp(join(tmpdir(), "forge-handover-"));
81 const work = await mkdtemp(join(tmpdir(), "forge-handover-work-"));
82 dirs.push(dataDir, work);
83 // The old forge picks the port; the release binds the one it got.
84 const oldApp = createApp({ dataDir, port: 0, selfSwap: true });
85 const oldServers = await serve(oldApp);
86 const port = oldServers.port;
87 const made = await createUser(oldApp.db, {
88 trusted: true,
89 username: "alice",
90 email: "[email protected]",
91 password: "a-good-passphrase",
92 adminIfFirst: true,
93 });
94 if (!made.ok) throw new Error("user");
95 const alice = made.value;
96 const repo = await createRepo(oldApp, alice, { name: "garden", visibility: "public" });
97 if (!repo.ok) throw new Error("repo");
98 await commitToBranch(oldApp, alice, repo.value, {
99 message: "seed",
100 files: [{ path: "a.txt", content: "a" }],
101 });
102 const { token } = createToken(oldApp.db, alice.id, "push", "rw");
103
104 // The push: one commit on main, as git sends it to receive-pack.
105 const bare = repoDir(oldApp.reposRoot, "alice", "garden");
106 const clone = join(work, "garden");
107 await git(["clone", "-q", bare, clone], work);
108 await Bun.write(join(clone, "b.txt"), "b");
109 await git(["add", "b.txt"], clone);
110 await git(["commit", "-q", "-m", "b"], clone);
111 const before = text(await git(["rev-parse", "origin/main"], clone));
112 const after = text(await git(["rev-parse", "HEAD"], clone));
113 const pack = await git(
114 ["pack-objects", "--stdout", "--revs", "--quiet"],
115 clone,
116 `${after}\n^${before}\n`,
117 );
118 const command = pktLine(`${before} ${after} refs/heads/main\0report-status\n`);
119 const flush = new TextEncoder().encode("0000");
120 const body = new Uint8Array(command.length + flush.length + pack.length);
121 body.set(command, 0);
122 body.set(flush, command.length);
123 body.set(pack, command.length + flush.length);
124
125 const url = `http://127.0.0.1:${port}`;
126 const push = (ms: number) =>
127 fetch(`${url}/alice/garden.git/git-receive-pack`, {
128 method: "POST",
129 headers: {
130 authorization: `Basic ${btoa(`alice:${token}`)}`,
131 "content-type": "application/x-git-receive-pack-request",
132 },
133 body: trickle(body, ms),
134 });
135 return { oldApp, oldServers, port, url, bare, after, push };
136 }
137
138 test("a push in flight on the old forge completes across the roll, and no connection is refused", async () => {
139 const { oldApp, oldServers, port, url, bare, after, push: pushOver } = await forgeWithPush();
140 const dataDir = oldApp.config.dataDir;
141 const started = Date.now();
142 const push = pushOver(PUSH_MS);
143 // The push is the old forge's before the release exists.
144 await Bun.sleep(500);
145
146 const newApp = createApp({ dataDir, port, selfSwap: true });
147 const newServers = await serve(newApp);
148
149 let looping = true;
150 let asked = 0;
151 let refused = 0;
152 const client = (async () => {
153 while (looping) {
154 asked++;
155 try {
156 const res = await fetch(`${url}/api/v1/health`, {
157 headers: { connection: "close" },
158 signal: AbortSignal.timeout(3_000),
159 });
160 await res.arrayBuffer();
161 } catch {
162 refused++;
163 }
164 await Bun.sleep(20);
165 }
166 })();
167
168 // Drain start: the old forge stops listening, keeping what it holds.
169 const drained = oldServers.stopAccepting(PUSH_MS * 2);
170
171 const answer = await push;
172 const report = await answer.text();
173 expect(answer.status).toBe(200);
174 expect(report).toContain("unpack ok");
175 expect(report).toContain("ok refs/heads/main");
176 expect(Date.now() - started).toBeGreaterThanOrEqual(PUSH_MS * 0.9);
177 await drained;
178
179 looping = false;
180 await client;
181 expect(asked).toBeGreaterThan(100);
182 if (migrates) expect(refused).toBe(0);
183 expect(text(await git(["rev-parse", "refs/heads/main"], bare))).toBe(after);
184
185 // Nobody else listens: the old forge let go of the port at drain start.
186 // `stop` closes the listener asynchronously, so ask until it is gone.
187 newServers.stop();
188 let orphan = "answered";
189 for (const until = Date.now() + 10_000; Date.now() < until; ) {
190 orphan = await fetch(`${url}/api/v1/health`, {
191 headers: { connection: "close" },
192 signal: AbortSignal.timeout(2_000),
193 }).then(
194 async (res) => {
195 await res.arrayBuffer();
196 return "answered";
197 },
198 () => "refused",
199 );
200 if (orphan === "refused") break;
201 await Bun.sleep(100);
202 }
203 expect(orphan).toBe("refused");
204
205 oldServers.stop();
206 newApp.close();
207 oldApp.close();
208 }, PUSH_MS * 3);
209
210 test("handover.drain lets go of the port before the rest of the drain, and closes only after the push is answered", async () => {
211 const { oldApp, oldServers, url, bare, after, push: pushOver } = await forgeWithPush();
212 const HELD_MS = 4_000;
213 const REST_MS = 2_000;
214 const push = pushOver(HELD_MS).then(async (res) => {
215 const report = await res.text();
216 answeredAt = Date.now();
217 return { status: res.status, report };
218 });
219 let answeredAt = 0;
220 let restDoneAt = 0;
221 let closedAt = 0;
222 // Wired as `main.ts` wires it; `rest` stands in for the shutdown's other
223 // steps (crew, apps, checks), `close` for its last.
224 const handOver = drainOnHandover(
225 oldServers,
226 async () => {
227 await Bun.sleep(REST_MS);
228 restDoneAt = Date.now();
229 },
230 () => {
231 closedAt = Date.now();
232 oldServers.stop();
233 },
234 );
235 handover.drain = () => void handOver();
236 try {
237 await Bun.sleep(500);
238 handover.drain();
239
240 // Nobody else listens here, so a refused connection is this listener gone.
241 let refusedAt = 0;
242 while (restDoneAt === 0) {
243 const gone = await fetch(`${url}/api/v1/health`, {
244 headers: { connection: "close" },
245 signal: AbortSignal.timeout(1_000),
246 }).then(
247 async (res) => {
248 await res.arrayBuffer();
249 return false;
250 },
251 () => true,
252 );
253 if (gone) {
254 refusedAt = Date.now();
255 break;
256 }
257 await Bun.sleep(50);
258 }
259 expect(refusedAt).toBeGreaterThan(0);
260 expect(restDoneAt).toBe(0);
261
262 const answered = await push;
263 expect(answered.status).toBe(200);
264 expect(answered.report).toContain("ok refs/heads/main");
265 while (closedAt === 0) await Bun.sleep(50);
266 expect(closedAt).toBeGreaterThanOrEqual(answeredAt);
267 expect(closedAt).toBeGreaterThanOrEqual(restDoneAt);
268 expect(text(await git(["rev-parse", "refs/heads/main"], bare))).toBe(after);
269 } finally {
270 delete handover.drain;
271 oldServers.stop();
272 oldApp.close();
273 }
274 }, 30_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs