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

Real-cell proof: a 2 GiB, 1-core child builds a first app to production and never answers 429 'the forge is full' #11998

Merged opened this

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

Merged

scout/forge:scout/11981-real-cell-proof-a-2-gib-1-core-child-bui → main 3 commits fork

  • Merged. scout/11981-real-cell-proof-a-2-gib-1-core-child-bui was merged into main as c55230c.

Review

Sign in to review this change.
2 files changed +84 −16
test/helpers/real-spawn-e2e.ts +40 −16
@@ -7,6 +7,12 @@
77 * app, then asks `<app>--<child>--<parent>` through that server until the
88 * app's own body comes back. Nothing is faked: not the cell, not the child's
99 * runtime, not the route. Printed: one JSON line of what was seen.
10 *
11 * With the argument `recover` (test/spawn-small-cell.test.ts, #11981) it also
12 * reads the child's cell limits, then stops the child as the host does for a
13 * memory breach (a recorded stop and its process tree killed) and polls every
14 * 100 ms until the app answers again, noting every status seen from the
15 * first request on, so a 429 at any moment is reported.
1016 */
1117 import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
1218 import { tmpdir } from "node:os";
@@ -14,13 +20,14 @@ import { join } from "node:path";
1420 import { createRepo } from "../../src/actions/repos.ts";
1521 import { spawnForge } from "../../src/actions/spawn.ts";
1622 import { createApp } from "../../src/app.ts";
17 import { cellsAvailable } from "../../src/apps/contain.ts";
23 import { cellsAvailable, killTree } from "../../src/apps/contain.ts";
1824 import { createUser, type User } from "../../src/core/users.ts";
1925 import { repoDir } from "../../src/git/repos.ts";
2026 import { commitFiles } from "../../src/git/write.ts";
2127 import { serve } from "../../src/http/server.ts";
22 import { cellPlacement, stopCellChildren } from "../../src/spawn/cell.ts";
28 import { cellChildren, cellPlacement, stopCellChildren } from "../../src/spawn/cell.ts";
2329 import { bootHostedChild } from "../../src/spawn/host.ts";
30 import { noteStop } from "../../src/spawn/stopped.ts";
2431 import { memoryBodies } from "../../src/spawn/placement.ts";
2532 import "./roomy-disk.ts";
2633
@@ -29,7 +36,9 @@ import "./roomy-disk.ts";
2936 // and a run that ran out says which phase did. The test's timeout is their sum
3037 // plus slack (see test/spawn-cell-e2e.test.ts).
3138 const SPAWN_MS = 120_000;
32 const POLL_MS = 150_000;
39 const POLL_MS = 60_000;
40 const RECOVER_MS = 60_000;
41 const recover = process.argv[2] === "recover";
3342 // A killed run (the test's timeout sends SIGTERM) still stops its cell children.
3443 let cleanup: () => Promise<void> = async () => {};
3544 // The spawn and boot work, kept so a teardown after its budget ran out waits
@@ -105,21 +114,36 @@ try {
105114 const spawning = spawnAndBoot(alice.value);
106115 pending = spawning;
107116 const made = await within(SPAWN_MS, "spawn and boot", spawning);
108 let status = 0;
109 let body = "";
110 let app: string | null = null;
111 const until = Date.now() + POLL_MS;
112 while (Date.now() < until) {
113 const res = await fetch(`${servers.url}/`, { headers: { host: "bakery--kid--forge.test" }, redirect: "manual" }).catch(() => undefined);
114 if (res !== undefined) {
115 status = res.status;
116 body = await res.text();
117 app = res.headers.get("x-forge-app");
118 if (status === 200 && app === "bakery") break;
117 const seen = new Set<number>();
118 // Asks the app's address until it answers 200 from the app, every `everyMs`; every status is
119 // noted.
120 async function pollApp(budgetMs: number, everyMs: number) {
121 let last = { status: 0, body: "", app: null as string | null };
122 const until = Date.now() + budgetMs;
123 while (Date.now() < until) {
124 const res = await fetch(`${servers.url}/`, { headers: { host: "bakery--kid--forge.test" }, redirect: "manual" }).catch(() => undefined);
125 if (res !== undefined) {
126 last = { status: res.status, body: await res.text(), app: res.headers.get("x-forge-app") };
127 seen.add(last.status);
128 if (last.status === 200 && last.app === "bakery") break;
129 }
130 await Bun.sleep(everyMs);
119131 }
120 await Bun.sleep(1000);
132 return last;
121133 }
122 console.log(JSON.stringify({ placement: made.value.placement, status, body: body.slice(0, 200), app }));
134 const first = await pollApp(POLL_MS, recover ? 100 : 1000);
135 let limits = null;
136 let after = null;
137 if (recover) {
138 const held = cellChildren(parent).list()[0];
139 limits = held?.limits ?? null;
140 if (held !== undefined) {
141 noteStop(parent, held.bodyId, "kid", "used 2374 MiB of memory, over its 2048 MiB limit", Date.now(), 1_000);
142 killTree(held.pid);
143 after = (await pollApp(RECOVER_MS, 100)).status;
144 }
145 }
146 console.log(JSON.stringify({ placement: made.value.placement, status: first.status, body: first.body.slice(0, 200), app: first.app, ...(recover ? { limits, after, statuses: [...seen] } : {}) }));
123147 } finally {
124148 await cleanup();
125149 parent.close();
test/spawn-small-cell.test.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * Recovery of a Small child in a real cell (#11981, part of #11975). It proves
3 * three things and no more: the child's cell holds the Small limits (2048 MiB,
4 * 1 core), its app is served through the parent, and a child stopped as the
5 * host stops one over its memory limit comes back by itself with no request,
6 * from boot to recovery, answered 429 "the forge is full". The stop is
7 * simulated (a recorded stop and the process tree killed), not a real memory
8 * breach, and the app is a committed one-file app: building a first app,
9 * passing its tests and landing a second change are not covered here.
10 *
11 * Runs where bwrap and socat are, else skips. Expected runtime: about a
12 * minute (spawn and boot, a restart after a 1 s backoff); the 3 minute
13 * ceiling is the worst case.
14 */
15 import { test } from "bun:test";
16 import { join } from "node:path";
17 import { portInsteadOfSocket } from "../src/git/hook-address.ts";
18 import { missing } from "./helpers/tools.ts";
19 import { failedWith } from "./helpers/machine-note.ts";
20 import { resultLine } from "./helpers/result-line.ts";
21
22 const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat");
23
24 test.skipIf(!cellTools)("a Small child in a real cell holds its limits and, once stopped, comes back with no 429", async () => {
25 const env: Record<string, string> = {};
26 for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v;
27 const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-spawn-e2e.ts"), "recover"], { env, stdout: "pipe", stderr: "pipe", timeout: 230_000 });
28 const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
29 let seen: Record<string, unknown>;
30 try {
31 seen = JSON.parse(resultLine(out)) as Record<string, unknown>;
32 } catch {
33 throw new Error(failedWith("the recovery run printed no result", out.slice(-800), errText));
34 }
35 if (typeof seen["skipped"] === "string") {
36 console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]})`);
37 return;
38 }
39 const limits = seen["limits"] as { memoryMiB?: number; cpus?: number } | null;
40 if (seen["placement"] !== "cell" || seen["status"] !== 200 || seen["body"] !== "bakery says hi") throw new Error(failedWith("the Small child's app did not answer through the cell", { seen }, errText));
41 if (limits?.memoryMiB !== 2048 || limits.cpus !== 1) throw new Error(`the child did not hold the Small limits: ${JSON.stringify(limits)}`);
42 if ((seen["statuses"] as number[]).includes(429)) throw new Error(failedWith("a child was answered 429, the sentence for a full host", { seen }, errText));
43 if (seen["after"] !== 200) throw new Error(failedWith("the stopped child did not come back", { seen }, errText));
44 }, 240_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs