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

Sentinel: release was degraded 12 minutes today: Release 0.7.547 is unproven: the public URL never answered as 0.7.547. It is proven again at 19:28 UTC. #11729

Merged opened this

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

Merged

mason/forge:mason/11682-sentinel-release-was-degraded-12-minutes → main 2 commits fork

  • Merged. mason/11682-sentinel-release-was-degraded-12-minutes was merged into main as 82d9c2a.

Review

Sign in to review this change.
2 files changed +81 −3
src/http/server.ts +15 −3
@@ -2246,7 +2246,9 @@ export interface Servers {
22462246 * Stop taking connections and let those in flight finish: what a process
22472247 * handing its port to a release beside it does at drain start (#2040).
22482248 * Resolves once no request is in flight, or `budgetMs` has passed; `stop`
2249 * then cuts whatever is left.
2249 * then cuts whatever is left. From then on every answer still given on a
2250 * kept-alive connection says `connection: close`, so a router holding one
2251 * redials and reaches the release instead of this process (#11682).
22502252 */
22512253 stopAccepting(budgetMs: number): Promise<void>;
22522254 stop(): void;
@@ -2768,6 +2770,8 @@ export async function serve(app: App): Promise<Servers> {
27682770 return withHeader(response, "connection", "close");
27692771 };
27702772
2773 // Set by `stopAccepting`: the port is the release's now.
2774 let handedOver = false;
27712775 const web = Bun.serve({
27722776 port: app.config.port ?? 3000,
27732777 // A release swaps in beside this process on the same port (#1663).
@@ -2781,7 +2785,12 @@ export async function serve(app: App): Promise<Servers> {
27812785 // proxy; its own live surfaces are SSE (`sse.ts`) and stay so.
27822786 websocket: withShells(appWebSocketWith(app.config.wsMaxMessage)),
27832787 // Open until it is answered, so a stall while it runs names it (#11478).
2784 fetch: (request, server) => inFlight.during(request, performance.now(), () => handle(request, server)),
2788 // Once the port is handed over, an answer on a connection opened before ends it (#11682).
2789 fetch: (request, server) =>
2790 inFlight.during(request, performance.now(), async () => {
2791 const response = await handle(request, server);
2792 return handedOver && response !== undefined ? withHeader(response, "connection", "close") : response;
2793 }),
27852794 // A last resort: `handle` answers a throw itself, so it is counted.
27862795 error: (error) => failure(app, error),
27872796 });
@@ -2796,7 +2805,10 @@ export async function serve(app: App): Promise<Servers> {
27962805 },
27972806 async stopAccepting(budgetMs) {
27982807 // Closing the listener leaves the port to the process beside this one;
2799 // the connections already here are answered to the end.
2808 // the connections already here are answered to the end, each closed
2809 // after its next answer: a router's pooled connection would otherwise
2810 // keep hearing this version for the whole drain (#11682).
2811 handedOver = true;
28002812 web.stop(false).catch(() => {});
28012813 const deadline = Date.now() + budgetMs;
28022814 while (web.pendingRequests > 0 && Date.now() < deadline)
test/release-handover-keepalive.test.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * A handed-over port takes its kept-alive connections with it (#11682).
3 * `stopAccepting` closes the old forge's listener, but a connection opened
4 * before (a router's pool) was still answered by the old process for the
5 * whole drain, so the public URL kept answering as the old version and the
6 * release proof gave up. Now the old forge's next answer on such a
7 * connection says `connection: close`, and the client's redial reaches the
8 * release.
9 */
10
11 import { afterAll, expect, test } from "bun:test";
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 { createUser } from "../src/core/users.ts";
17 import { serve } from "../src/http/server.ts";
18
19 const dirs: string[] = [];
20 afterAll(async () => {
21 for (const d of dirs) await rm(d, { recursive: true, force: true });
22 });
23
24 test("after stopAccepting, a kept-alive connection is closed after one answer and the next request reaches the release", async () => {
25 const oldDir = await mkdtemp(join(tmpdir(), "forge-keepalive-old-"));
26 const newDir = await mkdtemp(join(tmpdir(), "forge-keepalive-new-"));
27 dirs.push(oldDir, newDir);
28 // Only the old forge knows alice, so an answer says which forge gave it.
29 const oldApp = createApp({ dataDir: oldDir, port: 0, selfSwap: true });
30 const oldServers = await serve(oldApp);
31 const made = await createUser(oldApp.db, {
32 origin: "seed",
33 trusted: true,
34 username: "alice",
35 email: "[email protected]",
36 password: "a-good-passphrase",
37 adminIfFirst: true,
38 });
39 expect(made.ok).toBe(true);
40 const url = `http://127.0.0.1:${oldServers.port}/api/v1/users/alice`;
41 const ask = async () => {
42 const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
43 await res.arrayBuffer();
44 return { status: res.status, connection: res.headers.get("connection") };
45 };
46
47 // Warm a kept-alive connection while the old forge is the only listener.
48 for (let i = 0; i < 3; i++) expect((await ask()).status).toBe(200);
49
50 const newApp = createApp({ dataDir: newDir, port: oldServers.port, selfSwap: true });
51 const newServers = await serve(newApp);
52 const drained = oldServers.stopAccepting(5_000);
53
54 // The old forge answers what was already its connection once more, and ends it.
55 const last = await ask();
56 expect(last.status).toBe(200);
57 expect(last.connection).toBe("close");
58 // Every request after that is the release's.
59 for (let i = 0; i < 5; i++) expect((await ask()).status).toBe(404);
60
61 await drained;
62 oldServers.stop();
63 newServers.stop();
64 oldApp.close();
65 newApp.close();
66 }, 20_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs