Public: anyone can read this; the crew can change it.
A check never takes the forge down: each check cell has a real memory cap, and test processes stay small #2426
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2410-a-check-never-takes-the-forge-down-each → main 2 commits fork 1 thread · 1 unresolved
14 files
changed +646
−21
docs/APPS.md +17
| @@ -261,6 +261,22 @@ are capped by the machine (`FORGE_CELL_MEMORY_MAX_MIB`, default 4096; the | ||
| 261 | 261 | number of cores; `FORGE_CELL_DISK_MAX_MIB`, default 16384). A cell's `/tmp` |
| 262 | 262 | holds at most 256 MiB. |
| 263 | 263 | |
| 264 | Memory is held twice. The watch reads every cell's resident memory once a | |
| 265 | second and stops one over its limit. Where the forge holds a delegated | |
| 266 | cgroup v2 (`apps/cgroup.ts`), the kernel holds it as well: each cell runs | |
| 267 | in `<R>/cells/c<n>-<pid>` with `memory.max` set to its limit, | |
| 268 | `memory.swap.max` 0 and `memory.oom.group` 1, and the forge moves itself | |
| 269 | to `<R>/forge`. A check that allocates past its cap faster than a tick is | |
| 270 | then OOM-killed alone (`check stopped: it reached its 8192 MiB memory cap, | |
| 271 | and the kernel killed it`), never the forge or the other checks (#2410). | |
| 272 | `R` is `/sys/fs/cgroup` when the forge is at its container's cgroup | |
| 273 | namespace root, or `FORGE_CELL_CGROUP` (a path, or `off`). The cgroup has to | |
| 274 | be writable by the forge's user. A container's `/sys/fs/cgroup` is | |
| 275 | read-only by default, so the operator delegates it, for example as a | |
| 276 | writable cgroup mount owned by the forge's uid. Without that, cells are | |
| 277 | watched only. /system says which holds the cap, and why, and shows each | |
| 278 | cell's memory beside its cap. | |
| 279 | ||
| 264 | 280 | Where the host cannot hold cells (or the crew's shell sandbox), the forge |
| 265 | 281 | runs uncontained and says so in one line: a boot warning, and, in the foot of |
| 266 | 282 | every page a site admin reads, `Reduced protection: apps (<reason>) runs |
| @@ -1103,6 +1119,7 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does. | ||
| 1103 | 1119 | | `FORGE_CHECKS_DRAIN_S` | `300` | seconds a roll lets running checks finish first | |
| 1104 | 1120 | | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) | |
| 1105 | 1121 | | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) | |
| 1122 | | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only | | |
| 1106 | 1123 | | `FORGE_CREW_BUDGET_USD` | `5` | spend per run unless the agent says otherwise | |
| 1107 | 1124 | | `FORGE_CREW_TIMEOUT_MIN` | `45` | wall-clock limit of one agent run | |
| 1108 | 1125 | | `FORGE_CREW_ALLOW_HOSTS` | — | hosts an agent's shell may reach, comma-separated | |
scripts/shards.ts +30
| @@ -157,6 +157,36 @@ export function splitFiles( | ||
| 157 | 157 | ); |
| 158 | 158 | } |
| 159 | 159 | |
| 160 | /** | |
| 161 | * The most test files one `bun test` process runs. A process keeps what | |
| 162 | * each file leaves behind (one global per process), and the forge was | |
| 163 | * OOM-killed with 29 of them at 230–380 MiB each, ~110 files apiece | |
| 164 | * (#2410). Measured with VmHWM on Bun 1.3.14, light gate files in name | |
| 165 | * order: 40 files peaked at 548 MiB and 120 at 549 MiB — a process's peak | |
| 166 | * is its heaviest file's plus what the files before it left, so 40 keeps | |
| 167 | * that under 1 GiB with room, and costs a spawn (~0.1 s) per 40 files. | |
| 168 | */ | |
| 169 | export const FILES_PER_PROCESS = 40; | |
| 170 | ||
| 171 | /** | |
| 172 | * {@link splitFiles}, with no slice over `max` files: enough slices that | |
| 173 | * every one fits, balanced by weight, and any still over (many light files | |
| 174 | * on one slice) cut in order into runs of `max`. A runner runs at most | |
| 175 | * `n` of them at once; this decides only what each process holds. | |
| 176 | */ | |
| 177 | export function splitCapped( | |
| 178 | files: readonly string[], | |
| 179 | n: number, | |
| 180 | weight: (file: string) => number = () => 1, | |
| 181 | max = FILES_PER_PROCESS, | |
| 182 | ): string[][] { | |
| 183 | const per = Math.max(1, Math.floor(max)); | |
| 184 | const slices = splitFiles(files, Math.max(n, Math.ceil(files.length / per)), weight); | |
| 185 | return slices.flatMap((slice) => | |
| 186 | Array.from({ length: Math.ceil(slice.length / per) }, (_, i) => slice.slice(i * per, (i + 1) * per)), | |
| 187 | ); | |
| 188 | } | |
| 189 | ||
| 160 | 190 | /** How a test file is named: the same rule `scripts/test-shards.ts` uses. */ |
| 161 | 191 | export const TEST_FILE = /\.test\.[cm]?[jt]sx?$/; |
| 162 | 192 |
scripts/test-shards.ts +4 −3
| @@ -55,7 +55,7 @@ import { | ||
| 55 | 55 | shardCountFromEnv, |
| 56 | 56 | sliceFiles, |
| 57 | 57 | splitArgs, |
| 58 | splitFiles, | |
| 58 | splitCapped, | |
| 59 | 59 | tally, |
| 60 | 60 | weigher, |
| 61 | 61 | type ShardResult, |
| @@ -381,7 +381,7 @@ const perFile = record || measure || serialFiles || timed; | ||
| 381 | 381 | const slices = |
| 382 | 382 | perFile |
| 383 | 383 | ? heaviestFirst(files, weight).map((file) => [file]) |
| 384 | : splitFiles(files, count.value, weight); | |
| 384 | : splitCapped(files, count.value, weight); | |
| 385 | 385 | const work = files.reduce((sum, file) => sum + weight(file), 0); |
| 386 | 386 | |
| 387 | 387 | const banner = record |
| @@ -537,7 +537,8 @@ const started = performance.now(); | ||
| 537 | 537 | // Serial one-file processes when SHARDS=1; record keeps its worker width; |
| 538 | 538 | // multi-shard still runs every slice side by side. |
| 539 | 539 | const results = await pool( |
| 540 | serialFiles ? 1 : record || measure || timed ? count.value : slices.length, | |
| 540 | // Never more side by side than the count: capped slices outnumber it. | |
| 541 | serialFiles ? 1 : Math.min(count.value, slices.length), | |
| 541 | 542 | ); |
| 542 | 543 | if (results.length < slices.length) { |
| 543 | 544 | const skipped = slices.length - results.length; |
src/apps/cgroup.ts added +231
| @@ -0,0 +1,231 @@ | ||
| 1 | /** | |
| 2 | * A cell's memory cap, held by the kernel: each cell gets a cgroup v2 of | |
| 3 | * its own under the forge's, with `memory.max` set to the cell's memory | |
| 4 | * limit, so a cell that allocates past it is OOM-killed alone — never the | |
| 5 | * forge, never its neighbours (#2410). The watch in `apps/contain.ts` | |
| 6 | * still reads every cell once a second; this is the floor under it, for | |
| 7 | * a cell that allocates faster than a second's tick. | |
| 8 | * | |
| 9 | * It needs the forge's own cgroup delegated to it: cgroup v2, mounted | |
| 10 | * read-write, owned by the forge's user, with the memory controller | |
| 11 | * available. A container gets that from its runtime (a delegated | |
| 12 | * subtree, or a writable `/sys/fs/cgroup`); without it, cells are | |
| 13 | * watched only, and `cgroupHost()` says why (shown on /system). | |
| 14 | * | |
| 15 | * Layout under the forge's cgroup `R` (cgroup v2's "no internal | |
| 16 | * processes" rule: a cgroup that hands memory to children holds no | |
| 17 | * process of its own): | |
| 18 | * | |
| 19 | * R/forge/ the forge and everything it starts outside a cell | |
| 20 | * R/cells/c<n>/ one per cell: memory.max, memory.swap.max = 0, | |
| 21 | * memory.oom.group = 1 (the whole cell dies together) | |
| 22 | * | |
| 23 | * `FORGE_CELL_CGROUP` names `R` (an absolute path) or turns this `off`; | |
| 24 | * unset, `R` is `/sys/fs/cgroup` when the forge is at the root of its | |
| 25 | * cgroup namespace (`0::/` in `/proc/self/cgroup`: a container's own). | |
| 26 | */ | |
| 27 | ||
| 28 | import { | |
| 29 | accessSync, | |
| 30 | constants, | |
| 31 | existsSync, | |
| 32 | mkdirSync, | |
| 33 | readdirSync, | |
| 34 | readFileSync, | |
| 35 | rmdirSync, | |
| 36 | writeFileSync, | |
| 37 | } from "node:fs"; | |
| 38 | import { join } from "node:path"; | |
| 39 | ||
| 40 | export type CgroupHost = | |
| 41 | | { readonly ok: true; readonly root: string } | |
| 42 | | { readonly ok: false; readonly reason: string }; | |
| 43 | ||
| 44 | /** The cgroup a cell's process is in, open for the life of the cell. */ | |
| 45 | export interface CellCgroup { | |
| 46 | readonly dir: string; | |
| 47 | /** The cap, bytes (`memory.max`). */ | |
| 48 | readonly maxBytes: number; | |
| 49 | /** How many times the kernel OOM-killed in it (`memory.events` `oom_kill`). */ | |
| 50 | readonly oomKills: () => number; | |
| 51 | /** Kill whatever is left and remove the cgroup, retrying while it empties. */ | |
| 52 | readonly remove: () => void; | |
| 53 | } | |
| 54 | ||
| 55 | let host: CgroupHost | undefined; | |
| 56 | let nextCell = 1; | |
| 57 | ||
| 58 | /** Whether cells get a kernel memory cap here, decided once (and the layout prepared). */ | |
| 59 | export function cgroupHost(): CgroupHost { | |
| 60 | host ??= prepare(cgroupRoot()); | |
| 61 | return host; | |
| 62 | } | |
| 63 | ||
| 64 | /** The forge's own cgroup directory, or why there is none to use. */ | |
| 65 | export function cgroupRoot( | |
| 66 | env: string | undefined = process.env["FORGE_CELL_CGROUP"], | |
| 67 | self: () => string = () => readFileSync("/proc/self/cgroup", "utf8"), | |
| 68 | ): { ok: true; root: string } | { ok: false; reason: string } { | |
| 69 | if (env === "off") return { ok: false, reason: "FORGE_CELL_CGROUP=off" }; | |
| 70 | if (env !== undefined && env !== "") { | |
| 71 | if (!env.startsWith("/") || env.split("/").includes("..")) | |
| 72 | return { ok: false, reason: `FORGE_CELL_CGROUP must be an absolute path, not ${JSON.stringify(env)}` }; | |
| 73 | return { ok: true, root: env }; | |
| 74 | } | |
| 75 | if (process.platform !== "linux") return { ok: false, reason: `no cgroups on ${process.platform}` }; | |
| 76 | let text: string; | |
| 77 | try { | |
| 78 | text = self(); | |
| 79 | } catch { | |
| 80 | return { ok: false, reason: "/proc/self/cgroup is unreadable" }; | |
| 81 | } | |
| 82 | // cgroup v2 is the one line `0::<path>`; v1 has a line per controller. | |
| 83 | const line = text.split("\n").find((l) => l.startsWith("0::")); | |
| 84 | if (line === undefined) return { ok: false, reason: "this host has no cgroup v2" }; | |
| 85 | // At the root of its own cgroup namespace (a container), the cgroup is | |
| 86 | // the container's to divide. Anywhere else (a developer's login session) | |
| 87 | // it is shared with processes that are not the forge's: only when named. | |
| 88 | if (line.slice(3).trim() !== "/") | |
| 89 | return { ok: false, reason: "not at a cgroup namespace root; FORGE_CELL_CGROUP names a delegated cgroup" }; | |
| 90 | return { ok: true, root: "/sys/fs/cgroup" }; | |
| 91 | } | |
| 92 | ||
| 93 | const read = (path: string): string | undefined => { | |
| 94 | try { | |
| 95 | return readFileSync(path, "utf8"); | |
| 96 | } catch { | |
| 97 | return undefined; | |
| 98 | } | |
| 99 | }; | |
| 100 | ||
| 101 | const writable = (path: string): boolean => { | |
| 102 | try { | |
| 103 | accessSync(path, constants.W_OK); | |
| 104 | return true; | |
| 105 | } catch { | |
| 106 | return false; | |
| 107 | } | |
| 108 | }; | |
| 109 | ||
| 110 | const words = (text: string | undefined): string[] => (text ?? "").trim().split(/\s+/).filter((w) => w !== ""); | |
| 111 | ||
| 112 | /** | |
| 113 | * Make `R/forge` and `R/cells` and give `cells` the memory controller, | |
| 114 | * moving the forge's processes out of `R` first. Idempotent: a forge that | |
| 115 | * restarts finds the layout standing, and removes the cells of a forge | |
| 116 | * that is gone — never those of one still draining its checks in a roll. | |
| 117 | */ | |
| 118 | export function prepare( | |
| 119 | found: { ok: true; root: string } | { ok: false; reason: string }, | |
| 120 | ): CgroupHost { | |
| 121 | if (!found.ok) return found; | |
| 122 | const { root } = found; | |
| 123 | if (!words(read(join(root, "cgroup.controllers"))).includes("memory")) | |
| 124 | return { ok: false, reason: `${root} has no memory controller (cgroup v2 not delegated here)` }; | |
| 125 | for (const file of ["", "cgroup.procs", "cgroup.subtree_control"]) | |
| 126 | if (!writable(join(root, file))) | |
| 127 | return { ok: false, reason: `${root} is read-only to the forge (no delegated cgroup)` }; | |
| 128 | try { | |
| 129 | if (!words(read(join(root, "cgroup.subtree_control"))).includes("memory")) { | |
| 130 | const leaf = join(root, "forge"); | |
| 131 | mkdirSync(leaf, { recursive: true }); | |
| 132 | // One pid per write: cgroupfs takes them one at a time. | |
| 133 | for (const pid of words(read(join(root, "cgroup.procs")))) { | |
| 134 | try { | |
| 135 | writeFileSync(join(leaf, "cgroup.procs"), pid); | |
| 136 | } catch { | |
| 137 | // gone between the read and the write, or a kernel thread | |
| 138 | } | |
| 139 | } | |
| 140 | writeFileSync(join(root, "cgroup.subtree_control"), "+memory"); | |
| 141 | } | |
| 142 | const cells = join(root, "cells"); | |
| 143 | mkdirSync(cells, { recursive: true }); | |
| 144 | writeFileSync(join(cells, "cgroup.subtree_control"), "+memory"); | |
| 145 | for (const name of readdirSync(cells)) { | |
| 146 | const owner = /^c\d+-(\d+)$/.exec(name)?.[1]; | |
| 147 | if (owner !== undefined && !alive(Number(owner))) removeDir(join(cells, name)); | |
| 148 | } | |
| 149 | } catch (error) { | |
| 150 | return { ok: false, reason: `cannot lay out ${root}: ${error instanceof Error ? error.message : String(error)}` }; | |
| 151 | } | |
| 152 | return { ok: true, root }; | |
| 153 | } | |
| 154 | ||
| 155 | function alive(pid: number): boolean { | |
| 156 | try { | |
| 157 | process.kill(pid, 0); | |
| 158 | return true; | |
| 159 | } catch (error) { | |
| 160 | return (error as NodeJS.ErrnoException).code === "EPERM"; | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | /** Kill what is in a cell's cgroup and remove it; a few tries while the processes go. */ | |
| 165 | function removeDir(dir: string, tries = 10): void { | |
| 166 | try { | |
| 167 | if (existsSync(join(dir, "cgroup.kill"))) writeFileSync(join(dir, "cgroup.kill"), "1"); | |
| 168 | } catch { | |
| 169 | // already empty, or the kernel has no cgroup.kill (before 5.14) | |
| 170 | } | |
| 171 | try { | |
| 172 | rmdirSync(dir); | |
| 173 | } catch (error) { | |
| 174 | const code = (error as NodeJS.ErrnoException).code; | |
| 175 | if (code === "ENOENT" || tries <= 1) return; | |
| 176 | // EBUSY: a killed process is not reaped yet. | |
| 177 | setTimeout(() => removeDir(dir, tries - 1), 200).unref(); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | /** A cgroup of its own for one cell, capped at `memoryMiB`; `undefined` where the host has none. */ | |
| 182 | export function openCellCgroup(memoryMiB: number, at: CgroupHost = cgroupHost()): CellCgroup | undefined { | |
| 183 | if (!at.ok) return undefined; | |
| 184 | const dir = join(at.root, "cells", `c${nextCell++}-${process.pid}`); | |
| 185 | const maxBytes = Math.floor(memoryMiB * 1024 * 1024); | |
| 186 | try { | |
| 187 | mkdirSync(dir); | |
| 188 | writeFileSync(join(dir, "memory.max"), String(maxBytes)); | |
| 189 | } catch { | |
| 190 | removeDir(dir, 1); | |
| 191 | return undefined; | |
| 192 | } | |
| 193 | // Swap would only let a cell outlive its cap slowly; one OOM kills the cell, not a process of it. | |
| 194 | for (const [file, value] of [["memory.swap.max", "0"], ["memory.oom.group", "1"]] as const) { | |
| 195 | try { | |
| 196 | if (existsSync(join(dir, file))) writeFileSync(join(dir, file), value); | |
| 197 | } catch { | |
| 198 | // an older kernel: the cap still holds | |
| 199 | } | |
| 200 | } | |
| 201 | return { | |
| 202 | dir, | |
| 203 | maxBytes, | |
| 204 | oomKills: () => oomKills(read(join(dir, "memory.events"))), | |
| 205 | remove: () => removeDir(dir), | |
| 206 | }; | |
| 207 | } | |
| 208 | ||
| 209 | /** `oom_kill` from a `memory.events` file; 0 when it is absent. */ | |
| 210 | export function oomKills(events: string | undefined): number { | |
| 211 | const match = /^oom_kill (\d+)$/m.exec(events ?? ""); | |
| 212 | return match ? Number(match[1]) : 0; | |
| 213 | } | |
| 214 | ||
| 215 | /** | |
| 216 | * The argv that moves itself into the cgroup whose `cgroup.procs` is | |
| 217 | * `procs`, then becomes `argv` — so the cell and every process it starts | |
| 218 | * are under the cap from their first instruction. If it cannot enter, it | |
| 219 | * refuses to run rather than run uncapped. No caller text reaches the | |
| 220 | * shell: the path and the argv are arguments, never script. | |
| 221 | */ | |
| 222 | export function enterArgv(procs: string, argv: readonly string[]): string[] { | |
| 223 | return [ | |
| 224 | "/bin/sh", | |
| 225 | "-c", | |
| 226 | 'echo $$ > "$1" || { echo "cell: cannot enter its memory cgroup" >&2; exit 125; }; shift; exec "$@"', | |
| 227 | "cgroup-enter", | |
| 228 | procs, | |
| 229 | ...argv, | |
| 230 | ]; | |
| 231 | } |
src/apps/contain.ts +45 −9
| @@ -12,9 +12,11 @@ | ||
| 12 | 12 | * else. The way out is the forge's proxy (`apps/egress.ts`), which reaches |
| 13 | 13 | * the forge and the hosts the repository declared, and refuses the rest. |
| 14 | 14 | * The way in, for an app, is a relay from the port the forge proxies to. |
| 15 | * - **Limits.** Memory, CPU and disk are watched from outside (a container | |
| 16 | * cannot create cgroups), and a cell over any of them is stopped with the | |
| 17 | * reason, which becomes the step's error or the app's last log line. | |
| 15 | * - **Limits.** Memory, CPU and disk are watched from outside, and a cell | |
| 16 | * over any of them is stopped with the reason, which becomes the step's | |
| 17 | * error or the app's last log line. Where the forge holds a delegated | |
| 18 | * cgroup (`apps/cgroup.ts`), memory is also capped by the kernel: a cell | |
| 19 | * that allocates past it between two ticks is OOM-killed alone. | |
| 18 | 20 | * |
| 19 | 21 | * Where the host cannot hold a cell (no bubblewrap, no user namespaces — |
| 20 | 22 | * macOS, or a locked-down container), the code runs as before and every |
| @@ -35,6 +37,7 @@ import { availableParallelism, tmpdir } from "node:os"; | ||
| 35 | 37 | import { dirname, join } from "node:path"; |
| 36 | 38 | import { portInsteadOfSocket } from "../git/hook-address.ts"; |
| 37 | 39 | import { relay, startEgress, type Egress } from "./egress.ts"; |
| 40 | import { cgroupHost, enterArgv, openCellCgroup } from "./cgroup.ts"; | |
| 38 | 41 | |
| 39 | 42 | export interface Limits { |
| 40 | 43 | /** Resident memory of every process in the cell together, MiB. */ |
| @@ -316,7 +319,12 @@ export function openCell( | ||
| 316 | 319 | // small and bounded, and the ones that only exist to hide are read-only. |
| 317 | 320 | const SMALL = String(1 << 20); |
| 318 | 321 | |
| 319 | const argv = (command: string): string[] => [ | |
| 322 | const cgroup = openCellCgroup(spec.limits.memoryMiB); | |
| 323 | const argv = (command: string): string[] => { | |
| 324 | const inner = bwrapArgv(command); | |
| 325 | return cgroup === undefined ? inner : enterArgv(join(cgroup.dir, "cgroup.procs"), inner); | |
| 326 | }; | |
| 327 | const bwrapArgv = (command: string): string[] => [ | |
| 320 | 328 | "bwrap", |
| 321 | 329 | ...NAMESPACES, |
| 322 | 330 | ...(spec.terminal === true ? [] : ["--new-session"]), |
| @@ -365,8 +373,17 @@ export function openCell( | ||
| 365 | 373 | |
| 366 | 374 | let breach: string | undefined; |
| 367 | 375 | let unwatch: (() => void) | undefined; |
| 376 | // The kernel's kill leaves no watch tick behind: its count is the reason. | |
| 377 | const killed = (): string | undefined => | |
| 378 | cgroup !== undefined && cgroup.oomKills() > 0 | |
| 379 | ? `reached its ${spec.limits.memoryMiB} MiB memory cap, and the kernel killed it` | |
| 380 | : undefined; | |
| 368 | 381 | const watch = (pid: number, stop: (why: string) => void): void => { |
| 369 | unwatch = watchTree(pid, spec.limits, writable, spec.label, (why) => { | |
| 382 | const cap: MemoryCap = | |
| 383 | cgroup === undefined | |
| 384 | ? { bytes: Math.floor(spec.limits.memoryMiB * 1024 * 1024), by: "watch" } | |
| 385 | : { bytes: cgroup.maxBytes, by: "kernel" }; | |
| 386 | unwatch = watchTree(pid, spec.limits, cap, writable, spec.label, (why) => { | |
| 370 | 387 | if (breach !== undefined) return; |
| 371 | 388 | breach = why; |
| 372 | 389 | log(`stopped: ${why}`); |
| @@ -377,8 +394,11 @@ export function openCell( | ||
| 377 | 394 | argv, |
| 378 | 395 | env, |
| 379 | 396 | watch, |
| 380 | breach: () => breach, | |
| 397 | breach: () => breach ?? killed(), | |
| 381 | 398 | close: () => { |
| 399 | const kernel = breach === undefined ? killed() : undefined; | |
| 400 | if (kernel !== undefined) log(`stopped: ${kernel}`); | |
| 401 | cgroup?.remove(); | |
| 382 | 402 | unwatch?.(); |
| 383 | 403 | for (const o of opened) o.close(); |
| 384 | 404 | rmSync(sockets, { recursive: true, force: true }); |
| @@ -514,6 +534,8 @@ interface Watched { | ||
| 514 | 534 | ticks: number; |
| 515 | 535 | readonly key: number; |
| 516 | 536 | readonly label: string | undefined; |
| 537 | /** The memory cap, and who holds it: the kernel (a cgroup) or this watch alone. */ | |
| 538 | readonly cap: MemoryCap; | |
| 517 | 539 | /** The tree's CPU ticks so far and resident KiB, at the last tick. */ |
| 518 | 540 | last?: { ticks: number; kib: number }; |
| 519 | 541 | } |
| @@ -522,6 +544,18 @@ const watched = new Set<Watched>(); | ||
| 522 | 544 | let timer: ReturnType<typeof setInterval> | undefined; |
| 523 | 545 | let nextKey = 1; |
| 524 | 546 | |
| 547 | /** A cell's memory cap, bytes, and who holds it: the kernel (`apps/cgroup.ts`) or the watch alone. */ | |
| 548 | export interface MemoryCap { | |
| 549 | readonly bytes: number; | |
| 550 | readonly by: "kernel" | "watch"; | |
| 551 | } | |
| 552 | ||
| 553 | /** Whether cells' memory caps are the kernel's here, or why they are watched only. */ | |
| 554 | export function memoryCapHolder(): { by: "kernel" } | { by: "watch"; reason: string } { | |
| 555 | const host = cgroupHost(); | |
| 556 | return host.ok ? { by: "kernel" } : { by: "watch", reason: host.reason }; | |
| 557 | } | |
| 558 | ||
| 525 | 559 | /** One labelled cell as its watch last read it: cumulative CPU and resident memory. */ |
| 526 | 560 | export interface CellCounter { |
| 527 | 561 | /** Unique per watched tree, so a rate never spans two processes. */ |
| @@ -641,6 +675,7 @@ function descendants(root: number): number[] { | ||
| 641 | 675 | function watchTree( |
| 642 | 676 | root: number, |
| 643 | 677 | limits: Limits, |
| 678 | cap: MemoryCap, | |
| 644 | 679 | writable: readonly string[], |
| 645 | 680 | label: string | undefined, |
| 646 | 681 | breach: (why: string) => void, |
| @@ -654,6 +689,7 @@ function watchTree( | ||
| 654 | 689 | ticks: 0, |
| 655 | 690 | key: nextKey++, |
| 656 | 691 | label, |
| 692 | cap, | |
| 657 | 693 | }; |
| 658 | 694 | watched.add(entry); |
| 659 | 695 | timer ??= setInterval(tick, WATCH.tickMs); |
| @@ -808,8 +844,8 @@ function tick(): void { | ||
| 808 | 844 | } |
| 809 | 845 | |
| 810 | 846 | /** The root process of every labelled cell being watched, for grouping the process table by what launched it (`system/view.ts`). */ |
| 811 | export function cellRoots(): { label: string; root: number }[] { | |
| 812 | const out: { label: string; root: number }[] = []; | |
| 813 | for (const e of watched) if (e.label !== undefined) out.push({ label: e.label, root: e.root }); | |
| 847 | export function cellRoots(): { label: string; root: number; cap: MemoryCap }[] { | |
| 848 | const out: { label: string; root: number; cap: MemoryCap }[] = []; | |
| 849 | for (const e of watched) if (e.label !== undefined) out.push({ label: e.label, root: e.root, cap: e.cap }); | |
| 814 | 850 | return out; |
| 815 | 851 | } |
src/apps/process.ts +3
| @@ -294,6 +294,9 @@ export async function runStep( | ||
| 294 | 294 | proc.exited, |
| 295 | 295 | ]); |
| 296 | 296 | if (why !== undefined) return err(why); |
| 297 | // The kernel's OOM kill at the cell's cap is a breach no watch tick saw. | |
| 298 | const kernel = code === 0 ? undefined : cell?.breach(); | |
| 299 | if (kernel !== undefined) return err(`${name} stopped: it ${kernel}`); | |
| 297 | 300 | if (code !== 0) { |
| 298 | 301 | const reason = recent.length > 0 ? ` — ${failureReason(recent)}` : ""; |
| 299 | 302 | return err(`${name} exited with code ${code}${reason}`); |
src/system/live.ts +2 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { cellRoots } from "../apps/contain.ts"; |
| 10 | 10 | import { HOST, readRaw, type Paths, type Raw } from "./proc.ts"; |
| 11 | import { buildSystem, forAgent, type SystemView } from "./view.ts"; | |
| 11 | import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts"; | |
| 12 | 12 | |
| 13 | 13 | const FRESH_MS = 1_000; |
| 14 | 14 | |
| @@ -16,7 +16,7 @@ export type Live = { | ||
| 16 | 16 | read(now?: number): SystemView; |
| 17 | 17 | }; |
| 18 | 18 | |
| 19 | export function createLive(options: { paths?: Paths; roots?: typeof cellRoots; self?: number } = {}): Live { | |
| 19 | export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number } = {}): Live { | |
| 20 | 20 | const paths = options.paths ?? HOST; |
| 21 | 21 | const roots = options.roots ?? cellRoots; |
| 22 | 22 | const self = options.self ?? process.pid; |
src/system/view.ts +17 −2
| @@ -15,6 +15,9 @@ export type Group = { | ||
| 15 | 15 | procs: number; |
| 16 | 16 | cpuCores: number | null; |
| 17 | 17 | rssBytes: number; |
| 18 | /** A cell's memory cap, bytes, and who holds it (`apps/contain.ts`); null for the forge and the host. */ | |
| 19 | capBytes: number | null; | |
| 20 | capBy: "kernel" | "watch" | null; | |
| 18 | 21 | oldestSeconds: number; |
| 19 | 22 | top: { pid: number; comm: string; cpuCores: number | null; rssBytes: number; ageSeconds: number }[]; |
| 20 | 23 | tests: string[]; |
| @@ -38,7 +41,7 @@ export type SystemView = { | ||
| 38 | 41 | duplicates: Duplicate[]; |
| 39 | 42 | }; |
| 40 | 43 | |
| 41 | export type Roots = readonly { label: string; root: number }[]; | |
| 44 | export type Roots = readonly { label: string; root: number; cap?: { bytes: number; by: "kernel" | "watch" } }[]; | |
| 42 | 45 | |
| 43 | 46 | const kindOf = (label: string): Group["kind"] => |
| 44 | 47 | label.startsWith("check:") ? "check" : label.startsWith("app:") ? "app" : label === "forge" ? "forge" : "host"; |
| @@ -88,12 +91,24 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self | ||
| 88 | 91 | const before = new Map((prev?.procs ?? []).map((p) => [p.pid, p])); |
| 89 | 92 | const where = groupOf(next.procs, roots, self); |
| 90 | 93 | const groups = new Map<string, Group>(); |
| 94 | const caps = new Map(roots.map((r) => [r.label, r.cap])); | |
| 91 | 95 | const files = new Map<string, Set<string>>(); |
| 92 | 96 | for (const p of next.procs) { |
| 93 | 97 | const label = where.get(p.pid) ?? "host"; |
| 94 | 98 | const g = |
| 95 | 99 | groups.get(label) ?? |
| 96 | { label, kind: kindOf(label), procs: 0, cpuCores: null, rssBytes: 0, oldestSeconds: 0, top: [], tests: [] }; | |
| 100 | { | |
| 101 | label, | |
| 102 | kind: kindOf(label), | |
| 103 | procs: 0, | |
| 104 | cpuCores: null, | |
| 105 | rssBytes: 0, | |
| 106 | capBytes: caps.get(label)?.bytes ?? null, | |
| 107 | capBy: caps.get(label)?.by ?? null, | |
| 108 | oldestSeconds: 0, | |
| 109 | top: [], | |
| 110 | tests: [], | |
| 111 | }; | |
| 97 | 112 | const old = before.get(p.pid); |
| 98 | 113 | const cpu = old && old.age <= p.age ? rate(old.ticks, p.ticks, seconds * TICKS_PER_SECOND) : null; |
| 99 | 114 | g.procs++; |
src/tui/screens/system.ts +5 −1
| @@ -40,6 +40,10 @@ export function duplicateLines(dups: SystemView["duplicates"], inner: number): s | ||
| 40 | 40 | ); |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | /** A cell's memory beside its cap and who holds it, as /system says it (#2410); the forge and the host have none. */ | |
| 44 | const capOf = (g: SystemView["groups"][number]): string => | |
| 45 | g.capBytes === null ? "" : `${bytes(g.rssBytes)} of ${bytes(g.capBytes)} ${g.capBy === "kernel" ? "kernel" : "watched"}`; | |
| 46 | ||
| 43 | 47 | export function systemLines(v: SystemView): string[] { |
| 44 | 48 | const inner = WIDTH - 6; |
| 45 | 49 | return [ |
| @@ -47,7 +51,7 @@ export function systemLines(v: SystemView): string[] { | ||
| 47 | 51 | `hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`} · memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"} · ${v.processes} processes`, |
| 48 | 52 | ...v.disks.map((d) => `${pad(d.mount, 20)} ${Math.round((d.usedBytes / Math.max(1, d.totalBytes)) * 100)}% of ${bytes(d.totalBytes)}`), |
| 49 | 53 | "", |
| 50 | ...v.groups.slice(0, GROUPS).map((g) => `${pad(g.label, 28)} ${pad(String(g.procs), 4, "right")} procs`), | |
| 54 | ...v.groups.slice(0, GROUPS).map((g) => `${pad(g.label, 24)} ${pad(String(g.procs), 4, "right")} procs ${capOf(g)}`.trimEnd()), | |
| 51 | 55 | "", |
| 52 | 56 | ...duplicateLines(v.duplicates, inner), |
| 53 | 57 | ]; |
src/web/pages/system.ts +14 −1
| @@ -13,6 +13,7 @@ import { drawChart } from "../../boards/chart.ts"; | ||
| 13 | 13 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 14 | 14 | import { telemetryReader } from "../../actions/telemetry.ts"; |
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | import { memoryCapHolder } from "../../apps/contain.ts"; | |
| 16 | 17 | import { bytes, span } from "../../system/format.ts"; |
| 17 | 18 | import { live } from "../../system/live.ts"; |
| 18 | 19 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -20,6 +21,12 @@ import { html, raw, type Html } from "../html.ts"; | ||
| 20 | 21 | import { page } from "../layout.ts"; |
| 21 | 22 | import { card, empty } from "../ui.ts"; |
| 22 | 23 | |
| 24 | /** A group's memory, and a cell's beside its cap and who holds the cap. */ | |
| 25 | const memoryOf = (g: { rssBytes: number; capBytes: number | null; capBy: "kernel" | "watch" | null }): Html => | |
| 26 | g.capBytes === null | |
| 27 | ? html`${bytes(g.rssBytes)}` | |
| 28 | : html`${bytes(g.rssBytes)} <span class="muted small" data-cap="${String(g.capBytes)}" data-cap-by="${g.capBy ?? ""}">of ${bytes(g.capBytes)} (${g.capBy === "kernel" ? "kernel cap" : "watched"})</span>`; | |
| 29 | ||
| 23 | 30 | const pct = (n: number | null): string => (n === null ? "—" : `${n.toFixed(1)}%`); |
| 24 | 31 | const coresOf = (n: number | null): string => (n === null ? "—" : n.toFixed(2)); |
| 25 | 32 | |
| @@ -62,6 +69,11 @@ export function systemPage(ctx: Ctx): Response { | ||
| 62 | 69 | <ul class="list small mono"> |
| 63 | 70 | ${v.duplicates.map((d) => html`<li>${d.file} · ${String(d.checks.length)} checks: ${d.checks.join(", ")}</li>`)} |
| 64 | 71 | </ul>`; |
| 72 | const holder = memoryCapHolder(); | |
| 73 | const caps: Html = | |
| 74 | holder.by === "kernel" | |
| 75 | ? html`<p class="small" id="cell-caps" data-by="kernel">Each cell's memory is capped by the kernel (its own cgroup's <code>memory.max</code>): a cell over it is killed alone.</p>` | |
| 76 | : html`<p class="small" id="cell-caps" data-by="watch">Each cell's memory is watched once a second and the cell stopped over its cap; the kernel does not cap it (${holder.reason}).</p>`; | |
| 65 | 77 | const cores = v.cpu.cores.map( |
| 66 | 78 | (c, i) => html`<span class="mono small" title="cpu${String(i)}">${c === null ? "—" : String(Math.round(c))}</span> `, |
| 67 | 79 | ); |
| @@ -85,6 +97,7 @@ export function systemPage(ctx: Ctx): Response { | ||
| 85 | 97 | ${String(v.processes)} processes |
| 86 | 98 | </p> |
| 87 | 99 | <p class="small" id="cores" aria-label="Per-core CPU, percent">${cores}</p> |
| 100 | ${caps} | |
| 88 | 101 | <p class="small mono" id="temps">${v.temps.map((t) => `${t.chip} ${t.label} ${t.celsius.toFixed(0)}°C`).join(" · ")}</p> |
| 89 | 102 | </div>`, |
| 90 | 103 | { head: html`<h2>Machine</h2>` }, |
| @@ -111,7 +124,7 @@ export function systemPage(ctx: Ctx): Response { | ||
| 111 | 124 | <tbody> |
| 112 | 125 | ${v.groups.map( |
| 113 | 126 | (g) => html`<tr data-kind="${g.kind}"><td class="mono">${g.label}</td><td>${String(g.procs)}</td> |
| 114 | <td>${coresOf(g.cpuCores)}</td><td>${bytes(g.rssBytes)}</td><td>${span(g.oldestSeconds)}</td> | |
| 127 | <td>${coresOf(g.cpuCores)}</td><td>${memoryOf(g)}</td><td>${span(g.oldestSeconds)}</td> | |
| 115 | 128 | <td class="small mono">${g.top.slice(0, 3).map((p) => `${p.comm} ${coresOf(p.cpuCores)}`).join(" · ")}</td></tr>`, |
| 116 | 129 | )} |
| 117 | 130 | </tbody></table></div>`, |
test/cgroup.test.ts added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | /** | |
| 2 | * A check never takes the forge down (#2410): each cell runs in a cgroup of | |
| 3 | * its own whose `memory.max` is the cell's memory limit, so a check that | |
| 4 | * allocates past it is killed by the kernel, alone, between two ticks of | |
| 5 | * the watch. The layout is proven on a directory shaped like cgroupfs; the | |
| 6 | * kernel's kill end to end where this host delegates a cgroup to the forge | |
| 7 | * (it skips, and says why, where it does not — a crew sandbox's is | |
| 8 | * read-only). | |
| 9 | */ | |
| 10 | ||
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { | |
| 16 | cgroupHost, | |
| 17 | cgroupRoot, | |
| 18 | enterArgv, | |
| 19 | oomKills, | |
| 20 | openCellCgroup, | |
| 21 | prepare, | |
| 22 | } from "../src/apps/cgroup.ts"; | |
| 23 | import { cellsAvailable, type CellSpec } from "../src/apps/contain.ts"; | |
| 24 | import { childEnv, runStep } from "../src/apps/process.ts"; | |
| 25 | import type { Proc, Raw } from "../src/system/proc.ts"; | |
| 26 | import { buildSystem } from "../src/system/view.ts"; | |
| 27 | ||
| 28 | describe("which cgroup is the forge's to divide", () => { | |
| 29 | test("off, a relative or climbing path, cgroup v1 and a shared login session are refused", () => { | |
| 30 | expect(cgroupRoot("off").ok).toBe(false); | |
| 31 | expect(cgroupRoot("sys/fs/cgroup").ok).toBe(false); | |
| 32 | expect(cgroupRoot("/sys/fs/cgroup/../../etc").ok).toBe(false); | |
| 33 | expect(cgroupRoot("", () => "12:memory:/x\n11:cpu:/x\n").ok).toBe(false); | |
| 34 | const session = cgroupRoot("", () => "0::/user.slice/user-1000.slice/session-2.scope\n"); | |
| 35 | expect(session.ok).toBe(false); | |
| 36 | if (!session.ok) expect(session.reason).toContain("FORGE_CELL_CGROUP"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a container's own cgroup namespace root, or the one named", () => { | |
| 40 | if (process.platform === "linux") | |
| 41 | expect(cgroupRoot("", () => "0::/\n")).toEqual({ ok: true, root: "/sys/fs/cgroup" }); | |
| 42 | expect(cgroupRoot("/sys/fs/cgroup/forge.slice")).toEqual({ ok: true, root: "/sys/fs/cgroup/forge.slice" }); | |
| 43 | }); | |
| 44 | }); | |
| 45 | ||
| 46 | describe("the layout, on a directory shaped like cgroupfs", () => { | |
| 47 | let root: string; | |
| 48 | beforeAll(() => { | |
| 49 | root = mkdtempSync(join(tmpdir(), "forge-cgroup-")); | |
| 50 | }); | |
| 51 | afterAll(() => rmSync(root, { recursive: true, force: true })); | |
| 52 | ||
| 53 | const fake = (name: string, controllers: string): string => { | |
| 54 | const dir = join(root, name); | |
| 55 | mkdirSync(dir); | |
| 56 | writeFileSync(join(dir, "cgroup.controllers"), controllers); | |
| 57 | writeFileSync(join(dir, "cgroup.procs"), "11\n12\n"); | |
| 58 | writeFileSync(join(dir, "cgroup.subtree_control"), ""); | |
| 59 | return dir; | |
| 60 | }; | |
| 61 | ||
| 62 | test("no memory controller: watched only, and why", () => { | |
| 63 | const dir = fake("nomem", "cpu io\n"); | |
| 64 | const host = prepare({ ok: true, root: dir }); | |
| 65 | expect(host.ok).toBe(false); | |
| 66 | if (!host.ok) expect(host.reason).toContain("no memory controller"); | |
| 67 | expect(openCellCgroup(64, host)).toBeUndefined(); | |
| 68 | }); | |
| 69 | ||
| 70 | test.skipIf(process.getuid?.() === 0)("read-only to the forge: watched only, and why", () => { | |
| 71 | const dir = fake("ro", "cpu memory\n"); | |
| 72 | for (const f of ["cgroup.procs", "cgroup.subtree_control"]) Bun.spawnSync(["chmod", "0444", join(dir, f)]); | |
| 73 | const host = prepare({ ok: true, root: dir }); | |
| 74 | expect(host.ok).toBe(false); | |
| 75 | if (!host.ok) expect(host.reason).toContain("read-only"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the forge's processes move to a leaf, cells get the memory controller, and a crash's leftovers go", () => { | |
| 79 | const dir = fake("rw", "cpuset cpu memory pids\n"); | |
| 80 | // A forge that is gone left one; a forge still draining a roll (this process) keeps its own. | |
| 81 | const gone = Bun.spawnSync(["/bin/true"]).pid; | |
| 82 | mkdirSync(join(dir, "cells", `c7-${gone}`), { recursive: true }); | |
| 83 | mkdirSync(join(dir, "cells", `c8-${process.pid}`), { recursive: true }); | |
| 84 | const host = prepare({ ok: true, root: dir }); | |
| 85 | expect(host).toEqual({ ok: true, root: dir }); | |
| 86 | // One pid a write: the last one written is what the plain file holds. | |
| 87 | expect(readFileSync(join(dir, "forge", "cgroup.procs"), "utf8")).toBe("12"); | |
| 88 | expect(readFileSync(join(dir, "cgroup.subtree_control"), "utf8")).toBe("+memory"); | |
| 89 | expect(readFileSync(join(dir, "cells", "cgroup.subtree_control"), "utf8")).toBe("+memory"); | |
| 90 | expect(existsSync(join(dir, "cells", `c7-${gone}`))).toBe(false); | |
| 91 | expect(existsSync(join(dir, "cells", `c8-${process.pid}`))).toBe(true); | |
| 92 | // Already laid out (cgroupfs reads back `memory`): nothing moves again. | |
| 93 | writeFileSync(join(dir, "cgroup.subtree_control"), "memory\n"); | |
| 94 | writeFileSync(join(dir, "forge", "cgroup.procs"), ""); | |
| 95 | expect(prepare({ ok: true, root: dir }).ok).toBe(true); | |
| 96 | expect(readFileSync(join(dir, "forge", "cgroup.procs"), "utf8")).toBe(""); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a cell's cgroup is capped at its limit, swapless, killed whole; its OOM kills read back", () => { | |
| 100 | const dir = fake("cell", "memory\n"); | |
| 101 | const host = prepare({ ok: true, root: dir }); | |
| 102 | const cell = openCellCgroup(8192, host); | |
| 103 | expect(cell).toBeDefined(); | |
| 104 | expect(cell!.dir.startsWith(join(dir, "cells", "c"))).toBe(true); | |
| 105 | expect(cell!.maxBytes).toBe(8192 * 1024 * 1024); | |
| 106 | expect(readFileSync(join(cell!.dir, "memory.max"), "utf8")).toBe("8589934592"); | |
| 107 | // The kernel makes these files; here, a kernel that has them. | |
| 108 | writeFileSync(join(cell!.dir, "memory.events"), "low 0\nhigh 0\nmax 9\noom 1\noom_kill 1\noom_group_kill 1\n"); | |
| 109 | expect(cell!.oomKills()).toBe(1); | |
| 110 | expect(openCellCgroup(64, host)!.dir).not.toBe(cell!.dir); | |
| 111 | }); | |
| 112 | ||
| 113 | test("oom_kill is read from memory.events, and absent is none", () => { | |
| 114 | expect(oomKills("oom 0\noom_kill 0\n")).toBe(0); | |
| 115 | expect(oomKills("oom 3\noom_kill 2\n")).toBe(2); | |
| 116 | expect(oomKills(undefined)).toBe(0); | |
| 117 | expect(oomKills("oom_kill_x 5\n")).toBe(0); | |
| 118 | }); | |
| 119 | }); | |
| 120 | ||
| 121 | describe("entering the cgroup before the cell starts", () => { | |
| 122 | test("the process writes its own pid, then becomes the command — same pid, so all it starts is inside", async () => { | |
| 123 | const dir = mkdtempSync(join(tmpdir(), "forge-cgroup-enter-")); | |
| 124 | try { | |
| 125 | const procs = join(dir, "cgroup.procs"); | |
| 126 | writeFileSync(procs, ""); | |
| 127 | const proc = Bun.spawn(enterArgv(procs, ["/bin/sh", "-c", "echo $$"]), { stdout: "pipe" }); | |
| 128 | const out = (await new Response(proc.stdout).text()).trim(); | |
| 129 | expect(await proc.exited).toBe(0); | |
| 130 | expect(out).toBe(String(proc.pid)); | |
| 131 | expect(readFileSync(procs, "utf8").trim()).toBe(String(proc.pid)); | |
| 132 | } finally { | |
| 133 | rmSync(dir, { recursive: true, force: true }); | |
| 134 | } | |
| 135 | }); | |
| 136 | ||
| 137 | test("a cgroup it cannot enter: it refuses to run uncapped, and the path is never script", async () => { | |
| 138 | const dir = mkdtempSync(join(tmpdir(), "forge-cgroup-enter-")); | |
| 139 | try { | |
| 140 | const marker = join(dir, "ran"); | |
| 141 | const procs = join(dir, "gone", `$(touch ${join(dir, "injected")})`, "cgroup.procs"); | |
| 142 | const proc = Bun.spawn(enterArgv(procs, ["/bin/sh", "-c", `touch ${marker}`]), { stderr: "pipe" }); | |
| 143 | const text = await new Response(proc.stderr).text(); | |
| 144 | expect(await proc.exited).toBe(125); | |
| 145 | expect(text).toContain("cannot enter its memory cgroup"); | |
| 146 | expect(existsSync(marker)).toBe(false); | |
| 147 | expect(existsSync(join(dir, "injected"))).toBe(false); | |
| 148 | } finally { | |
| 149 | rmSync(dir, { recursive: true, force: true }); | |
| 150 | } | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | describe("/system: a check cell's memory beside its cap", () => { | |
| 155 | test("a cell's group carries its cap and who holds it; the forge's carries none", () => { | |
| 156 | const proc = (pid: number, ppid: number, rss: number): Proc => ({ pid, ppid, comm: "bun", ticks: 0, rss, age: 5, tests: [] }); | |
| 157 | const raw: Raw = { | |
| 158 | at: 1_000, | |
| 159 | cores: [], | |
| 160 | temps: [], | |
| 161 | load: null, | |
| 162 | memory: null, | |
| 163 | disks: [], | |
| 164 | net: [], | |
| 165 | procs: [proc(1, 0, 100), proc(200, 1, 1 << 20), proc(201, 200, 3 << 20)], | |
| 166 | }; | |
| 167 | const v = buildSystem(undefined, raw, [ | |
| 168 | { label: "check:RUN1", root: 200, cap: { bytes: 8192 * 2 ** 20, by: "kernel" } }, | |
| 169 | ], 1); | |
| 170 | const check = v.groups.find((g) => g.label === "check:RUN1")!; | |
| 171 | expect(check.rssBytes).toBe(4 << 20); | |
| 172 | expect(check.capBytes).toBe(8589934592); | |
| 173 | expect(check.capBy).toBe("kernel"); | |
| 174 | const forge = v.groups.find((g) => g.label === "forge")!; | |
| 175 | expect(forge.capBytes).toBeNull(); | |
| 176 | expect(forge.capBy).toBeNull(); | |
| 177 | }); | |
| 178 | }); | |
| 179 | ||
| 180 | // The kernel's own kill, on a host that delegates a cgroup to the forge and holds cells. | |
| 181 | const host = cgroupHost(); | |
| 182 | const cells = await cellsAvailable(); | |
| 183 | const why = !host.ok ? host.reason : !cells.ok ? cells.reason : undefined; | |
| 184 | if (why !== undefined) console.log(`cgroup.test.ts: the kernel's kill is skipped here: ${why}`); | |
| 185 | ||
| 186 | describe.skipIf(why !== undefined)("a check over its memory cap fails alone; the forge stays up", () => { | |
| 187 | let dir: string; | |
| 188 | beforeAll(() => { | |
| 189 | dir = mkdtempSync(join(tmpdir(), "forge-cgroup-e2e-")); | |
| 190 | }); | |
| 191 | afterAll(() => rmSync(dir, { recursive: true, force: true })); | |
| 192 | ||
| 193 | const cell = (name: string, memoryMiB: number): CellSpec => ({ | |
| 194 | dir: join(dir, name), | |
| 195 | writable: [], | |
| 196 | home: join(dir, name, ".home"), | |
| 197 | hide: [], | |
| 198 | egress: [], | |
| 199 | reach: [], | |
| 200 | // CPU and disk out of the way: only memory decides. | |
| 201 | limits: { memoryMiB, cpus: 64, diskMiB: 4096 }, | |
| 202 | }); | |
| 203 | const step = (command: string, spec: CellSpec) => | |
| 204 | runStep("check", command, { | |
| 205 | cwd: spec.dir, | |
| 206 | env: childEnv({}), | |
| 207 | log: () => {}, | |
| 208 | signal: new AbortController().signal, | |
| 209 | timeoutMs: 60_000, | |
| 210 | cell: spec, | |
| 211 | }); | |
| 212 | ||
| 213 | test("1 GiB allocated in a burst under a 96 MiB cap: the kernel kills that check, its neighbour finishes", async () => { | |
| 214 | mkdirSync(join(dir, "hog"), { recursive: true }); | |
| 215 | mkdirSync(join(dir, "calm"), { recursive: true }); | |
| 216 | const before = process.memoryUsage().rss; | |
| 217 | const [hog, calm] = await Promise.all([ | |
| 218 | // Faster than the watch's one-second tick: only the kernel can stop it in time. | |
| 219 | step(`bun -e "const a = []; for (let i = 0; i < 64; i++) a.push(new Uint8Array(16 << 20).fill(1)); console.log(a.length)"`, cell("hog", 96)), | |
| 220 | step(`bun -e "const a = new Uint8Array(16 << 20).fill(1); await Bun.sleep(1500); console.log(a[0])"`, cell("calm", 256)), | |
| 221 | ]); | |
| 222 | expect(hog.ok).toBe(false); | |
| 223 | if (!hog.ok) | |
| 224 | expect(hog.error).toBe("check stopped: it reached its 96 MiB memory cap, and the kernel killed it"); | |
| 225 | expect(calm.ok).toBe(true); | |
| 226 | // The forge (this process) is alive and did not pay for the hog. | |
| 227 | expect(process.memoryUsage().rss - before).toBeLessThan(256 * 2 ** 20); | |
| 228 | }, 60_000); | |
| 229 | }); |
test/contain.test.ts +2 −1
| @@ -888,7 +888,8 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => { | ||
| 888 | 888 | expect(result.ok).toBe(false); |
| 889 | 889 | if (!result.ok) |
| 890 | 890 | expect(result.error).toMatch( |
| 891 | /^check stopped: it used \d+ MiB of memory, over its 128 MiB limit$/, | |
| 891 | // The watch's tick, or — where the forge holds a delegated cgroup — the kernel's cap first. | |
| 892 | /^check stopped: it (used \d+ MiB of memory, over its 128 MiB limit|reached its 128 MiB memory cap, and the kernel killed it)$/, | |
| 892 | 893 | ); |
| 893 | 894 | }, 30_000); |
| 894 | 895 |
test/shards.test.ts +38
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | SHARDS_MAX, |
| 13 | 13 | WORKER_MEMORY_BYTES, |
| 14 | 14 | CELL_CPU_SHARE, |
| 15 | FILES_PER_PROCESS, | |
| 15 | 16 | formatTimes, |
| 16 | 17 | heaviestFirst, |
| 17 | 18 | memoryLimit, |
| @@ -23,6 +24,7 @@ import { | ||
| 23 | 24 | shardCountFromEnv, |
| 24 | 25 | sliceFiles, |
| 25 | 26 | splitArgs, |
| 27 | splitCapped, | |
| 26 | 28 | splitFiles, |
| 27 | 29 | tally, |
| 28 | 30 | weigher, |
| @@ -67,6 +69,42 @@ describe("splitFiles", () => { | ||
| 67 | 69 | }); |
| 68 | 70 | }); |
| 69 | 71 | |
| 72 | describe("splitCapped: no bun test holds more than FILES_PER_PROCESS files (#2410)", () => { | |
| 73 | const many = Array.from({ length: 330 }, (_, i) => `test/m${String(i).padStart(3, "0")}.test.ts`); | |
| 74 | ||
| 75 | test("the fast tier's ~110 files a process, at 3 shards, become slices of at most 40", () => { | |
| 76 | expect(FILES_PER_PROCESS).toBe(40); | |
| 77 | const slices = splitCapped(many, 3); | |
| 78 | // 330 / 40 = 8.25, so at least 9 slices; the old split made 3 of 110. | |
| 79 | expect(splitFiles(many, 3).map((s) => s.length)).toEqual([110, 110, 110]); | |
| 80 | expect(slices.length).toBeGreaterThanOrEqual(9); | |
| 81 | for (const slice of slices) { | |
| 82 | expect(slice.length).toBeGreaterThan(0); | |
| 83 | expect(slice.length).toBeLessThanOrEqual(40); | |
| 84 | } | |
| 85 | const all = slices.flat(); | |
| 86 | expect(all.length).toBe(many.length); | |
| 87 | expect([...all].sort()).toEqual(many); | |
| 88 | }); | |
| 89 | ||
| 90 | test("many light files on one heavy-balanced slice are cut, in order", () => { | |
| 91 | // One heavy file takes a slice alone; the other 99 share the other and are cut in runs of 40. | |
| 92 | const weight = (file: string) => (file === "test/m000.test.ts" ? 1_000 : 1); | |
| 93 | const list = many.slice(0, 100); | |
| 94 | const slices = splitCapped(list, 2, weight, 40); | |
| 95 | for (const slice of slices) expect(slice.length).toBeLessThanOrEqual(40); | |
| 96 | expect(slices.find((s) => s.includes("test/m000.test.ts"))!.length).toBeLessThanOrEqual(40); | |
| 97 | expect(slices.flat().sort()).toEqual(list); | |
| 98 | for (const slice of slices) expect(slice).toEqual(list.filter((f) => slice.includes(f))); | |
| 99 | }); | |
| 100 | ||
| 101 | test("under the cap it is splitFiles exactly", () => { | |
| 102 | const weight = (file: string) => (Number(file.match(/\d+/)![0]) * 37) % 11; | |
| 103 | expect(splitCapped(files, 3, weight)).toEqual(splitFiles(files, 3, weight)); | |
| 104 | expect(splitCapped(["only"], 8)).toEqual([["only"]]); | |
| 105 | }); | |
| 106 | }); | |
| 107 | ||
| 70 | 108 | describe("weighing by measured time", () => { |
| 71 | 109 | // Four files of one size: bytes cannot tell them apart, the table can. |
| 72 | 110 | const list = [ |
test/system-telemetry.test.ts +9 −2
| @@ -193,7 +193,7 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 193 | 193 | createLive({ |
| 194 | 194 | paths: { proc: join(host, "proc"), sys: join(host, "sys") }, |
| 195 | 195 | roots: () => [ |
| 196 | { label: "check:RUN1", root: 200 }, | |
| 196 | { label: "check:RUN1", root: 200, cap: { bytes: 8192 * 2 ** 20, by: "kernel" } }, | |
| 197 | 197 | { label: "check:RUN2", root: 300 }, |
| 198 | 198 | { label: "check:RUN3", root: 400 }, |
| 199 | 199 | ], |
| @@ -299,6 +299,9 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 299 | 299 | const html = await page.text(); |
| 300 | 300 | expect(html).toContain('id="duplicates" data-count="1"'); |
| 301 | 301 | expect(html).toContain("test/shared.test.ts"); |
| 302 | // Each check cell's memory beside its cap, and who holds the cap (#2410). | |
| 303 | expect(html).toContain('data-cap="8589934592" data-cap-by="kernel">of 8.0 GiB (kernel cap)'); | |
| 304 | expect(html).toMatch(/id="cell-caps" data-by="(kernel|watch)"/); | |
| 302 | 305 | const flowPage = await fetch(`${servers.url}/flow`, { headers: { cookie: await login("alice") } }); |
| 303 | 306 | expect(await flowPage.text()).toContain('id="duplicate-work" data-count="1"'); |
| 304 | 307 | |
| @@ -409,7 +412,11 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 409 | 412 | const sys = run(alice, "system"); |
| 410 | 413 | sys.cmd!.run(); |
| 411 | 414 | expect(sys.pushed.map((x) => x.title)).toEqual(["System"]); |
| 412 | expect(systemLines(JSON.parse(JSON.stringify(live().read()))).join("\n")).toContain("1 test file is running in more than one check"); | |
| 415 | const lines = systemLines(JSON.parse(JSON.stringify(live().read()))); | |
| 416 | expect(lines.join("\n")).toContain("1 test file is running in more than one check"); | |
| 417 | // A check cell's memory beside its cap and who holds it, as /system says it (#2410). | |
| 418 | expect(lines.find((l) => l.startsWith("check:RUN1"))).toMatch(/procs {2}\S+ \S+ of 8\.0 GiB kernel$/); | |
| 419 | expect(lines.every((l) => l.length <= 66)).toBe(true); | |
| 413 | 420 | |
| 414 | 421 | expect(flowLines(app.db, new Date(), JSON.parse(JSON.stringify(live().read().duplicates))).join("\n")).toContain( |
| 415 | 422 | "1 test file is running in more than one check", |
new above
offline
current()has no caller in src. Remove it, or surface it on /system.