Public: anyone can read this; the crew can change it.
A check cell's declared memory is never silently clamped: forge.json says 8192, the cell enforced 4096 #3133
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3128-a-check-cell-s-declared-memory-is-never → main 3 commits fork 4 threads · 4 unresolved
7 files
changed +212
−6
README.md +4
| @@ -1735,6 +1735,10 @@ Cross-team work (`src/core/issue_teams.ts`, `src/actions/issue_teams.ts`) — an | ||
| 1735 | 1735 | |
| 1736 | 1736 | The forge setting `signup` (`src/settings/signup.ts`) is `open`, `invite` or `closed`, and `open` is what a forge with nothing stored reads as. A new forge — a fresh data directory or a spawned child — stores `invite` when it first boots; a forge that already has a stored value, or has run before without one, keeps what it had (no migration rewrites it). The seeding is done by `createApp` right after it makes the database, so a crash between those two steps would leave that forge `open`. **A new forge therefore refuses its first signup too**: make the first admin with `--first-admin` (a spawned forge's claim does it for the child), or set `FORGE_SIGNUP=open` before the first boot. A forge admin who is a person changes it live — **Signup** on `/admin/limits`, `PATCH /api/v1/admin/settings` (`{"signup":"closed"}`) or the `signup` MCP tool (dry by default, admin scope, never an agent) — with no restart; any other value is a 422. `FORGE_SIGNUP` sets it at boot, and a wrong value stops the boot with a plain message; while it stays set, every restart or deploy writes it again over a change made on the page (kept as a change by `boot`), so unset it to let the page decide. Every change is kept with who made it and when, and listed beside the form. While signup is `closed` (and `invite`, for anyone without an open invite link), `/signup` is one sentence with no form, the API and passkey signup answer 403 `signup_closed`, the terminal offers no Create account and refuses (with `invite` it asks for the token instead), and a first edge sign-in for a new name is 401. Existing accounts still sign in. A fresh forge's first account is held to the setting too; the operator's `--first-admin` link and a spawned forge's claim make the admin whatever it says. |
| 1737 | 1737 | |
| 1738 | ### A cell's memory ceiling | |
| 1739 | ||
| 1740 | A check, test or app cell asks for memory in `forge.json` (`check.limits.memory`, `app.limits.memory`, MiB) and gets it up to the machine's ceiling. The ceiling is `FORGE_CELL_MEMORY_MAX_MIB` when set; otherwise 75% of the pod's `/sys/fs/cgroup/memory.max`; otherwise 4096 where the host states no limit. A declaration above the ceiling is held to it, and a check's log shows (before its command runs) `warning: forge.json declares memory 8192 MiB; this machine grants at most 4096 (…)`. A cell stopped for memory says whether its limit was the ceiling, `forge.json` or the default. The warning is for checks only: an app declaring `app.limits.memory` above the ceiling is held to it without a line saying so, and the settings page does not show the cut either (#3134). | |
| 1741 | ||
| 1738 | 1742 | ## Choosing among variants |
| 1739 | 1743 | |
| 1740 | 1744 | A tell with `variants: n` starts n builders whose pulls share a variant group. |
src/apps/contain.ts +53 −4
| @@ -39,9 +39,14 @@ import { portInsteadOfSocket } from "../git/hook-address.ts"; | ||
| 39 | 39 | import { relay, startEgress, type Egress } from "./egress.ts"; |
| 40 | 40 | import { cgroupHost, enterArgv, openCellCgroup } from "./cgroup.ts"; |
| 41 | 41 | |
| 42 | /** `ceiling`: a declaration was cut to the machine's maximum; `declared`: forge.json's own; `default`: neither. */ | |
| 43 | export type MemorySource = "ceiling" | "declared" | "default"; | |
| 44 | ||
| 42 | 45 | export interface Limits { |
| 43 | 46 | /** Resident memory of every process in the cell together, MiB. */ |
| 44 | 47 | readonly memoryMiB: number; |
| 48 | /** Where `memoryMiB` came from, for the line that says it was exceeded. */ | |
| 49 | readonly memorySource?: MemorySource | undefined; | |
| 45 | 50 | /** Cores, averaged over `CPU_WINDOW_MS`. */ |
| 46 | 51 | readonly cpus: number; |
| 47 | 52 | /** Bytes on disk under the cell's writable directories, MiB. */ |
| @@ -60,12 +65,54 @@ export const DEFAULT_LIMITS: Record<"app" | "step", Limits> = { | ||
| 60 | 65 | /** The most any declaration gets on this machine. */ |
| 61 | 66 | export function maxLimits(): Limits { |
| 62 | 67 | return { |
| 63 | memoryMiB: positive(process.env["FORGE_CELL_MEMORY_MAX_MIB"]) ?? 4096, | |
| 68 | memoryMiB: memoryCeiling(process.env["FORGE_CELL_MEMORY_MAX_MIB"]), | |
| 64 | 69 | cpus: availableParallelism(), |
| 65 | 70 | diskMiB: positive(process.env["FORGE_CELL_DISK_MAX_MIB"]) ?? 16384, |
| 66 | 71 | }; |
| 67 | 72 | } |
| 68 | 73 | |
| 74 | /** The share of the pod's memory a single cell may be given. */ | |
| 75 | export const POD_MEMORY_SHARE = 0.75; | |
| 76 | ||
| 77 | /** | |
| 78 | * The most memory one cell gets, MiB: `FORGE_CELL_MEMORY_MAX_MIB` when set, | |
| 79 | * else a share of the pod's own `memory.max`, else 4096 where the host | |
| 80 | * states no limit. Never a number the host cannot give. | |
| 81 | */ | |
| 82 | export function memoryCeiling( | |
| 83 | env: string | undefined, | |
| 84 | podFile = "/sys/fs/cgroup/memory.max", | |
| 85 | ): number { | |
| 86 | const set = positive(env); | |
| 87 | if (set !== undefined) return set; | |
| 88 | try { | |
| 89 | const bytes = Number(readFileSync(podFile, "utf8").trim()); | |
| 90 | if (Number.isFinite(bytes) && bytes > 0) | |
| 91 | return Math.max(1, Math.floor((bytes * POD_MEMORY_SHARE) / 1048576)); | |
| 92 | } catch { | |
| 93 | // no cgroup v2 here, or "max" (unlimited): the fixed default below | |
| 94 | } | |
| 95 | return 4096; | |
| 96 | } | |
| 97 | ||
| 98 | /** The line saying a declared memory limit is cut to the machine's ceiling; none when it is not. */ | |
| 99 | export function limitWarnings(declared: DeclaredLimits | undefined): string[] { | |
| 100 | const want = declared?.memoryMiB; | |
| 101 | const have = maxLimits().memoryMiB; | |
| 102 | return want !== undefined && want > have | |
| 103 | ? [`warning: forge.json declares memory ${want} MiB; this machine grants at most ${have} (FORGE_CELL_MEMORY_MAX_MIB, else a share of the pod's memory)`] | |
| 104 | : []; | |
| 105 | } | |
| 106 | ||
| 107 | /** Where a cell's memory limit came from, for the line that says it was exceeded. */ | |
| 108 | export function memorySource(source: MemorySource | undefined): string { | |
outdated
Equality with the ceiling is a guess at provenance. A forge.json that declares exactly the ceiling gets the wrong message. Return the source with the limit, and add a test. |
||
| 109 | return source === "ceiling" | |
| 110 | ? "the machine's ceiling, FORGE_CELL_MEMORY_MAX_MIB; a higher forge.json limit was held to it" | |
| 111 | : source === "declared" | |
| 112 | ? "forge.json limits.memory" | |
| 113 | : "the default"; | |
| 114 | } | |
| 115 | ||
| 69 | 116 | function positive(raw: string | undefined): number | undefined { |
| 70 | 117 | const n = Number(raw); |
| 71 | 118 | return Number.isFinite(n) && n > 0 ? n : undefined; |
| @@ -78,8 +125,10 @@ export function resolveLimits( | ||
| 78 | 125 | ): Limits { |
| 79 | 126 | const base = DEFAULT_LIMITS[kind]; |
| 80 | 127 | const max = maxLimits(); |
| 128 | const want = declared?.memoryMiB; | |
| 81 | 129 | return { |
| 82 | memoryMiB: Math.min(declared?.memoryMiB ?? base.memoryMiB, max.memoryMiB), | |
| 130 | memoryMiB: Math.min(want ?? base.memoryMiB, max.memoryMiB), | |
| 131 | memorySource: want === undefined ? "default" : want > max.memoryMiB ? "ceiling" : "declared", | |
| 83 | 132 | cpus: Math.min(declared?.cpus ?? base.cpus, max.cpus), |
| 84 | 133 | diskMiB: Math.min(declared?.diskMiB ?? base.diskMiB, max.diskMiB), |
| 85 | 134 | }; |
| @@ -672,7 +721,7 @@ function descendants(root: number): number[] { | ||
| 672 | 721 | return order; |
| 673 | 722 | } |
| 674 | 723 | |
| 675 | function watchTree( | |
| 724 | export function watchTree( | |
| 676 | 725 | root: number, |
| 677 | 726 | limits: Limits, |
| 678 | 727 | cap: MemoryCap, |
| @@ -821,7 +870,7 @@ function tick(): void { | ||
| 821 | 870 | entry.last = { ticks, kib }; |
| 822 | 871 | if (kib > limits.memoryMiB * 1024) { |
| 823 | 872 | entry.breach( |
| 824 | `used ${mib(kib * 1024)} of memory, over its ${limits.memoryMiB} MiB limit`, | |
| 873 | `used ${mib(kib * 1024)} of memory, over its ${limits.memoryMiB} MiB limit (${memorySource(limits.memorySource)})`, | |
Breach text with the source suffix is not asserted anywhere; cover it or call memorySource from a test of the breach message. |
||
| 825 | 874 | ); |
| 826 | 875 | continue; |
| 827 | 876 | } |
src/checks/service.ts +2
| @@ -75,6 +75,7 @@ import { | ||
| 75 | 75 | } from "../core/capacity.ts"; |
| 76 | 76 | import { |
| 77 | 77 | cellsAvailable, |
| 78 | limitWarnings, | |
| 78 | 79 | resolveLimits, |
| 79 | 80 | STEP_HOSTS, |
| 80 | 81 | type CellSpec, |
| @@ -798,6 +799,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 798 | 799 | } |
| 799 | 800 | : undefined; |
| 800 | 801 | try { |
| 802 | for (const line of limitWarnings(manifest.limits)) write(line); | |
Untested wiring: removing this line fails no test. Add a check-run test asserting the warning is the first log line. |
||
| 801 | 803 | const cellCpus = resolveLimits("step", manifest.limits).cpus; |
| 802 | 804 | const env = childEnv({ |
| 803 | 805 | // The shard count an admin set (`core/capacity.ts`), else the |
test/check-memory-warning.test.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** A declared memory above the ceiling is said in the check's log, and in the kill text (#3128). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 9 | import { watchTree, WATCH, type Limits } from "../src/apps/contain.ts"; | |
| 10 | import { isTerminal, listRuns } from "../src/core/runs.ts"; | |
| 11 | import { readRunLog } from "../src/runs/log.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | ||
| 14 | process.env["FORGE_CELLS"] = "off"; | |
| 15 | const savedCeiling = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 16 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let checks: ChecksHandle; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-memwarn-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | checks = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 26 | }); | |
| 27 | ||
| 28 | afterAll(async () => { | |
| 29 | await checks.stop(); | |
| 30 | app.close(); | |
| 31 | await rm(dataDir, { recursive: true, force: true }); | |
| 32 | if (savedCeiling === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 33 | else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = savedCeiling; | |
| 34 | }); | |
| 35 | ||
| 36 | test("a check declaring memory above the ceiling logs the warning before its command", async () => { | |
| 37 | const u = await createUser(app.db, { | |
| 38 | origin: "seed", | |
| 39 | trusted: true, | |
| 40 | username: "alice", | |
| 41 | email: "[email protected]", | |
| 42 | password: "a-good-passphrase", | |
| 43 | adminIfFirst: true, | |
| 44 | }); | |
| 45 | if (!u.ok) throw new Error(u.error); | |
| 46 | const made = await createRepo(app, u.value, { name: "big", visibility: "public" }); | |
| 47 | if (!made.ok) throw new Error(String(made.error)); | |
| 48 | const repo = made.value; | |
| 49 | const pushed = await commitToBranch(app, u.value, repo, { | |
| 50 | message: "Asks for more memory than the machine grants", | |
| 51 | files: [ | |
| 52 | { | |
| 53 | path: "forge.json", | |
| 54 | content: `{"check": {"run": "true", "install": "", "limits": {"memory": 8192}}}\n`, | |
| 55 | }, | |
| 56 | ], | |
| 57 | }); | |
| 58 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 59 | const oid = pushed.value.commitOid; | |
| 60 | ||
| 61 | const deadline = Date.now() + 30_000; | |
| 62 | const mine = () => | |
| 63 | listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 64 | (r) => r.commitOid === oid && isTerminal(r.state), | |
| 65 | ); | |
| 66 | while (Date.now() < deadline && mine() === undefined) await Bun.sleep(100); | |
| 67 | const run = mine(); | |
| 68 | if (run === undefined) throw new Error("the check did not finish"); | |
| 69 | const lines = readRunLog(dataDir, run.id).lines; | |
| 70 | const warned = lines.findIndex((l) => l.startsWith("warning: forge.json declares memory 8192 MiB")); | |
| 71 | const ran = lines.findIndex((l) => l.startsWith("$ ")); | |
| 72 | expect(warned).toBeGreaterThanOrEqual(0); | |
| 73 | expect(lines[warned]).toContain("grants at most 4096"); | |
| 74 | expect(ran).toBeGreaterThan(warned); | |
| 75 | }, 60_000); | |
| 76 | ||
| 77 | test("the breach text names where the limit came from", async () => { | |
| 78 | const saved = { ...WATCH }; | |
| 79 | WATCH.tickMs = 100; | |
| 80 | const hog = Bun.spawn( | |
| 81 | ["bun", "-e", "const a = new Uint8Array(200 << 20).fill(1); await Bun.sleep(20000); console.log(a.length)"], | |
| 82 | { stdout: "ignore", stderr: "ignore" }, | |
| 83 | ); | |
| 84 | try { | |
| 85 | const why = await new Promise<string>((resolve) => { | |
| 86 | const limits: Limits = { memoryMiB: 64, memorySource: "ceiling", cpus: 64, diskMiB: 1 << 20 }; | |
| 87 | const unwatch = watchTree(hog.pid, limits, { bytes: 64 << 20, by: "watch" }, [], undefined, (w) => { | |
| 88 | unwatch(); | |
| 89 | resolve(w); | |
| 90 | }); | |
| 91 | }); | |
| 92 | expect(why).toMatch(/^used \d+ MiB of memory, over its 64 MiB limit \(the machine's ceiling, FORGE_CELL_MEMORY_MAX_MIB;/); | |
| 93 | } finally { | |
| 94 | hog.kill(); | |
| 95 | Object.assign(WATCH, saved); | |
| 96 | } | |
| 97 | }, 30_000); |
test/contain.test.ts +1 −1
| @@ -810,7 +810,7 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 810 | 810 | if (!result.ok) |
| 811 | 811 | expect(result.error).toMatch( |
| 812 | 812 | // The watch's tick, or — where the forge holds a delegated cgroup — the kernel's cap first. |
| 813 | /^check stopped: it (used \d+ MiB of memory, over its 128 MiB limit|reached its 128 MiB memory cap, and the kernel killed it)$/, | |
| 813 | /^check stopped: it (used \d+ MiB of memory, over its 128 MiB limit \(the default\)|reached its 128 MiB memory cap, and the kernel killed it)$/, | |
| 814 | 814 | ); |
| 815 | 815 | }, 30_000); |
| 816 | 816 |
test/memory-ceiling.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** The memory ceiling follows the host and a declared limit above it is said aloud (#3128). */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { limitWarnings, memoryCeiling, memorySource, resolveLimits } from "../src/apps/contain.ts"; | |
| 7 | ||
| 8 | const pod = (text: string) => { | |
| 9 | const f = join(mkdtempSync(join(tmpdir(), "mem-")), "memory.max"); | |
| 10 | writeFileSync(f, text); | |
| 11 | return f; | |
| 12 | }; | |
| 13 | ||
| 14 | describe("the memory ceiling is never a silent clamp (#3128)", () => { | |
| 15 | test("the env wins; else a share of the pod's memory.max; else 4096", () => { | |
| 16 | expect(memoryCeiling("8192", pod("1073741824"))).toBe(8192); | |
| 17 | expect(memoryCeiling(undefined, pod(String(16384 * 1048576)))).toBe(12288); | |
| 18 | expect(memoryCeiling(undefined, pod("max\n"))).toBe(4096); | |
| 19 | expect(memoryCeiling(undefined, "/nonexistent/memory.max")).toBe(4096); | |
| 20 | }); | |
| 21 | ||
| 22 | test("a declared memory above the ceiling produces a warning; within it, none", () => { | |
| 23 | const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 24 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 25 | try { | |
| 26 | const lines = limitWarnings({ memoryMiB: 8192 }); | |
| 27 | expect(lines).toHaveLength(1); | |
| 28 | expect(lines[0]).toContain("declares memory 8192 MiB"); | |
| 29 | expect(lines[0]).toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 30 | expect(limitWarnings({ memoryMiB: 2048 })).toEqual([]); | |
| 31 | expect(limitWarnings(undefined)).toEqual([]); | |
| 32 | } finally { | |
| 33 | if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 34 | else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved; | |
| 35 | } | |
| 36 | }); | |
| 37 | ||
| 38 | test("the kill message names the limit's real source, even when forge.json declares exactly the ceiling", () => { | |
| 39 | const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 40 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 41 | try { | |
| 42 | const source = (declared?: { memoryMiB: number }) => | |
| 43 | memorySource(resolveLimits("step", declared).memorySource); | |
| 44 | expect(source({ memoryMiB: 8192 })).toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 45 | expect(source({ memoryMiB: 4096 })).toBe("forge.json limits.memory"); | |
| 46 | expect(source({ memoryMiB: 1024 })).toBe("forge.json limits.memory"); | |
| 47 | expect(source()).toBe("the default"); | |
| 48 | expect(limitWarnings({ memoryMiB: 4096 })).toEqual([]); | |
| 49 | } finally { | |
| 50 | if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 51 | else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved; | |
| 52 | } | |
| 53 | }); | |
| 54 | }); |
test/shards.test.ts +1 −1
| @@ -302,7 +302,7 @@ describe("shardCount", () => { | ||
| 302 | 302 | const src = readFileSync(new URL("../scripts/test-shards.ts", import.meta.url), "utf8"); |
| 303 | 303 | expect(src).toContain('resolveLimits("step", declared).memoryMiB'); |
| 304 | 304 | const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; |
| 305 | delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 305 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 306 | 306 | try { |
| 307 | 307 | expect(resolveLimits("step", { memoryMiB: 8192 }).memoryMiB).toBe(4096); |
| 308 | 308 | } finally { |
new above
offline
The cpus and disk warnings go beyond the memory issue. Drop them, or justify them in the pull and test them.