Public: anyone can read this; the crew can change it.
A child forge's disk, memory, CPU and process limits are enforced by the kernel; a 50G fallocate on a small child must fail #10855
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10850-a-child-forge-s-disk-memory-cpu-and-proc → main 9 commits fork
24 files
changed +468
−18
docs/APPS.md +18
| @@ -623,6 +623,24 @@ never exhausted (#10518). Crew runs get no process cap, as before. | ||
| 623 | 623 | /system says beside the memory line who holds the process count, and why |
| 624 | 624 | when it is the watch alone. |
| 625 | 625 | |
| 626 | Every cell also starts under kernel rlimits, with or without a cgroup: its | |
| 627 | first shell sets, soft and hard, `RLIMIT_FSIZE` to the cell's disk (a small | |
| 628 | child forge: 2048 MiB, so `fallocate -l 50G` or a runaway `dd` fails at | |
| 629 | once with SIGXFSZ), `RLIMIT_NOFILE` 8192 and `RLIMIT_NPROC` 4096 tasks, | |
| 630 | counted per user namespace (`cellRlimits`, `RLIMIT_LINE` in | |
| 631 | `apps/contain.ts`). The file cap bounds one file, not their sum: the total | |
| 632 | is the watch's disk walk until a child's writable space is a filesystem of | |
| 633 | its own (#10850). A cell's hostname is its own: a child forge's cell | |
| 634 | answers `uname -n` with the child's name, a machine shell with the | |
| 635 | machine's, anything else with `cell`, never the pod's — where the host | |
| 636 | lets bwrap set it: the cell probe asks once (`hostnameArgs`), and a host | |
| 637 | that refuses `sethostname` runs its cells under the pod's name rather | |
| 638 | than not at all. | |
| 639 | ||
| 640 | The parent keeps 15% of its own volume (`spawn/disk_floor.ts`): below it | |
| 641 | no child forge is made or started (a sleeping one stays asleep), the New | |
| 642 | forge page and menu say why, and vitals carries the `disk-floor` line. | |
| 643 | ||
| 626 | 644 | On a Kubernetes pod the forge is usually not at a cgroup namespace root |
| 627 | 645 | (`/proc/self/cgroup` is not `0::/`) and `/sys/fs/cgroup` is read-only, so |
| 628 | 646 | cells are watched only. The operator delegates one: run the container in |
src/apps/contain.ts +83 −2
| @@ -238,6 +238,8 @@ export interface CellSpec { | ||
| 238 | 238 | readonly limits: Limits; |
| 239 | 239 | /** Read-only paths to keep visible when they sit under a hidden directory (a forge's own code). */ |
| 240 | 240 | readonly reveal?: readonly string[] | undefined; |
| 241 | /** The name the cell's processes see as the host's (`cellHostname`); "cell" when unset. */ | |
| 242 | readonly hostname?: string | undefined; | |
| 241 | 243 | /** The port an app listens on; the forge relays `127.0.0.1:<port>` in. */ |
| 242 | 244 | readonly listen?: number | undefined; |
| 243 | 245 | /** Keep the controlling terminal (an app's `tty`). */ |
| @@ -332,7 +334,24 @@ async function probe(): Promise<{ ok: true } | { ok: false; reason: string }> { | ||
| 332 | 334 | for (const tool of ["bwrap", "socat"]) |
| 333 | 335 | if (Bun.which(tool) === null) |
| 334 | 336 | return { ok: false, reason: `${tool} is not installed` }; |
| 335 | return askUntilBelieved(tryOnce); | |
| 337 | const verdict = await askUntilBelieved(() => tryOnce()); | |
| 338 | // Some hosts hold a cell but refuse its sethostname (EPERM from bwrap's | |
| 339 | // `--hostname`, seen on the check machine): asked once, after the cell is | |
| 340 | // believed, so a refused name costs the cell nothing (#10850). | |
| 341 | if (verdict.ok) hostnameAllowed = (await tryOnce(["--hostname", "cell"])).ok; | |
| 342 | return verdict; | |
| 343 | } | |
| 344 | ||
| 345 | /** Whether this host lets a cell set its own hostname; false until `cellsAvailable` has asked. */ | |
| 346 | let hostnameAllowed = false; | |
| 347 | ||
| 348 | /** | |
| 349 | * The `--hostname` bwrap gets: the cell's own name (`cellHostname`) where | |
| 350 | * the host allows it, nothing where it does not — the cell then keeps the | |
| 351 | * name it was born with, the pod's, rather than not starting at all. | |
| 352 | */ | |
| 353 | export function hostnameArgs(name: string | undefined, allowed: boolean = hostnameAllowed): string[] { | |
| 354 | return allowed ? ["--hostname", cellHostname(name)] : []; | |
| 336 | 355 | } |
| 337 | 356 | |
| 338 | 357 | type Verdict = { ok: true } | { ok: false; reason: string }; |
| @@ -359,13 +378,14 @@ const PROBE_TRIES = 3; | ||
| 359 | 378 | const PROBE_PAUSE_MS = 500; |
| 360 | 379 | const PROBE_TIMEOUT_MS = 10_000; |
| 361 | 380 | |
| 362 | async function tryOnce(): Promise<Verdict> { | |
| 381 | async function tryOnce(extra: readonly string[] = []): Promise<Verdict> { | |
| 363 | 382 | let timedOut = false; |
| 364 | 383 | try { |
| 365 | 384 | const proc = Bun.spawn( |
| 366 | 385 | [ |
| 367 | 386 | "bwrap", |
| 368 | 387 | ...NAMESPACES, |
| 388 | ...extra, | |
| 369 | 389 | "--ro-bind", |
| 370 | 390 | "/", |
| 371 | 391 | "/", |
| @@ -422,6 +442,61 @@ const NAMESPACES = [ | ||
| 422 | 442 | /** The size of a cell's own `/tmp`, MiB. */ |
| 423 | 443 | export const TMP_MIB = 256; |
| 424 | 444 | |
| 445 | /** Open files a process in a cell may hold (`RLIMIT_NOFILE`): sockets count. */ | |
| 446 | export const FILES_MAX = 8192; | |
| 447 | /** | |
| 448 | * Tasks the cell's user may run (`RLIMIT_NPROC`), threads included: the | |
| 449 | * kernel counts them per user namespace (since 5.14, ucounts), so this is | |
| 450 | * the cell's own count. Above `PROCESSES_MAX`, which counts processes, since | |
| 451 | * a Bun process runs a dozen threads; where pids is delegated `pids.max` is | |
| 452 | * the tighter one. | |
| 453 | */ | |
| 454 | export const TASKS_MAX = 4096; | |
| 455 | ||
| 456 | /** | |
| 457 | * The kernel's limits every process in a cell starts under, set by the | |
| 458 | * cell's first shell with `ulimit` (both soft and hard, so nothing inside | |
| 459 | * raises them; a hard limit already lower stays lower): | |
| 460 | * | |
| 461 | * - `fileBlocks`, `RLIMIT_FSIZE` in 512-byte blocks (dash's `ulimit -f` | |
| 462 | * unit): no file grows past the cell's disk, so one `fallocate -l 50G` | |
| 463 | * or `dd` on a small cell fails with SIGXFSZ / EFBIG at once, not at the | |
| 464 | * watch's next disk walk. It caps a file, not the sum of them: the total | |
| 465 | * is still the watch's (#10850). | |
| 466 | * - `files`, `RLIMIT_NOFILE`, and `tasks`, `RLIMIT_NPROC`. | |
| 467 | */ | |
| 468 | export function cellRlimits(limits: Pick<Limits, "diskMiB">): { fileBlocks: number; files: number; tasks: number } { | |
| 469 | const blocks = Number.isFinite(limits.diskMiB) && limits.diskMiB > 0 ? Math.ceil(limits.diskMiB * 2048) : undefined; | |
| 470 | return { fileBlocks: blocks ?? Number.MAX_SAFE_INTEGER, files: FILES_MAX, tasks: TASKS_MAX }; | |
| 471 | } | |
| 472 | ||
| 473 | /** | |
| 474 | * The name a cell answers `uname -n` and a prompt's `\h` with: its own, | |
| 475 | * never the pod's (the UTS namespace is unshared). Lowercased to a DNS | |
| 476 | * label (RFC 1123: letters, digits, hyphens, at most 63); "cell" when | |
| 477 | * nothing usable is left. | |
| 478 | */ | |
| 479 | export function cellHostname(name: string | undefined): string { | |
| 480 | const label = (name ?? "") | |
| 481 | .toLowerCase() | |
| 482 | .replace(/[^a-z0-9-]+/g, "-") | |
| 483 | .replace(/^-+|-+$/g, "") | |
| 484 | .slice(0, 63) | |
| 485 | .replace(/-+$/, ""); | |
| 486 | return label === "" ? "cell" : label; | |
| 487 | } | |
| 488 | ||
| 489 | /** | |
| 490 | * The line the cell's first shell runs with `cellRlimits`' three figures | |
| 491 | * as `$1 $2 $3`, then drops them. Arguments, never text spliced in. | |
| 492 | * Processes are dash's `-p` and bash's `-u` (bash's `-p` is the pipe size, | |
| 493 | * read-only, so its refusal is silent); `-f` counts 512-byte blocks in dash, | |
| 494 | * the image's `/bin/sh`, and 1024-byte ones in bash, where a file's cap is | |
| 495 | * then twice the disk: still finite. | |
| 496 | */ | |
| 497 | export const RLIMIT_LINE = | |
| 498 | "ulimit -f $1 2>/dev/null; ulimit -n $2 2>/dev/null; ulimit -u $3 2>/dev/null || ulimit -p $3 2>/dev/null; shift 3"; | |
| 499 | ||
| 425 | 500 | /** The proxy's port inside every cell. */ |
| 426 | 501 | const PROXY_PORT = 3128; |
| 427 | 502 | const INSIDE = "/run/forge"; |
| @@ -435,6 +510,7 @@ const INSIDE = "/run/forge"; | ||
| 435 | 510 | * forge tells a busy app from an idle one (`apps/busy.ts`). |
| 436 | 511 | */ |
| 437 | 512 | const WRAPPER = `s=${INSIDE}; listen=$1; shift; loops=$1; shift |
| 513 | ${RLIMIT_LINE} | |
| 438 | 514 | (socat TCP-LISTEN:${PROXY_PORT},bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:$s/out.sock >/dev/null 2>&1 &) |
| 439 | 515 | for p in $loops; do (socat TCP-LISTEN:$p,bind=127.0.0.1,fork,reuseaddr UNIX-CONNECT:$s/loop-$p.sock >/dev/null 2>&1 &); done |
| 440 | 516 | [ "$listen" = - ] || (socat UNIX-LISTEN:$s/in.sock,fork,unlink-early TCP:127.0.0.1:$listen >/dev/null 2>&1 &) |
| @@ -567,9 +643,11 @@ export function openCell( | ||
| 567 | 643 | const inner = bwrapArgv(command); |
| 568 | 644 | return cgroup === undefined ? inner : enterArgv(join(cgroup.dir, "cgroup.procs"), inner); |
| 569 | 645 | }; |
| 646 | const rlimits = cellRlimits(spec.limits); | |
| 570 | 647 | const bwrapArgv = (command: string): string[] => [ |
| 571 | 648 | "bwrap", |
| 572 | 649 | ...NAMESPACES, |
| 650 | ...hostnameArgs(spec.hostname), | |
| 573 | 651 | ...(spec.terminal === true ? [] : ["--new-session"]), |
| 574 | 652 | "--ro-bind", "/", "/", |
| 575 | 653 | "--size", String(TMP_MIB * 1024 * 1024), "--tmpfs", "/tmp", |
| @@ -594,6 +672,9 @@ export function openCell( | ||
| 594 | 672 | "cell", |
| 595 | 673 | spec.listen === undefined ? "-" : String(spec.listen), |
| 596 | 674 | [...new Set(loops)].join(" "), |
| 675 | String(rlimits.fileBlocks), | |
| 676 | String(rlimits.files), | |
| 677 | String(rlimits.tasks), | |
| 597 | 678 | "/bin/sh", |
| 598 | 679 | "-c", |
| 599 | 680 | command, |
src/kernel/disk_reading.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * The volume under a directory, as statfs(2) reports it: free is `bavail`, | |
| 3 | * what an unprivileged writer can still use, total is `blocks`, both times | |
| 4 | * `bsize`. One reader every default reading goes through (vitals, the disk | |
| 5 | * floor of `spawn/disk_floor.ts`), so the suite can give it a disk. | |
| 6 | */ | |
| 7 | ||
| 8 | import { statfsSync } from "node:fs"; | |
| 9 | ||
| 10 | /** What a volume holds and has free. */ | |
| 11 | export interface DiskReading { | |
| 12 | readonly freeBytes: number; | |
| 13 | readonly totalBytes: number; | |
| 14 | } | |
| 15 | ||
| 16 | /** The volume under `dir`, as the operating system reports it. */ | |
| 17 | export function statDisk(dir: string): DiskReading { | |
| 18 | const fs = statfsSync(dir); | |
| 19 | return { | |
| 20 | freeBytes: Number(fs.bavail) * Number(fs.bsize), | |
| 21 | totalBytes: Number(fs.blocks) * Number(fs.bsize), | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | let reader: (dir: string) => DiskReading = statDisk; | |
| 26 | ||
| 27 | /** | |
| 28 | * The suite runs on machines whose disks are whatever they are; its preload | |
| 29 | * gives a roomy one, so no test is refused by the machine it ran on, and a | |
| 30 | * test of a full disk gives its own. `undefined` puts statfs back. | |
| 31 | */ | |
| 32 | export function provideDiskForTest(r: ((dir: string) => DiskReading) | undefined): void { | |
| 33 | reader = r ?? statDisk; | |
| 34 | } | |
| 35 | ||
| 36 | /** The volume under `dir` as the forge reads it by default. */ | |
| 37 | export const readDisk = (dir: string): DiskReading => reader(dir); |
src/kernel/vitals.ts +12 −16
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | import { statfsSync, statSync } from "node:fs"; | |
| 12 | import { statSync } from "node:fs"; | |
| 13 | import { readDisk, type DiskReading } from "./disk_reading.ts"; | |
| 14 | import { DISK_FLOOR } from "../spawn/disk_floor.ts"; | |
| 13 | 15 | import { dirname } from "node:path"; |
| 14 | 16 | import { freemem, totalmem } from "node:os"; |
| 15 | 17 | import { refusedInternal, type RefusedLedger } from "./internal_refused.ts"; |
| @@ -120,20 +122,7 @@ export interface Vitals { | ||
| 120 | 122 | readonly at: string; |
| 121 | 123 | } |
| 122 | 124 | |
| 123 | /** What a volume holds and has free. */ | |
| 124 | export interface DiskReading { | |
| 125 | readonly freeBytes: number; | |
| 126 | readonly totalBytes: number; | |
| 127 | } | |
| 128 | ||
| 129 | /** The volume under `dir`, as the operating system reports it. */ | |
| 130 | export function statDisk(dir: string): DiskReading { | |
| 131 | const fs = statfsSync(dir); | |
| 132 | return { | |
| 133 | freeBytes: Number(fs.bavail) * Number(fs.bsize), | |
| 134 | totalBytes: Number(fs.blocks) * Number(fs.bsize), | |
| 135 | }; | |
| 136 | } | |
| 125 | export { statDisk, readDisk, type DiskReading } from "./disk_reading.ts"; | |
| 137 | 126 | |
| 138 | 127 | export interface VitalsInput { |
| 139 | 128 | readonly db: Database; |
| @@ -343,7 +332,7 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 343 | 332 | degraded.push({ cause, sentence, since, href }); |
| 344 | 333 | }; |
| 345 | 334 | |
| 346 | const disk = (input.disk ?? statDisk)(input.dataDir); | |
| 335 | const disk = (input.disk ?? readDisk)(input.dataDir); | |
| 347 | 336 | if (disk.totalBytes > 0 && disk.freeBytes / disk.totalBytes < (input.diskFreeMin ?? DISK_FREE_MIN)) |
| 348 | 337 | need( |
| 349 | 338 | "disk", |
| @@ -351,6 +340,13 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 351 | 340 | undefined, |
| 352 | 341 | "/settings/capacity", |
| 353 | 342 | ); |
| 343 | else if (disk.totalBytes > 0 && disk.freeBytes / disk.totalBytes < DISK_FLOOR) | |
| 344 | need( | |
| 345 | "disk-floor", | |
| 346 | `Disk is under the ${DISK_FLOOR * 100}% the forge keeps for itself: ${Math.round((disk.freeBytes / disk.totalBytes) * 100)}% free. No child forge is made or started until room is made.`, | |
| 347 | undefined, | |
| 348 | "/settings/capacity", | |
| 349 | ); | |
| 354 | 350 | |
| 355 | 351 | for (const file of openDbFiles(input.dataDir)) |
| 356 | 352 | need(`file:${file.name}`, file.sentence, undefined, "/settings/capacity"); |
src/machines/shell.ts +2
| @@ -425,6 +425,8 @@ export function shellCell( | ||
| 425 | 425 | }, |
| 426 | 426 | terminal: true, |
| 427 | 427 | label, |
| 428 | // The prompt names the machine, never the pod it runs on (#10850). | |
| 429 | hostname: machine.name, | |
| 428 | 430 | }; |
| 429 | 431 | } |
| 430 | 432 |
src/spawn/cell.ts +13
| @@ -21,6 +21,7 @@ import { childDirOf } from "../actions/spawn.ts"; | ||
| 21 | 21 | import { openBeatDoor, parentLocal, type BeatDoor } from "./beat-door.ts"; |
| 22 | 22 | import { hostedPlacement, parentOrigin, childPublicUrl, type BodyStore, type Placement } from "./placement.ts"; |
| 23 | 23 | import { SIZE_IO, SIZES, type Size } from "./sizes.ts"; |
| 24 | import { floorRefusal } from "./disk_floor.ts"; | |
| 24 | 25 | import { CGROUP_DIR_ENV, SIZE_ENV } from "./usage.ts"; |
| 25 | 26 | import { childModelHosts, childRegistryHosts } from "../core/capacity.ts"; |
| 26 | 27 | import { liveChildDisk, type ChildDisk } from "./disk_mount.ts"; |
| @@ -113,6 +114,8 @@ export function cellSpecFor(input: { | ||
| 113 | 114 | size: Size; |
| 114 | 115 | port: number; |
| 115 | 116 | label: string; |
| 117 | /** The child's name: its cell's hostname. */ | |
| 118 | name?: string; | |
| 116 | 119 | /** The model provider hosts an admin let children reach (`children.modelEgress`); none by default. */ |
| 117 | 120 | modelHosts?: readonly string[]; |
| 118 | 121 | /** The package registry an admin let children reach (`children.registryEgress`); none by default. */ |
| @@ -155,6 +158,8 @@ export function cellSpecFor(input: { | ||
| 155 | 158 | io: SIZE_IO[input.size], |
| 156 | 159 | listen: input.port, |
| 157 | 160 | label: input.label, |
| 161 | // Its shell's prompt names the child, never the parent's pod (#10850). | |
| 162 | hostname: input.name, | |
| 158 | 163 | }; |
| 159 | 164 | } |
| 160 | 165 | |
| @@ -254,6 +259,7 @@ export function launchSpecFor( | ||
| 254 | 259 | size: input.size, |
| 255 | 260 | port: at.port, |
| 256 | 261 | label: `forge:${input.name}`, |
| 262 | name: input.name, | |
| 257 | 263 | modelHosts: childModelHosts(parent.db), |
| 258 | 264 | registryHosts: childRegistryHosts(parent.db), |
| 259 | 265 | disk: liveChildDisk(parent.config.dataDir, input.bodyId, mountinfo), |
| @@ -272,6 +278,13 @@ function makeRegistry(parent: App): CellChildren { | ||
| 272 | 278 | const busy = new Map<string, number>(); |
| 273 | 279 | |
| 274 | 280 | const launch = async (input: { bodyId: string; name: string; size: Size }): Promise<CellChild | undefined> => { |
| 281 | // Below the disk floor no child cell starts, a sleeping one included (#10850). | |
| 282 | const floor = floorRefusal(parent.config.dataDir); | |
| 283 | if (floor !== undefined) { | |
| 284 | parent.log.warn("child cell not started", { name: input.name, reason: floor }); | |
| 285 | failed.set(input.bodyId, { at: Date.now(), why: floor }); | |
| 286 | return undefined; | |
| 287 | } | |
| 275 | 288 | const cells = await cellsAvailable(); |
| 276 | 289 | if (!cells.ok) { |
| 277 | 290 | parent.log.warn("child cell not started", { name: input.name, reason: cells.reason }); |
src/spawn/disk_floor.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * The disk floor: the share of the forge's own volume it keeps for itself | |
| 3 | * (#10850, item 5). Child forges write to the one disk under the parent's | |
| 4 | * data; below the floor no new child forge is made (`spawn/quota.ts` | |
| 5 | * `quotaRefusal`, which the New forge page and menu also show) and no child | |
| 6 | * cell starts (`spawn/cell.ts`; a sleeping one stays asleep), so what room | |
| 7 | * is left is the parent's to recover with. The owner's line is the vitals | |
| 8 | * need `disk-floor` (`kernel/vitals.ts`). | |
| 9 | */ | |
| 10 | ||
| 11 | import { readDisk, type DiskReading } from "../kernel/disk_reading.ts"; | |
| 12 | ||
| 13 | /** Free disk under this share of the volume and nothing new starts. */ | |
| 14 | export const DISK_FLOOR = 0.15; | |
| 15 | ||
| 16 | /** The volume under `dir` (`readDisk`, which the suite's preload makes roomy), or undefined when it cannot be read. */ | |
| 17 | export function diskUnder(dir: string): DiskReading | undefined { | |
| 18 | try { | |
| 19 | return readDisk(dir); | |
| 20 | } catch { | |
| 21 | return undefined; | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | const gib = (bytes: number): string => (bytes / 1024 ** 3).toFixed(1); | |
| 26 | ||
| 27 | /** | |
| 28 | * One sentence when the volume under `dir` has less than `DISK_FLOOR` free, | |
| 29 | * else undefined. A volume that cannot be read, or reads as empty, is not | |
| 30 | * refused: the floor protects a disk it can see, it does not guess. | |
| 31 | */ | |
| 32 | export function floorRefusal(dir: string, disk: DiskReading | undefined = diskUnder(dir)): string | undefined { | |
| 33 | if (disk === undefined || !(disk.totalBytes > 0)) return undefined; | |
| 34 | const share = disk.freeBytes / disk.totalBytes; | |
| 35 | if (share >= DISK_FLOOR) return undefined; | |
| 36 | return `this host's disk has ${(share * 100).toFixed(1)}% free (${gib(disk.freeBytes)} of ${gib(disk.totalBytes)} GiB), under the ${DISK_FLOOR * 100}% it keeps for itself: nothing new starts until room is made`; | |
| 37 | } |
src/spawn/quota.ts +4
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { readdirSync, readFileSync } from "node:fs"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | import { floorRefusal } from "./disk_floor.ts"; | |
| 11 | 12 | import { DEFAULT_SIZE, isSize, SIZES, type Size, type SizeLimits } from "./sizes.ts"; |
| 12 | 13 | |
| 13 | 14 | interface Axis { |
| @@ -61,6 +62,9 @@ export function quotaRefusal( | ||
| 61 | 62 | size: Size, |
| 62 | 63 | env: Record<string, string | undefined> = process.env, |
| 63 | 64 | ): string | undefined { |
| 65 | // The host's own floor comes first, declared limits or not (#10850). | |
| 66 | const floor = floorRefusal(dataDir); | |
| 67 | if (floor !== undefined) return floor; | |
| 64 | 68 | const limits = AXES.map((a) => ({ a, max: limitOf(env, a.env) })).filter((l) => l.max !== undefined); |
| 65 | 69 | if (limits.length === 0) return undefined; |
| 66 | 70 | const held = heldByChildren(dataDir); |
test/cell-kernel-limits.test.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 2 | * #10850: what the kernel holds a cell to, tried. A cell's first shell runs | |
| 3 | * `RLIMIT_LINE` with `cellRlimits`; here the same line runs under the same | |
| 4 | * `/bin/sh` and then tries to break each limit: a file past the disk, a | |
| 5 | * file descriptor past the open-file limit, and raising a limit back. | |
| 6 | * No bubblewrap is needed for that (rlimits are the kernel's per process), | |
| 7 | * so these run in every sandbox; the cell's argv itself is the check's. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { cellCgroup, cellHostname, cellRlimits, hostnameArgs, FILES_MAX, PROCESSES_MAX, RLIMIT_LINE, TASKS_MAX } from "../src/apps/contain.ts"; | |
| 15 | import { cellLimits, cellSpecFor } from "../src/spawn/cell.ts"; | |
| 16 | import { SIZES } from "../src/spawn/sizes.ts"; | |
| 17 | import { missing } from "./helpers/tools.ts"; | |
| 18 | ||
| 19 | let dir: string; | |
| 20 | beforeAll(() => { | |
| 21 | dir = mkdtempSync(join(tmpdir(), "cell-rlimits-")); | |
| 22 | }); | |
| 23 | afterAll(() => rmSync(dir, { recursive: true, force: true })); | |
| 24 | ||
| 25 | /** `script` run by `/bin/sh` after the cell's own limit line, with these figures. */ | |
| 26 | async function underLimits(figures: { fileBlocks: number; files: number; tasks: number }, script: string) { | |
| 27 | const proc = Bun.spawn( | |
| 28 | ["/bin/sh", "-c", `${RLIMIT_LINE}\n${script}`, "cell", String(figures.fileBlocks), String(figures.files), String(figures.tasks)], | |
| 29 | { cwd: dir, stdout: "pipe", stderr: "pipe", env: { PATH: process.env["PATH"] ?? "/usr/bin:/bin" } }, | |
| 30 | ); | |
| 31 | const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); | |
| 32 | return { out: out.trim(), code }; | |
| 33 | } | |
| 34 | ||
| 35 | describe("a cell's rlimits, figured from its size", () => { | |
| 36 | test("a small child's file cap is its 2048 MiB in 512-byte blocks: 2048 × 2048 = 4194304", () => { | |
| 37 | expect(SIZES.small.diskMiB).toBe(2048); | |
| 38 | expect(cellRlimits(cellLimits("small"))).toEqual({ fileBlocks: 4_194_304, files: FILES_MAX, tasks: TASKS_MAX }); | |
| 39 | // large: 40960 MiB × 2048 = 83886080 blocks = 40 GiB, never the 50G of the report. | |
| 40 | expect(cellRlimits(cellLimits("large")).fileBlocks).toBe(83_886_080); | |
| 41 | }); | |
| 42 | ||
| 43 | test("an unbounded disk is no file cap, never a zero one", () => { | |
| 44 | expect(cellRlimits({ diskMiB: Infinity }).fileBlocks).toBe(Number.MAX_SAFE_INTEGER); | |
| 45 | expect(cellRlimits({ diskMiB: 0 }).fileBlocks).toBe(Number.MAX_SAFE_INTEGER); | |
| 46 | }); | |
| 47 | }); | |
| 48 | ||
| 49 | describe.skipIf(missing("/usr/bin/fallocate"))("the kernel refuses what passes them", () => { | |
| 50 | // 2048 blocks of 512 bytes: a 1 MiB cell. | |
| 51 | const oneMiB = { fileBlocks: 2048, files: 64, tasks: TASKS_MAX }; | |
| 52 | ||
| 53 | test("fallocate past the cell's disk fails, and nothing was allocated", async () => { | |
| 54 | const r = await underLimits(oneMiB, "fallocate -l 50G big; echo $?"); | |
| 55 | // SIGXFSZ: 128 + 25. | |
| 56 | expect(Number(r.out)).not.toBe(0); | |
| 57 | expect(statSync(join(dir, "big")).size).toBe(0); | |
| 58 | expect(statSync(join(dir, "big")).blocks).toBe(0); | |
| 59 | }); | |
| 60 | ||
| 61 | test("dd past the cell's disk stops at the cap", async () => { | |
| 62 | const r = await underLimits(oneMiB, "dd if=/dev/zero of=dd.out bs=1M count=5 2>/dev/null; echo $?"); | |
| 63 | expect(Number(r.out)).not.toBe(0); | |
| 64 | expect(statSync(join(dir, "dd.out")).size).toBeLessThanOrEqual(1024 * 1024); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a file under the cap is written whole", async () => { | |
| 68 | const r = await underLimits(oneMiB, "dd if=/dev/zero of=ok.out bs=1k count=512 2>/dev/null; echo $?"); | |
| 69 | expect(r.out).toBe("0"); | |
| 70 | expect(statSync(join(dir, "ok.out")).size).toBe(512 * 1024); | |
| 71 | }); | |
| 72 | ||
| 73 | test("open files past the limit are refused (EMFILE)", async () => { | |
| 74 | const script = `exec ${JSON.stringify(process.execPath)} -e 'const fs=require("fs");let n=0;try{for(;n<200;n++)fs.openSync("/dev/null","r")}catch(e){console.log(e.code)}'`; | |
| 75 | const r = await underLimits(oneMiB, script); | |
| 76 | expect(r.out).toBe("EMFILE"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("nothing inside raises a limit back: soft and hard were both set", async () => { | |
| 80 | const r = await underLimits(oneMiB, "ulimit -f unlimited 2>/dev/null; ulimit -f; ulimit -n 100000 2>/dev/null; ulimit -n"); | |
| 81 | expect(r.out.split("\n")).toEqual(["2048", "64"]); | |
| 82 | }); | |
| 83 | ||
| 84 | test("the task limit is set", async () => { | |
| 85 | const r = await underLimits({ ...oneMiB, tasks: 300 }, "ulimit -u 2>/dev/null || ulimit -p"); | |
| 86 | expect(r.out).toBe("300"); | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("a cell's hostname is its own", () => { | |
| 91 | test("a child's cell is named for the child, never the parent's pod", () => { | |
| 92 | const spec = cellSpecFor({ | |
| 93 | dir: join(dir, "kid"), | |
| 94 | parentDataDir: dir, | |
| 95 | parentHome: join(dir, "home"), | |
| 96 | parentUrl: "https://f.test", | |
| 97 | size: "small", | |
| 98 | port: 4000, | |
| 99 | label: "forge:kid", | |
| 100 | name: "kid", | |
| 101 | }); | |
| 102 | expect(spec.hostname).toBe("kid"); | |
| 103 | expect(cellHostname(spec.hostname)).toBe("kid"); | |
| 104 | }); | |
| 105 | ||
| 106 | test("a name becomes a DNS label, and nothing becomes 'cell'", () => { | |
| 107 | expect(cellHostname("Scottish Bob's forge")).toBe("scottish-bob-s-forge"); | |
| 108 | expect(cellHostname("x".repeat(80))).toHaveLength(63); | |
| 109 | expect(cellHostname("--")).toBe("cell"); | |
| 110 | expect(cellHostname(undefined)).toBe("cell"); | |
| 111 | expect(cellHostname("a;$(reboot)")).toBe("a-reboot"); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a host that refuses sethostname gets no --hostname, so the cell still starts", () => { | |
| 115 | // The check machine's bwrap answers `--hostname` with EPERM; passing it | |
| 116 | // anyway kept every child cell from coming up. | |
| 117 | expect(hostnameArgs("kid", true)).toEqual(["--hostname", "kid"]); | |
| 118 | expect(hostnameArgs("kid", false)).toEqual([]); | |
| 119 | }); | |
| 120 | }); | |
| 121 | ||
| 122 | describe("a small child's cgroup, as written", () => { | |
| 123 | // A stand-in for the delegated cgroup: cgroupfs files are plain files to write. | |
| 124 | test("memory.max 2 GiB, pids.max, and cpu.max one core whatever the host's count", () => { | |
| 125 | const root = join(dir, "cg"); | |
| 126 | mkdirSync(join(root, "cells"), { recursive: true }); | |
| 127 | writeFileSync(join(root, "cells", "cgroup.subtree_control"), "memory pids cpu io"); | |
| 128 | const spec = cellSpecFor({ | |
| 129 | dir: join(dir, "kid2"), | |
| 130 | parentDataDir: dir, | |
| 131 | parentHome: join(dir, "home"), | |
| 132 | parentUrl: "https://f.test", | |
| 133 | size: "small", | |
| 134 | port: 4001, | |
| 135 | label: "forge:kid2", | |
| 136 | }); | |
| 137 | const made = cellCgroup(spec, { ok: true, root }); | |
| 138 | expect(made).toBeDefined(); | |
| 139 | const at = (file: string) => readFileSync(join(made!.dir, file), "utf8"); | |
| 140 | // 2048 MiB × 1048576 = 2147483648 bytes. | |
| 141 | expect(at("memory.max")).toBe("2147483648"); | |
| 142 | expect(at("pids.max")).toBe(String(PROCESSES_MAX)); | |
| 143 | // One core is 100000 µs of every 100000 µs period: an absolute quota, so 64 cores on the host change nothing. | |
| 144 | expect(at("cpu.max")).toBe("100000 100000"); | |
| 145 | expect(at("io.weight")).toBe("default 100"); | |
| 146 | expect(made!.cpuCapped).toBe(true); | |
| 147 | }); | |
| 148 | }); |
test/disk-floor.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * #10850 item 5: the parent protects itself. Below 15% free on its own | |
| 3 | * volume no child forge is made, no child cell starts, and the owner reads | |
| 4 | * one line in vitals. Each is tried against a disk this test hands in. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, afterEach, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp } from "../src/app.ts"; | |
| 12 | import { provideDiskForTest, type DiskReading } from "../src/kernel/disk_reading.ts"; | |
| 13 | import { vitals } from "../src/kernel/vitals.ts"; | |
| 14 | import { cellChildren, stopCellChildren } from "../src/spawn/cell.ts"; | |
| 15 | import { DISK_FLOOR, floorRefusal } from "../src/spawn/disk_floor.ts"; | |
| 16 | import { quotaFull, quotaRefusal } from "../src/spawn/quota.ts"; | |
| 17 | ||
| 18 | const tmp = mkdtempSync(join(tmpdir(), "disk-floor-")); | |
| 19 | const GiB = 1024 ** 3; | |
| 20 | const reading = (freeGiB: number, totalGiB = 100): DiskReading => ({ freeBytes: freeGiB * GiB, totalBytes: totalGiB * GiB }); | |
| 21 | const roomy = (): DiskReading => reading(900, 1000); | |
| 22 | ||
| 23 | afterEach(() => provideDiskForTest(roomy)); | |
| 24 | afterAll(() => rmSync(tmp, { recursive: true, force: true })); | |
| 25 | ||
| 26 | describe("floorRefusal", () => { | |
| 27 | test("the floor is 15%", () => expect(DISK_FLOOR).toBe(0.15)); | |
| 28 | ||
| 29 | test("14.9 GiB free of 100 is under it, and the sentence says the figures", () => { | |
| 30 | expect(floorRefusal(tmp, reading(14.9))).toBe( | |
| 31 | "this host's disk has 14.9% free (14.9 of 100.0 GiB), under the 15% it keeps for itself: nothing new starts until room is made", | |
| 32 | ); | |
| 33 | }); | |
| 34 | ||
| 35 | test("exactly 15% free is not under it", () => expect(floorRefusal(tmp, reading(15))).toBeUndefined()); | |
| 36 | ||
| 37 | test("the reported pod (314 GiB free of 669) is above it; at 90 GiB free it is not", () => { | |
| 38 | expect(floorRefusal(tmp, reading(314, 669))).toBeUndefined(); | |
| 39 | expect(floorRefusal(tmp, reading(90, 669))).toContain("13.5% free"); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a disk that cannot be read or reads as empty is not refused", () => { | |
| 43 | expect(floorRefusal(tmp, undefined)).toBeUndefined(); | |
| 44 | expect(floorRefusal(tmp, { freeBytes: 0, totalBytes: 0 })).toBeUndefined(); | |
| 45 | }); | |
| 46 | ||
| 47 | test("with no reading handed in, it reads the forge's disk reader", () => { | |
| 48 | provideDiskForTest(() => reading(10)); | |
| 49 | expect(floorRefusal(tmp)).toContain("10.0% free"); | |
| 50 | }); | |
| 51 | }); | |
| 52 | ||
| 53 | describe("below the floor", () => { | |
| 54 | test("no child forge is made, whatever limits are declared, and the New forge page says why", () => { | |
| 55 | provideDiskForTest(() => reading(5)); | |
| 56 | expect(quotaRefusal(tmp, "small", {})).toContain("under the 15% it keeps for itself"); | |
| 57 | expect(quotaFull(tmp, {})).toContain("under the 15% it keeps for itself"); | |
| 58 | provideDiskForTest(roomy); | |
| 59 | expect(quotaRefusal(tmp, "small", {})).toBeUndefined(); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a child cell does not start, and its failure names the floor", async () => { | |
| 63 | const app = createApp({ dataDir: join(tmp, "parent"), url: "https://f.test" }); | |
| 64 | try { | |
| 65 | provideDiskForTest(() => reading(12)); | |
| 66 | const children = cellChildren(app); | |
| 67 | expect(await children.start({ bodyId: "cell-floor", name: "kid", size: "small" })).toBeUndefined(); | |
| 68 | expect(children.failure("cell-floor")).toContain("12.0% free"); | |
| 69 | expect(children.list()).toEqual([]); | |
| 70 | } finally { | |
| 71 | await stopCellChildren(app); | |
| 72 | app.close(); | |
| 73 | } | |
| 74 | }); | |
| 75 | ||
| 76 | test("the owner reads one line in vitals between the floor and nearly full", () => { | |
| 77 | const app = createApp({ dataDir: join(tmp, "vitals"), url: "https://f.test" }); | |
| 78 | try { | |
| 79 | const v = vitals({ db: app.db, signals: app.signals, dataDir: app.config.dataDir, disk: () => ({ freeBytes: 12, totalBytes: 100 }) }); | |
| 80 | expect(v.degraded.map((d) => d.cause)).toEqual(["disk-floor"]); | |
| 81 | expect(v.needs).toEqual(["Disk is under the 15% the forge keeps for itself: 12% free. No child forge is made or started until room is made."]); | |
| 82 | const at15 = vitals({ db: app.db, signals: app.signals, dataDir: app.config.dataDir, disk: () => ({ freeBytes: 15, totalBytes: 100 }) }); | |
| 83 | expect(at15.degraded.map((d) => d.cause)).toEqual([]); | |
| 84 | } finally { | |
| 85 | app.close(); | |
| 86 | } | |
| 87 | }); | |
| 88 | }); |
test/disk-mount.test.ts +2
| @@ -237,6 +237,8 @@ describe("what the forge launches and boots", () => { | ||
| 237 | 237 | const own = launchSpecFor(parent, input, at, `${ROOT}\n731 22 7:3 / ${real} rw - ext4 /dev/loop3 rw\n`); |
| 238 | 238 | expect(own.writable).toEqual([real]); |
| 239 | 239 | expect(launchSpecFor(parent, input, at, `${ROOT}\n`).writable).toEqual([]); |
| 240 | // The launch path names the cell after the child, not the parent's pod (#10850). | |
| 241 | expect(own.hostname).toBe("kid"); | |
| 240 | 242 | }); |
| 241 | 243 | |
| 242 | 244 | test("the boot does not open a child on the shared disk when one of its own is required, and opens it when not", async () => { |
test/helpers/apps-forge.ts +1
| @@ -16,6 +16,7 @@ import type { Repo } from "../../src/core/repos.ts"; | ||
| 16 | 16 | import { listRuns, type Run } from "../../src/core/runs.ts"; |
| 17 | 17 | import { createUser, type User } from "../../src/core/users.ts"; |
| 18 | 18 | import { serve, type Servers } from "../../src/http/server.ts"; |
| 19 | import "./roomy-disk.ts"; | |
| 19 | 20 | |
| 20 | 21 | export const APPS_DOMAIN = "apps.test"; |
| 21 | 22 |
test/helpers/big-forge.ts +1
| @@ -8,6 +8,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../../src/app.ts"; |
| 11 | import "./roomy-disk.ts"; | |
| 11 | 12 | |
| 12 | 13 | export interface BigForgeSize { |
| 13 | 14 | readonly issues: number; |
test/helpers/checks-forge.ts +1
| @@ -25,6 +25,7 @@ import type { Repo } from "../../src/core/repos.ts"; | ||
| 25 | 25 | import { serve, type Servers } from "../../src/http/server.ts"; |
| 26 | 26 | import { listEvents } from "../../src/core/events.ts"; |
| 27 | 27 | import { promote } from "./members.ts"; |
| 28 | import "./roomy-disk.ts"; | |
| 28 | 29 | |
| 29 | 30 | /** A forge.json declaring `run` as the check, with no install step. */ |
| 30 | 31 | export const checkJson = (run: string, extra = ""): string => |
test/helpers/claimed-forge.ts +1
| @@ -19,6 +19,7 @@ import { firstBoot } from "../../src/spawn/first-boot.ts"; | ||
| 19 | 19 | import { hostedPlacement } from "../../src/spawn/placement.ts"; |
| 20 | 20 | import { passkeyFields } from "./passkey.ts"; |
| 21 | 21 | import { seatAs } from "./seat.ts"; |
| 22 | import "./roomy-disk.ts"; | |
| 22 | 23 | |
| 23 | 24 | export const PASSWORD = "a-long-next-steps-card-passphrase"; |
| 24 | 25 | const FORGE_JSON = JSON.stringify({ |
test/helpers/crew-standin.ts +1
| @@ -24,6 +24,7 @@ import { listRuns, type Run } from "../../src/core/runs.ts"; | ||
| 24 | 24 | import { createUser, type User } from "../../src/core/users.ts"; |
| 25 | 25 | import type { InstallRunner } from "../../src/crew/workdir.ts"; |
| 26 | 26 | import { serve, type Servers } from "../../src/http/server.ts"; |
| 27 | import "./roomy-disk.ts"; | |
| 27 | 28 | |
| 28 | 29 | export interface StandInCrew { |
| 29 | 30 | readonly dataDir: string; |
test/helpers/keeper.ts +1
| @@ -9,6 +9,7 @@ import { id } from "../../src/kernel/id.ts"; | ||
| 9 | 9 | import type { Deployer } from "../../src/keeper/deploy.ts"; |
| 10 | 10 | import type { KeeperPorts } from "../../src/keeper/keeper.ts"; |
| 11 | 11 | import { verifyUnderLoad, type Probe } from "../../src/keeper/verify.ts"; |
| 12 | import "./roomy-disk.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const A = "a".repeat(40); |
| 14 | 15 | export const B = "b".repeat(40); |
test/helpers/listed-forge.ts +1
| @@ -9,6 +9,7 @@ import { createApp, type App } from "../../src/app.ts"; | ||
| 9 | 9 | import { createSession } from "../../src/core/sessions.ts"; |
| 10 | 10 | import { forgeHttp, type ForgeHttp } from "../../src/http/server.ts"; |
| 11 | 11 | import { seedAdmin } from "./admin.ts"; |
| 12 | import "./roomy-disk.ts"; | |
| 12 | 13 | |
| 13 | 14 | export interface ListedForge { |
| 14 | 15 | app: App; |
test/helpers/overlap-worker.ts +1
| @@ -16,6 +16,7 @@ import { getRepo } from "../../src/core/repos.ts"; | ||
| 16 | 16 | import { deliverDue } from "../../src/core/webhooks.ts"; |
| 17 | 17 | import { LOOPBACK } from "./loopback-hook.ts"; |
| 18 | 18 | import { queueAgentRun } from "../../src/crew/dispatch.ts"; |
| 19 | import "./roomy-disk.ts"; | |
| 19 | 20 | |
| 20 | 21 | const [mode, dataDir, startAt, count] = process.argv.slice(2); |
| 21 | 22 | const app = createApp({ dataDir: dataDir!, port: 0 }); |
test/helpers/parks-engine.ts +1
| @@ -15,6 +15,7 @@ import { createUser, getUserByName } from "../../src/core/users.ts"; | ||
| 15 | 15 | import { serve } from "../../src/http/server.ts"; |
| 16 | 16 | import { settleAgentRun } from "../../src/crew/autonomy.ts"; |
| 17 | 17 | import { interruptedSummary } from "../../src/crew/interrupted.ts"; |
| 18 | import "./roomy-disk.ts"; | |
| 18 | 19 | |
| 19 | 20 | /** |
| 20 | 21 | * One forge with alice's `engine` on its own: atlas plans, mason builds. |
test/helpers/preload.ts +1
| @@ -7,6 +7,7 @@ import { provideCpusForTest } from "../../src/core/capacity.ts"; | ||
| 7 | 7 | import { provideMirrorOutboundForTest } from "../../src/origins/git-mirror.ts"; |
| 8 | 8 | import { installCheapHash } from "./cheap-hash.ts"; |
| 9 | 9 | import { useReachByDefault } from "../../src/spawn/reach.ts"; |
| 10 | import "./roomy-disk.ts"; | |
| 10 | 11 | |
| 11 | 12 | installCheapHash(); |
| 12 | 13 | // Defaults sized from CPUs (#2549) read as on a 4-CPU pod — 4 shards, one |
test/helpers/real-child-forge.ts +1
| @@ -15,6 +15,7 @@ import { createApp } from "../../src/app.ts"; | ||
| 15 | 15 | import { cellCounters, cellsAvailable } from "../../src/apps/contain.ts"; |
| 16 | 16 | import { cellChildren, stopCellChildren } from "../../src/spawn/cell.ts"; |
| 17 | 17 | import { answerFromCell } from "../../src/spawn/host.ts"; |
| 18 | import "./roomy-disk.ts"; | |
| 18 | 19 | |
| 19 | 20 | const cells = await cellsAvailable(); |
| 20 | 21 | if (!cells.ok) { |
test/helpers/real-spawn-e2e.ts +1
| @@ -22,6 +22,7 @@ import { serve } from "../../src/http/server.ts"; | ||
| 22 | 22 | import { cellPlacement, stopCellChildren } from "../../src/spawn/cell.ts"; |
| 23 | 23 | import { bootHostedChild } from "../../src/spawn/host.ts"; |
| 24 | 24 | import { memoryBodies } from "../../src/spawn/placement.ts"; |
| 25 | import "./roomy-disk.ts"; | |
| 25 | 26 | |
| 26 | 27 | // Two budgets, each its own: spawn+boot may take SPAWN_MS, then the poll gets |
| 27 | 28 | // POLL_MS counted from when boot ended. A slow spawn never shortens the poll, |
test/helpers/roomy-disk.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | /** | |
| 2 | * The suite runs on machines whose disks are whatever they are: every | |
| 3 | * default disk reading (vitals, the disk floor of #10850) sees a roomy one, | |
| 4 | * so no test is refused by the machine it ran on, and a test of a full disk | |
| 5 | * gives its own reading. The preload imports it; so does every helper the | |
| 6 | * suite starts as a process of its own (`real-child-forge.ts` and the like), | |
| 7 | * which no preload reaches: a check host near 15% free must not refuse the | |
| 8 | * child a cell test boots. | |
| 9 | */ | |
| 10 | import { provideDiskForTest } from "../../src/kernel/disk_reading.ts"; | |
| 11 | ||
| 12 | provideDiskForTest(() => ({ freeBytes: 900 * 1024 ** 3, totalBytes: 1000 * 1024 ** 3 })); |