Public: anyone can read this; the crew can change it.
The three real-cell spawn test files run under the test budget #12453
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12396-the-three-real-cell-spawn-test-files-run → main 4 commits fork
11 files
changed +176
−108
scripts/gate.ts −3
| @@ -160,10 +160,7 @@ export const SLOW: readonly Exclusion[] = [ | ||
| 160 | 160 | { file: "test/signup-fresh.test.ts", reason: "boots main.ts on an empty data directory as a real process; 3.1 s a test on the gate box", issue: 1242 }, |
| 161 | 161 | { file: "test/signup-gate-parent.test.ts", reason: "walks every caller of createUser in the source tree; 2.3 s a test on the gate box", issue: 1242 }, |
| 162 | 162 | { file: "test/snapshot-uplink.test.ts", reason: "uploads a 400 MB snapshot in parts through a throttled uplink; 4.5 s a test on the gate box", issue: 1242 }, |
| 163 | { file: "test/spawn-cell-e2e.test.ts", reason: "spawns a child forge into a real cell and serves its app through the forward; 25.4 s a test on the gate box", issue: 1242 }, | |
| 164 | { file: "test/spawn-cell.test.ts", reason: "boots a real child forge in its own cell and waits for its first request; 21.7 s a test on the gate box", issue: 1242 }, | |
| 165 | 163 | { file: "test/spawn-move-cell-e2e.test.ts", reason: "moves a running child forge into a real cell with its data, repository and app; 13.1 s a test on the gate box", issue: 1242 }, |
| 166 | { file: "test/spawn-small-cell.test.ts", reason: "boots a child forge in a real cell and restarts it to check its limits; 33.3 s a test on the gate box", issue: 1242 }, | |
| 167 | 164 | { file: "test/ssh-port-taken.test.ts", reason: "boots main.ts as a real process with its SSH port already held; 14.2 s a test on the gate box", issue: 1242 }, |
| 168 | 165 | { file: "test/standby-restore.test.ts", reason: "boots a restored snapshot read-only as a real process; 3.1 s a test on the gate box", issue: 1242 }, |
| 169 | 166 | { file: "test/subscriptions.test.ts", reason: "runs stand-in agents on granted accounts, waiting for a refusal and a handover; 10.4 s a test on the gate box", issue: 1242 }, |
src/actions/fork_limit.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | /** | |
| 2 | * How many forks one person may make in any 24 hours (#853, #888): each may | |
| 3 | * become an app that deploys at once. Counted from the ledger of forks made, | |
| 4 | * so deleting one makes no room. The crew's agents fork per issue to work — | |
| 5 | * bounded instead by the crew's own concurrency and spend ceilings, since an | |
| 6 | * agent forks only for a run — and the forge's own admin answers for its | |
| 7 | * forge: neither is held to it. | |
| 8 | * | |
| 9 | * A leaf of its own, importing nothing: `make_it_yours.ts` reads it while | |
| 10 | * its module is evaluated, and `repos.ts` reaches `make_it_yours.ts` back | |
| 11 | * through its imports, so the constant cannot live in `repos.ts` without a | |
| 12 | * "before initialization" error for whoever loads that cycle first. | |
| 13 | */ | |
| 14 | export const FORKS_PER_DAY = 5; |
src/actions/make_it_yours.ts +2 −1
| @@ -46,7 +46,8 @@ import { requireAbility } from "./roles.ts"; | ||
| 46 | 46 | import { requireAppRoom, appLimitWords } from "./app_limit.ts"; |
| 47 | 47 | import { disableApp, enableApp } from "./apps.ts"; |
| 48 | 48 | import { forkDrift } from "./forks.ts"; |
| 49 | import { deleteRepo, FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts"; | |
| 49 | import { deleteRepo, forkRepo, repositoryLimitMessage } from "./repos.ts"; | |
| 50 | import { FORKS_PER_DAY } from "./fork_limit.ts"; | |
| 50 | 51 | |
| 51 | 52 | export interface MakeItYoursPlan { |
| 52 | 53 | readonly dryRun: boolean; |
src/actions/repos.ts +2 −9
| @@ -79,6 +79,7 @@ import { validPolishShare } from "../crew/polish_share.ts"; | ||
| 79 | 79 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 80 | 80 | import type { SettingsChanged } from "../core/settings_changed.ts"; |
| 81 | 81 | import { seatStarterCrew } from "../spawn/starter_crew.ts"; |
| 82 | import { FORKS_PER_DAY } from "./fork_limit.ts"; | |
| 82 | 83 | |
| 83 | 84 | export interface CreateRepoInput { |
| 84 | 85 | /** Owner name: the actor themselves (default) or an org they can write to. */ |
| @@ -197,15 +198,7 @@ const doorPayload = (actorId: string): Record<string, string> => { | ||
| 197 | 198 | return door === undefined ? {} : { door }; |
| 198 | 199 | }; |
| 199 | 200 | |
| 200 | /** | |
| 201 | * How many forks one person may make in any 24 hours (#853, #888): each may | |
| 202 | * become an app that deploys at once. Counted from the ledger of forks made, | |
| 203 | * so deleting one makes no room. The crew's agents fork per issue to work — | |
| 204 | * bounded instead by the crew's own concurrency and spend ceilings, since an | |
| 205 | * agent forks only for a run — and the forge's own admin answers for its | |
| 206 | * forge: neither is held to it. | |
| 207 | */ | |
| 208 | export const FORKS_PER_DAY = 5; | |
| 201 | export { FORKS_PER_DAY }; | |
| 209 | 202 | |
| 210 | 203 | export async function forkRepo( |
| 211 | 204 | app: App, |
test/fork-limit-cycle.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | /** | |
| 2 | * `repos.ts` reaches `make_it_yours.ts` back through its imports, and | |
| 3 | * `make_it_yours.ts` reads the fork limit while it is evaluated. A process | |
| 4 | * that loads `repos.ts` first (the real-cell helpers do) died with "Cannot | |
| 5 | * access 'FORKS_PER_DAY' before initialization"; the limit lives in a leaf | |
| 6 | * module now. Tried in a fresh process, since this one may have loaded both | |
| 7 | * already; the process is the file's time, in its hook. | |
| 8 | */ | |
| 9 | import { beforeAll, expect, test } from "bun:test"; | |
| 10 | import { join } from "node:path"; | |
| 11 | ||
| 12 | const root = join(import.meta.dir, ".."); | |
| 13 | let seen: { code: number; out: string; err: string } = { code: -1, out: "", err: "not run" }; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | const script = `const a = await import(${JSON.stringify(join(root, "src/actions/repos.ts"))}); | |
| 17 | const b = await import(${JSON.stringify(join(root, "src/actions/make_it_yours.ts"))}); | |
| 18 | console.log(JSON.stringify([a.FORKS_PER_DAY, b.COPIES_PER_DAY]));`; | |
| 19 | const proc = Bun.spawn([process.execPath, "-e", script], { cwd: root, stdout: "pipe", stderr: "pipe" }); | |
| 20 | const [out, err, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); | |
| 21 | seen = { code, out, err: err.slice(-800) }; | |
| 22 | }, 20_000); | |
| 23 | ||
| 24 | test("a process that loads repos.ts first evaluates the fork limit on both sides", () => { | |
| 25 | expect({ code: seen.code, err: seen.code === 0 ? "" : seen.err }).toEqual({ code: 0, err: "" }); | |
| 26 | expect(JSON.parse(seen.out.trim().split("\n").at(-1) ?? "null")).toEqual([5, 5]); | |
| 27 | }); |
test/helpers/machine-note.ts +28
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | import { statfsSync } from "node:fs"; |
| 8 | 8 | import { availableParallelism, loadavg, tmpdir } from "node:os"; |
| 9 | import { resultLine } from "./result-line.ts"; | |
| 9 | 10 | |
| 10 | 11 | export function machineNote(dir: string = tmpdir()): string { |
| 11 | 12 | let disk = "disk unknown"; |
| @@ -23,3 +24,30 @@ export function machineNote(dir: string = tmpdir()): string { | ||
| 23 | 24 | export function failedWith(what: string, seen: unknown, errText: string): string { |
| 24 | 25 | return `${what}: ${JSON.stringify(seen)}\n${machineNote()}\nstderr tail: ${errText.slice(-1500)}`; |
| 25 | 26 | } |
| 27 | ||
| 28 | /** | |
| 29 | * What a real-cell helper process gave: its JSON result, the host's reason | |
| 30 | * to skip, or the whole evidence of a failure. | |
| 31 | */ | |
| 32 | export type HelperRun = { seen: Record<string, unknown>; out: string; errText: string } | { skipped: string } | { error: string }; | |
| 33 | ||
| 34 | /** | |
| 35 | * Run a real-cell helper once, without FORGE_CELLS (a check turns cells off | |
| 36 | * for the forges its tests boot), and read its one result line. A file calls | |
| 37 | * this from its `beforeAll`, so the boot is the file's time and each test | |
| 38 | * reads what the one child showed (#12396). | |
| 39 | */ | |
| 40 | export async function runHelper(argv: string[], what: string, timeoutMs: number): Promise<HelperRun> { | |
| 41 | const env: Record<string, string> = {}; | |
| 42 | for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; | |
| 43 | const proc = Bun.spawn([process.execPath, ...argv], { env, stdout: "pipe", stderr: "pipe", timeout: timeoutMs }); | |
| 44 | const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); | |
| 45 | let seen: Record<string, unknown>; | |
| 46 | try { | |
| 47 | seen = JSON.parse(resultLine(out)) as Record<string, unknown>; | |
| 48 | } catch { | |
| 49 | return { error: failedWith(`${what} printed no result`, out.slice(-800), errText) }; | |
| 50 | } | |
| 51 | if (typeof seen["skipped"] === "string") return { skipped: seen["skipped"] }; | |
| 52 | return { seen, out, errText }; | |
| 53 | } |
test/helpers/real-child-forge.ts +23 −5
| @@ -6,7 +6,11 @@ | ||
| 6 | 6 | * |
| 7 | 7 | * The parent holds a finished `cell` ledger for `kid`. The first request |
| 8 | 8 | * is answered before the cell is up; then the cell is awaited and `/` and |
| 9 | * `/healthz` are carried to it. Printed: one JSON line of what was seen. | |
| 9 | * `/healthz` are carried to it. The child's parent record names an | |
| 10 | * upstream nobody can reach, as a preview's cell has, so its first | |
| 11 | * heartbeat arriving proves the cell relays the parent's beat door (the | |
| 12 | * one child serves both proofs, #12396). Printed: one JSON line of what | |
| 13 | * was seen. | |
| 10 | 14 | */ |
| 11 | 15 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 12 | 16 | import { tmpdir } from "node:os"; |
| @@ -15,6 +19,7 @@ import { createApp } from "../../src/app.ts"; | ||
| 15 | 19 | import { cellCounters, cellsAvailable } from "../../src/apps/contain.ts"; |
| 16 | 20 | import { cellChildren, stopCellChildren } from "../../src/spawn/cell.ts"; |
| 17 | 21 | import { answerFromCell } from "../../src/spawn/host.ts"; |
| 22 | import { lastHeard } from "../../src/spawn/heartbeat.ts"; | |
| 18 | 23 | import "./roomy-disk.ts"; |
| 19 | 24 | |
| 20 | 25 | const cells = await cellsAvailable(); |
| @@ -27,7 +32,10 @@ const dataDir = mkdtempSync(join(tmpdir(), "real-child-")); | ||
| 27 | 32 | const parent = createApp({ dataDir, url: "https://f.test" }); |
| 28 | 33 | const ledger = { name: "kid", bodyId: "cell-real", size: "small" } as const; |
| 29 | 34 | mkdirSync(join(dataDir, "spawn"), { recursive: true }); |
| 30 | writeFileSync(join(dataDir, "spawn", "kid.json"), JSON.stringify({ ...ledger, done: true, placement: "cell" })); | |
| 35 | writeFileSync(join(dataDir, "spawn", "kid.json"), JSON.stringify({ ...ledger, done: true, placement: "cell", beatKey: "beat-key" })); | |
| 36 | const childDir = join(dataDir, "children", ledger.bodyId); | |
| 37 | mkdirSync(childDir, { recursive: true }); | |
| 38 | writeFileSync(join(childDir, "parent.json"), JSON.stringify({ name: "kid", upstream: "https://unreachable.invalid", beatKey: "beat-key" })); | |
| 31 | 39 | const ask = (path: string) => answerFromCell(parent, new Request(`https://kid--f.test${path}`, { headers: { host: "kid--f.test" } })); |
| 32 | 40 | |
| 33 | 41 | try { |
| @@ -38,8 +46,17 @@ try { | ||
| 38 | 46 | const child = await children.start(ledger); |
| 39 | 47 | const root = child === undefined ? null : (await ask("/")).status; |
| 40 | 48 | const health = child === undefined ? null : (await ask("/healthz")).status; |
| 41 | // The watch reads the cell's tree once a second, by its label. | |
| 42 | await Bun.sleep(1_500); | |
| 49 | // The watch reads the cell's tree once a second, by its label; the child | |
| 50 | // beats once as it boots. Both are asked until seen, not waited out. | |
| 51 | const until = Date.now() + 10_000; | |
| 52 | let labelled = false; | |
| 53 | let heard = child === undefined ? undefined : await lastHeard(parent, "kid"); | |
| 54 | while (child !== undefined && Date.now() < until) { | |
| 55 | labelled ||= cellCounters().some((c) => c.label === "forge:kid"); | |
| 56 | heard ??= await lastHeard(parent, "kid"); | |
| 57 | if (labelled && heard !== undefined) break; | |
| 58 | await Bun.sleep(100); | |
| 59 | } | |
| 43 | 60 | console.log( |
| 44 | 61 | JSON.stringify({ |
| 45 | 62 | first: first.status, |
| @@ -48,7 +65,8 @@ try { | ||
| 48 | 65 | limits: child?.limits ?? null, |
| 49 | 66 | root, |
| 50 | 67 | health, |
| 51 | labelled: cellCounters().some((c) => c.label === "forge:kid"), | |
| 68 | labelled, | |
| 69 | beat: heard?.beat.name ?? null, | |
| 52 | 70 | }), |
| 53 | 71 | ); |
| 54 | 72 | } finally { |
test/helpers/real-spawn-e2e.ts +2 −2
| @@ -131,14 +131,14 @@ try { | ||
| 131 | 131 | } |
| 132 | 132 | return last; |
| 133 | 133 | } |
| 134 | const first = await pollApp(POLL_MS, recover ? 100 : 1000); | |
| 134 | const first = await pollApp(POLL_MS, 100); | |
| 135 | 135 | let limits = null; |
| 136 | 136 | let after = null; |
| 137 | 137 | if (recover) { |
| 138 | 138 | const held = cellChildren(parent).list()[0]; |
| 139 | 139 | limits = held?.limits ?? null; |
| 140 | 140 | if (held !== undefined) { |
| 141 | noteStop(parent, held.bodyId, "kid", "used 2374 MiB of memory, over its 2048 MiB limit", Date.now(), 1_000); | |
| 141 | noteStop(parent, held.bodyId, "kid", "used 2374 MiB of memory, over its 2048 MiB limit", Date.now(), 100); | |
| 142 | 142 | killTree(held.pid); |
| 143 | 143 | after = (await pollApp(RECOVER_MS, 100)).status; |
| 144 | 144 | } |
test/spawn-cell-e2e.test.ts +15 −19
| @@ -3,35 +3,31 @@ | ||
| 3 | 3 | * app, then `<app>--<child>--<parent>` through the real server answers with |
| 4 | 4 | * the app's own body. `spawn-cell-app-hosts.test.ts` fakes the cell; this |
| 5 | 5 | * does not. It runs where bwrap and socat are (as `spawn-cell.test.ts`'s |
| 6 | * real child does) and says it skipped where they are not. | |
| 6 | * real child does) and says it skipped where they are not. The one child is | |
| 7 | * spawned in the file's hook (#12396); the test reads what it showed. | |
| 7 | 8 | */ |
| 8 | import { test } from "bun:test"; | |
| 9 | import { beforeAll, test } from "bun:test"; | |
| 9 | 10 | import { join } from "node:path"; |
| 10 | 11 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 11 | 12 | import { missing } from "./helpers/tools.ts"; |
| 12 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 13 | import { resultLine } from "./helpers/result-line.ts"; | |
| 13 | import { failedWith, runHelper, type HelperRun } from "./helpers/machine-note.ts"; | |
| 14 | 14 | |
| 15 | 15 | const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat"); |
| 16 | let run: HelperRun = { skipped: "no bwrap and socat here" }; | |
| 16 | 17 | |
| 17 | test.skipIf(!cellTools)("a child forge spawned into a cell serves its app at <app>--<child>--<parent>", async () => { | |
| 18 | const env: Record<string, string> = {}; | |
| 19 | for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; | |
| 20 | const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-spawn-e2e.ts")], { env, stdout: "pipe", stderr: "pipe", timeout: 290_000 }); | |
| 21 | const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); | |
| 22 | const line = resultLine(out); | |
| 23 | let seen: Record<string, unknown>; | |
| 24 | try { | |
| 25 | seen = JSON.parse(line) as Record<string, unknown>; | |
| 26 | } catch { | |
| 27 | throw new Error(failedWith("the spawn run printed no result", out.slice(-800), errText)); | |
| 28 | } | |
| 29 | if (typeof seen["skipped"] === "string") { | |
| 30 | console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child was spawned into one`); | |
| 18 | beforeAll(async () => { | |
| 19 | if (cellTools) run = await runHelper([join(import.meta.dir, "helpers", "real-spawn-e2e.ts")], "the spawn run", 290_000); | |
| 20 | }, 300_000); | |
| 21 | ||
| 22 | test.skipIf(!cellTools)("a child forge spawned into a cell serves its app at <app>--<child>--<parent>", () => { | |
| 23 | if ("error" in run) throw new Error(run.error); | |
| 24 | if ("skipped" in run) { | |
| 25 | console.log(`SKIPPING: this host refuses a cell inside the test (${run.skipped}), so no child was spawned into one`); | |
| 31 | 26 | return; |
| 32 | 27 | } |
| 28 | const { seen, out, errText } = run; | |
| 33 | 29 | // The child's own reason (its parent's "did not answer" line) and the |
| 34 | 30 | // machine's disk and load are in the message, not a bare mismatch. |
| 35 | 31 | const ok = seen["placement"] === "cell" && seen["status"] === 200 && seen["app"] === "bakery" && seen["body"] === "bakery says hi"; |
| 36 | 32 | if (!ok) throw new Error(failedWith("the child forge's app did not answer through the cell", { seen, out: out.slice(-1500) }, errText)); |
| 37 | }, 310_000); | |
| 33 | }); |
test/spawn-cell.test.ts +32 −46
| @@ -1,4 +1,4 @@ | ||
| 1 | import { afterAll, describe, expect, test } from "bun:test"; | |
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | 2 | import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| @@ -10,7 +10,7 @@ import { createRun, queueDepth } from "../src/core/runs.ts"; | ||
| 10 | 10 | import { createApp } from "../src/app.ts"; |
| 11 | 11 | import { childEnv } from "../src/apps/process.ts"; |
| 12 | 12 | import { watchedLimits } from "../src/apps/contain.ts"; |
| 13 | import { childLog, plainLine, cellChildren, crewAtWork, nextRunDue, type CellChild, stopCellChildren, stopGently, cellEnv, childEnvFor, cellLimits, cellPlacement, cellSpecFor, forward, launchSpecFor, ownsBody } from "../src/spawn/cell.ts"; | |
| 13 | import { childLog, plainLine, cellChildren, crewAtWork, nextRunDue, type CellChild, stopGently, cellEnv, childEnvFor, cellLimits, cellPlacement, cellSpecFor, forward, launchSpecFor, ownsBody } from "../src/spawn/cell.ts"; | |
| 14 | 14 | import { fallbackPlacement, hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 15 | 15 | import type { SpawnPlan } from "../src/spawn/plan.ts"; |
| 16 | 16 | import { SIZES } from "../src/spawn/sizes.ts"; |
| @@ -18,13 +18,12 @@ import { notePeer } from "../src/http/peer.ts"; | ||
| 18 | 18 | import type { App } from "../src/app.ts"; |
| 19 | 19 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 20 | 20 | import { missing } from "./helpers/tools.ts"; |
| 21 | import { resultLine } from "./helpers/result-line.ts"; | |
| 22 | 21 | import { hostAllowed } from "../src/apps/egress.ts"; |
| 23 | 22 | import { CAPACITY, childModelHosts, childPaymentHosts, childRegistryHosts, |
| 24 | 23 | childOriginHosts, setCapacity } from "../src/core/capacity.ts"; |
| 25 | 24 | import { openBeatDoor, parentLocal } from "../src/spawn/beat-door.ts"; |
| 26 | import { lastHeard, sendBeat } from "../src/spawn/heartbeat.ts"; | |
| 27 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 25 | import { sendBeat } from "../src/spawn/heartbeat.ts"; | |
| 26 | import { failedWith, runHelper, type HelperRun } from "./helpers/machine-note.ts"; | |
| 28 | 27 | |
| 29 | 28 | const plan = (name: string): SpawnPlan => ({ name, org: "o", apps: [], briefs: [], subscriptions: [], direction: "", keepAccess: false, budgetUsd: null }); |
| 30 | 29 | const tmp = mkdtempSync(join(tmpdir(), "spawn-cell-")); |
| @@ -568,55 +567,42 @@ describe("a child that will not start", () => { | ||
| 568 | 567 | // must boot a child in a cell anyway (#6023). In its own process, without it. |
| 569 | 568 | const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat"); |
| 570 | 569 | |
| 570 | // One child for the file (#12396): booted once in the hook, so each test | |
| 571 | // reads what it saw; the boot is the file's time, not a test's. | |
| 572 | let childRun: HelperRun = { skipped: "no bwrap and socat here" }; | |
| 573 | function seenChild(): Record<string, unknown> | undefined { | |
| 574 | if ("error" in childRun) throw new Error(childRun.error); | |
| 575 | if ("skipped" in childRun) { | |
| 576 | console.log(`SKIPPING: this host refuses a cell inside the test (${childRun.skipped}), so no child forge was booted in one`); | |
| 577 | return undefined; | |
| 578 | } | |
| 579 | // The child's own reason and the machine's disk and load, not a bare mismatch. | |
| 580 | const { seen, out, errText } = childRun; | |
| 581 | if (seen["failure"] !== null || seen["health"] !== 200) throw new Error(failedWith("the child forge did not come up in its cell", { seen, out: out.slice(-1500) }, errText)); | |
| 582 | return seen; | |
| 583 | } | |
| 584 | ||
| 571 | 585 | describe("a real child forge", () => { |
| 586 | beforeAll(async () => { | |
| 587 | if (cellTools) childRun = await runHelper([join(import.meta.dir, "helpers", "real-child-forge.ts")], "the child forge run", 85_000); | |
| 588 | }, 90_000); | |
| 589 | ||
| 572 | 590 | test.skipIf(!cellTools)("boots in its own forge:<name> cell and answers / through the forward; its first request is held until it is up", async () => { |
| 573 | const env: Record<string, string> = {}; | |
| 574 | for (const [k, v] of Object.entries(process.env)) if (v !== undefined && k !== "FORGE_CELLS") env[k] = v; | |
| 575 | const proc = Bun.spawn([process.execPath, join(import.meta.dir, "helpers", "real-child-forge.ts")], { env, stdout: "pipe", stderr: "pipe" }); | |
| 576 | const [out, errText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); | |
| 577 | const line = resultLine(out); | |
| 578 | let seen: Record<string, unknown>; | |
| 579 | try { | |
| 580 | seen = JSON.parse(line) as Record<string, unknown>; | |
| 581 | } catch { | |
| 582 | throw new Error(failedWith("the child forge run printed no result", out.slice(-800), errText)); | |
| 583 | } | |
| 584 | if (typeof seen["skipped"] === "string") { | |
| 585 | console.log(`SKIPPING: this host refuses a cell inside the test (${seen["skipped"]}), so no child forge was booted in one`); | |
| 586 | return; | |
| 587 | } | |
| 588 | // The child's own reason and the machine's disk and load, not a bare mismatch. | |
| 589 | if (seen["failure"] !== null || seen["health"] !== 200) throw new Error(failedWith("the child forge did not come up in its cell", { seen, out: out.slice(-1500) }, errText)); | |
| 591 | const seen = seenChild(); | |
| 592 | if (seen === undefined) return; | |
| 590 | 593 | expect(seen["first"]).toBe(200); |
| 591 | 594 | expect(seen["limits"]).toEqual({ memoryMiB: 2048, cpus: 1, diskMiB: 2048 }); |
| 592 | 595 | expect(seen["root"] as number).toBeLessThan(400); |
| 593 | 596 | expect(seen["health"]).toBe(200); |
| 594 | 597 | expect(seen["labelled"]).toBe(true); |
| 595 | }, 90_000); | |
| 598 | }); | |
| 596 | 599 | |
| 597 | test.skipIf(!cells.ok)("its heartbeat reaches the parent through the cell, with no public address", async () => { | |
| 598 | // The parent's public url is not reachable from here, as in a preview's cell. | |
| 599 | const parent = createApp({ dataDir: join(tmp, "beat-parent"), url: "https://unreachable.invalid" }); | |
| 600 | try { | |
| 601 | mkdirSync(join(parent.config.dataDir, "spawn"), { recursive: true }); | |
| 602 | writeFileSync(join(parent.config.dataDir, "spawn", "kid.json"), JSON.stringify({ name: "kid", bodyId: "cell-beat", done: true, beatKey: "beat-key" })); | |
| 603 | const dir = join(parent.config.dataDir, "children", "cell-beat"); | |
| 604 | mkdirSync(dir, { recursive: true }); | |
| 605 | writeFileSync(join(dir, "parent.json"), JSON.stringify({ name: "kid", upstream: "https://unreachable.invalid", beatKey: "beat-key" })); | |
| 606 | const children = cellChildren(parent); | |
| 607 | const child = await children.start({ bodyId: "cell-beat", name: "kid", size: "small" }); | |
| 608 | if (child === undefined) throw new Error(`the child did not start: ${children.failure("cell-beat")}`); | |
| 609 | let heard = await lastHeard(parent, "kid"); | |
| 610 | for (let i = 0; i < 100 && heard === undefined; i++) { | |
| 611 | await Bun.sleep(100); | |
| 612 | heard = await lastHeard(parent, "kid"); | |
| 613 | } | |
| 614 | expect(heard?.beat.name).toBe("kid"); | |
| 615 | } finally { | |
| 616 | await stopCellChildren(parent); | |
| 617 | parent.close(); | |
| 618 | } | |
| 619 | }, 60_000); | |
| 600 | test.skipIf(!cellTools)("its heartbeat reaches the parent through the cell, with no public address", async () => { | |
| 601 | // The child's parent record names an unreachable upstream, as in a preview's cell. | |
| 602 | const seen = seenChild(); | |
| 603 | if (seen === undefined) return; | |
| 604 | expect(seen["beat"]).toBe("kid"); | |
| 605 | }); | |
| 620 | 606 | }); |
| 621 | 607 | |
| 622 | 608 | describe("a child whose cell will not come up", () => { |
test/spawn-small-cell.test.ts +31 −23
| @@ -8,37 +8,45 @@ | ||
| 8 | 8 | * breach, and the app is a committed one-file app: building a first app, |
| 9 | 9 | * passing its tests and landing a second change are not covered here. |
| 10 | 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. | |
| 11 | * Runs where bwrap and socat are, else skips. The one child is spawned, stopped | |
| 12 | * and recovered in the file's hook (#12396), with the restart's backoff set | |
| 13 | * to 100 ms through noteStop's seam; each test reads what it showed. The | |
| 14 | * 4 minute ceiling is the worst case. | |
| 14 | 15 | */ |
| 15 | import { test } from "bun:test"; | |
| 16 | import { beforeAll, expect, test } from "bun:test"; | |
| 16 | 17 | import { join } from "node:path"; |
| 17 | 18 | import { portInsteadOfSocket } from "../src/git/hook-address.ts"; |
| 18 | 19 | import { missing } from "./helpers/tools.ts"; |
| 19 | import { failedWith } from "./helpers/machine-note.ts"; | |
| 20 | import { resultLine } from "./helpers/result-line.ts"; | |
| 20 | import { failedWith, runHelper, type HelperRun } from "./helpers/machine-note.ts"; | |
| 21 | 21 | |
| 22 | 22 | const cellTools = process.platform === "linux" && !portInsteadOfSocket() && !missing("bwrap", "socat"); |
| 23 | let run: HelperRun = { skipped: "no bwrap and socat here" }; | |
| 23 | 24 | |
| 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)); | |
| 25 | beforeAll(async () => { | |
| 26 | if (cellTools) run = await runHelper([join(import.meta.dir, "helpers", "real-spawn-e2e.ts"), "recover"], "the recovery run", 230_000); | |
| 27 | }, 240_000); | |
| 28 | ||
| 29 | function seenRun(): { seen: Record<string, unknown>; errText: string } | undefined { | |
| 30 | if ("error" in run) throw new Error(run.error); | |
| 31 | if ("skipped" in run) { | |
| 32 | console.log(`SKIPPING: this host refuses a cell inside the test (${run.skipped})`); | |
| 33 | return undefined; | |
| 34 | 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; | |
| 35 | return run; | |
| 36 | } | |
| 37 | ||
| 38 | test.skipIf(!cellTools)("a Small child's app answers through its cell, and its cell holds the Small limits", () => { | |
| 39 | const r = seenRun(); | |
| 40 | if (r === undefined) return; | |
| 41 | const { seen, errText } = r; | |
| 40 | 42 | 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)}`); | |
| 43 | expect(seen["limits"]).toMatchObject({ memoryMiB: 2048, cpus: 1 }); | |
| 44 | }); | |
| 45 | ||
| 46 | test.skipIf(!cellTools)("a Small child stopped over its memory comes back by itself, and is never answered 429", () => { | |
| 47 | const r = seenRun(); | |
| 48 | if (r === undefined) return; | |
| 49 | const { seen, errText } = r; | |
| 42 | 50 | 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 | 51 | if (seen["after"] !== 200) throw new Error(failedWith("the stopped child did not come back", { seen }, errText)); |
| 44 | }, 240_000); | |
| 52 | }); |
new above
offline