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.
This pull makes the kernel enforce a hard limit on how big one file can grow inside a cell, so the reported `fallocate -l 50G` on a small child now fails at once. It does **not** finish item 1: the kernel still doesn't cap a child's total disk, because the pod can't mount a filesystem for each child. #10850 waits on four follow-ups I opened.
shipped production is serving it deploy 114667f
This pull makes the kernel enforce a hard limit on how big one file can grow inside a cell, so the reported fallocate -l 50G on a small child now fails at once. It does not finish item 1: the kernel still doesn't cap a child's total disk, because the pod can't mount a filesystem for each child. #10850 waits on four follow-ups I opened.
Done in this pull
- Limits set by every cell's first shell (
RLIMIT_LINEandcellRlimitsinsrc/apps/contain.ts). Each is set both soft and hard, so nothing inside the cell can raise it again.
- File size (RLIMIT_FSIZE) is the cell's disk size. For a small child that is 2048 MiB × 2048 = 4194304 blocks of 512 bytes. fallocate and dd past it fail with SIGXFSZ.
- Open files (RLIMIT_NOFILE) is 8192.
- Processes (RLIMIT_NPROC) is 4096, threads included. The kernel counts these separately for each cell.
- Hostname (item 6). A child's cell runs bwrap with
--hostname <child name>, a machine shell gets the machine's name, and any other cell getscell(cellHostname). Prompts no longer show the pod's name. - Disk floor (item 5, part). When the parent's volume has under 15% free (
src/spawn/disk_floor.ts):
- quotaRefusal refuses to make a new child forge, and the New forge page and menu say why.
- A child cell won't start, and a sleeping child stays asleep.
- Vitals shows a disk-floor line between 10% and 15% free. Under 10%, the existing "Disk is nearly full" line still applies.
- Shared disk reading. All default disk readings now go through
src/kernel/disk_reading.ts. The test preload hands it a roomy disk, so no test fails because of the disk of the machine it runs on. - Memory, processes and CPU (items 2 and 3). These were already enforced through the cgroup. A new test reads the files written for a small child on a stand-in cgroup tree:
memory.max2147483648,pids.max512,cpu.max"100000 100000" andio.weight100.cpu.maxis a fixed time budget, so it holds the same on a 64-core host.
Not done, and why
- Item 1, a separate filesystem per child. The pod runs as uid 1000 with every capability dropped (
charts/app/templates/deployment.yaml). That rules out loop-mounting an ext4 or xfs image or setting a project quota; inside a user namespace only tmpfs, which is memory, can be mounted. The file-size limit caps one file, not their sum:fallocate -n(keep size) or many smaller files can still fill the shared disk until the existing check, which runs every 15 seconds, stops the cell. #10851 asks the operator to choose between a small privileged mount helper and a volume per child. - Not gated below the floor: app and check cells inside the parent. Refusing them would turn pulls red because of the machine. Moving running children to read-only and the single notification to the owner are not done either. Both are in #10852.
- Item 7, showing each child its size and usage: in #10853.
io.maxper cell: in #10854.
Each follow-up is linked so #10850 and #586 wait on it.
Tests
test/cell-kernel-limits.test.tsruns the cells' exact limit line under/bin/shand tries to break each limit:
- a 50G fallocate fails and allocates nothing;
- dd stops at the cap, while a smaller file is written whole;
- opening too many files gives EMFILE;
- raising a limit back has no effect.
test/disk-floor.test.tscovers the floor's wording at the 15% boundary, the refusal to make a child, a refused child start, and the vitals line.- The gate and
tscpass, and the tests covering the change pass. The bubblewrap part of the cell, including the hostname as the kernel actually sets it, is skipped here (no unix sockets in this sandbox); the forge's check runs it.
To try: on a small child's shell, fallocate -l 50G big now fails with "File size limit exceeded", and hostname prints the child's name.
🤖 Generated with Claude Code
This merges main into the branch. The only conflict was in the ./sizes.ts import in src/spawn/cell.ts. The import now brings in SIZE_IO from main as well as this branch's floorRefusal from ./disk_floor.ts. Git merged the rest of the cell spec without trouble, so it now sets both main's disk-bandwidth limit on the data volume (io: SIZE_IO[input.size], #10854) and this branch's hostname: input.name (#10850).
Checks before the push: bun scripts/gate.ts and bun x tsc --noEmit passed. scripts/fast.ts exited 2 because the merge touches src/kernel/disk_reading.ts, so it couldn't pick the tests itself. I ran the spawn, cell, sizes and metrics suites instead: 75 passed, 0 failed, and 3 were skipped. The skipped ones need unix sockets, which this sandbox can't bind; the forge's check runs them on this push.
🤖 Generated with Claude Code
Cells now start under kernel rlimits (resource limits) and take the child's own name as their hostname, so a child's shell prompt names the child and never the parent's pod (#10850). No child forge starts when the parent's data volume has less than 15% free disk. The 15% floor is checked in src/spawn/disk_floor.ts.
The branch is merged up to main again. The only conflict was the imports in src/spawn/cell.ts. It now imports both this branch's floorRefusal and main's CGROUP_DIR_ENV/SIZE_ENV from ./usage.ts. The two don't overlap: main's usage.ts passes a child its size and cgroup directory so it can report what it is using. This branch's limits and disk floor are untouched by that.
Before the push:
bun scripts/gate.tsandbun x tsc --noEmitpassed.scripts/fast.tscouldn't choose the tests itself because the diff reachessrc/kernel/disk_reading.ts.- I ran the covering suites myself:
cell-kernel-limits,disk-floor,spawn-cell,spawn-sizes,hosted-child-cells,sizes-api,spawn-self-use,metrics-cellsandspawn-cell-app-hosts. 78 passed, 0 failed, and 3 were skipped because they need unix sockets, which this sandbox can't bind. The forge's check runs those on this push.
🤖 Generated with Claude Code
Cells now start under kernel rlimits (resource limits) and take the child's own name as their hostname, so a child's shell prompt names the child and never the parent's pod (#10850). No child forge starts when the parent's data volume has less than 15% free disk; the check is in src/spawn/disk_floor.ts.
This push merges main again. The merge took more than combining two import lines: main moved how a cell's spec is built out of the start path in src/spawn/cell.ts into a new launchSpecFor. That function didn't pass the child's name, so the hostname this branch sets would have been silently dropped. launchSpecFor now passes name: input.name. The disk floor check and the disk_mount.ts work from main are both kept.
The existing hostname test only covered the step that builds the spec from its inputs, so it would not have caught the dropped name. test/disk-mount.test.ts now also checks that launchSpecFor gives the cell the hostname kid. That check fails when the line is removed.
Before the push:
bun scripts/gate.tsandbun x tsc --noEmitpassed.- The cell, spawn, sizes, disk-floor and disk-mount suites ran: 101 passed, 0 failed.
- 3 were skipped because they need unix sockets, which this sandbox can't bind; the forge's check runs them.
🤖 Generated with Claude Code
This pull makes the kernel enforce a hard limit on how big one file can grow inside a cell, so the reported fallocate -l 50G on a small child now fails at once. It does not finish item 1: the kernel still doesn't cap a child's total disk, because the pod can't mount a filesystem for each child. #10850 waits on four follow-ups opened earlier.
Done in this pull
- Limits set by every cell's first shell (
RLIMIT_LINEandcellRlimitsinsrc/apps/contain.ts). Each is set both soft and hard, so nothing inside the cell can raise it again:
- File size (RLIMIT_FSIZE) is the cell's disk size: a small child gets 2048 MiB × 2048 = 4194304 blocks of 512 bytes. fallocate and dd past that fail with SIGXFSZ.
- Open files (RLIMIT_NOFILE) is 8192.
- Processes (RLIMIT_NPROC) is 4096, threads included. The kernel counts these separately for each cell.
- Hostname (item 6): a child's cell runs bwrap with
--hostname <child name>, a machine shell gets the machine's name, and any other cell getscell(cellHostname). - Disk floor (item 5, part): when the parent's volume has under 15% free (
src/spawn/disk_floor.ts),quotaRefusalrefuses to make a new child forge, a child cell won't start (a sleeping one stays asleep), and vitals raises adisk-floorneed.
What the last push fixes
- The two real-cell tests ("a real child forge > boots in its own forge:<name> cell…" and "a child forge spawned into a cell serves its app…") each start their parent forge as a separate process (
test/helpers/real-child-forge.ts,real-spawn-e2e.ts). The test preload doesn't reach those processes, so they read the check host's real disk. - The check host was at about 17% free when the runs ended, so most likely it dipped under 15% mid-run and the floor refused the child. The log only shows the failed tests' names, so this is the likely cause, not a confirmed one.
- Fix: the preload's "roomy disk" stub now lives in
test/helpers/roomy-disk.ts. The preload and every helper the suite runs as its own process import it. No source code changed.
Not fixed here: "a stall under a named job is read through system" also fails on #10930. It comes from main, not from this pull. The other 14 failures in the latest run (out-of-memory errors in shard 75 and the follow-on errors after them) were not in the run before it, on the same commit.
Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and 21 test files (the disk-floor, cell-limit, spawn, vitals and quota tests, plus 12 that use the changed helpers). All passed. The real-cell tests skip here because the sandbox runs servers on ports instead of unix sockets, so only the forge's check runs them.
🤖 Generated with Claude Code
I've pushed the fix for the red check. The two child-forge cell failures came from my own change, and I fixed the stall test from main. I left the blob-page failure alone because it doesn't reproduce. The covering tests, the gate and tsc pass here, but the real bubblewrap cell can't run in this sandbox, so only the forge's check will show that a child cell boots again.
What follows is the pull description.
---
Child forges now get limits the kernel enforces. Each cell starts with a hard cap on file size, so the reported fallocate -l 50G on a small child fails at once. Each cell's hostname is its own wherever the host allows it, and the parent keeps a 15% disk floor. This does not finish item 1: the kernel still doesn't cap a child's total disk, because the pod can't mount a filesystem for each child. #10850 waits on four follow-up issues.
Done in this pull
- Limits set by every cell's first shell (
RLIMIT_LINEandcellRlimitsinsrc/apps/contain.ts). Each is set both soft and hard, so nothing inside the cell can raise it.
- File size (RLIMIT_FSIZE) is the cell's disk size. For a small child that is 2048 MiB × 2048 = 4194304 blocks of 512 bytes. fallocate or dd past that fails with SIGXFSZ.
- Open files (RLIMIT_NOFILE) is 8192.
- Processes (RLIMIT_NPROC) is 4096, threads included. The kernel counts them per user namespace, so each cell has its own count.
- Hostname (item 6).
- A child's cell is named after the child, a machine shell after the machine, and any other cell is called cell (cellHostname).
- The name is only passed where the host allows it. The cell probe asks once whether bwrap may use --hostname (hostnameArgs).
- The check machine's bwrap refuses sethostname with EPERM. The previous push passed the flag anyway, and every child cell failed to come up. On such a host a cell now keeps the pod's name instead of not starting.
- Disk floor (item 5, part). When the parent's volume has under 15% free (
src/spawn/disk_floor.ts):
- quotaRefusal refuses to make a new child forge, and the New forge page and menu say why.
- A child cell won't start, and a sleeping child stays asleep.
- Vitals shows a disk-floor line between 10% and 15% free.
- Shared disk reading. All default disk readings go through
src/kernel/disk_reading.ts. The test preload, and every helper the suite starts as its own process, give it a roomy disk. So a check host near 15% free doesn't refuse the cells the tests boot. - Memory, processes and CPU (items 2 and 3). These were already enforced through the cgroup. A new test reads the files written for a small child:
memory.max2147483648,pids.max512,cpu.max"100000 100000" andio.weight100. - Stall test fix.
test/mcp-system-stalls.test.ts(from #10911) depended on test order. Its 5 s stall window reached back over requests an earlier file in the same shard had just made, so the stall was counted against those routes instead of the job. It now waits 400 ms, then stalls for 300 ms (over the 250 ms limit), so the window holds only the job. This was a wrong test, not a code fault.
Not done, and why
- Item 1, a separate filesystem per child. The pod runs as uid 1000 with every capability dropped. That rules out loop-mounting an image or setting a project quota. #10851 asks the operator to choose between a privileged mount helper and a volume per child.
- Below the floor: app and check cells are not refused, running children are not moved to read-only, and the owner gets no notification. These are in #10852.
- Item 7, showing each child its size and usage: #10853.
io.maxper cell: #10854.the blob page's switch > a kind with no renderer…failed once on the check. It passed five runs in a row here and this pull doesn't touch that code, so it is left alone.
Tests
test/cell-kernel-limits.test.tsruns the cells' exact limit line under/bin/shand checks each limit:
- a 50G fallocate fails and allocates nothing;
- dd stops at the cap;
- opening too many files gives EMFILE;
- raising a limit again has no effect.
- A new case checks that a host refusing sethostname gets no --hostname.
test/disk-floor.test.tscovers the floor at the 15% boundary, the refusal to make a child, a refused child start, and the vitals line.- The gate,
tscand the covering tests pass here. The real bubblewrap cell is skipped in this sandbox because it has no unix sockets; the forge's check runs it.
To try: in a small child's shell, fallocate -l 50G big fails with "File size limit exceeded". hostname prints the child's name on hosts that allow it.
🤖 Generated with Claude Code
I merged main into this branch. The only conflict was in test/mcp-system-stalls.test.ts. Both sides had fixed the same flaky stall test in the same way: wait a moment, then use a 300 ms stall window so that only the named job falls inside it. main waits 320 ms; this branch waits 400 ms. I kept the 400 ms wait and its longer comment, because the extra time is a safer margin after earlier test files' requests in a shard. Everything else from main merged on its own, including the docs/APPS.md changes.
bun scripts/gate.ts and bun x tsc --noEmit pass, and so does test/mcp-system-stalls.test.ts (1 pass, 0 fail). I ran only that test file. The forge's check runs the full suite on this push.
🤖 Generated with Claude Code
Merged main again. The conflict in test/mcp-system-stalls.test.ts was only in the comment above the 400 ms wait. main had already taken the same 400 ms wait and 300 ms stall window as this branch, so I kept main's one-line comment. The test now matches main exactly, and the branch's own changes (a child forge's disk, memory, CPU and process limits) are untouched.
bun scripts/gate.ts and bun x tsc --noEmit pass, and so does test/mcp-system-stalls.test.ts. I ran only that test file; the forge's check runs the full suite on this push.
🤖 Generated with Claude Code
Approved from the diff and the description; I did not open the preview or the screenshots. The 50G fallocate on a small child now fails through a soft and hard RLIMIT_FSIZE set by the cell first shell. The hostname is probed before use, so a host that refuses sethostname still starts its cells. The 15% disk floor refuses a new child, refuses a child start and shows in vitals. Tests try the real limits under /bin/sh and the floor at its boundary. The how-to is in docs/APPS.md and README is untouched. No new dependency. This is infrastructure, so it needs no TUI screen. The file cap bounds one file, not the total, and the pull says so; the follow-ups are #10851 to #10854. Two things I would do next: (1) Show the floor to a person on the page, not only in vitals. When the New forge page is refused for the floor, say what to do (free space) and link /settings/capacity, so the refusal is worded for the person who hit it. (2) Move DISK_FLOOR out of spawn/ into kernel/ (for example next to disk_reading.ts), so kernel/vitals.ts no longer imports from spawn/.
This pull makes the kernel enforce a hard limit on how big one file can grow inside a cell, so the reported
fallocate -l 50Gon a small child now fails at once. It does not finish item 1: the kernel still doesn't cap a child's total disk, because the pod can't mount a filesystem for each child. #10850 waits on four follow-ups I opened.Done in this pull
RLIMIT_LINEandcellRlimitsinsrc/apps/contain.ts). Each is set both soft and hard, so nothing inside the cell can raise it again.- File size (
RLIMIT_FSIZE) is the cell's disk size. For a small child that is 2048 MiB × 2048 = 4194304 blocks of 512 bytes.fallocateandddpast it fail with SIGXFSZ. - Open files (RLIMIT_NOFILE) is 8192. - Processes (RLIMIT_NPROC) is 4096, threads included. The kernel counts these separately for each cell.--hostname <child name>, a machine shell gets the machine's name, and any other cell getscell(cellHostname). Prompts no longer show the pod's name.src/spawn/disk_floor.ts):-
quotaRefusalrefuses to make a new child forge, and the New forge page and menu say why. - A child cell won't start, and a sleeping child stays asleep. - Vitals shows adisk-floorline between 10% and 15% free. Under 10%, the existing "Disk is nearly full" line still applies.src/kernel/disk_reading.ts. The test preload hands it a roomy disk, so no test fails because of the disk of the machine it runs on.memory.max2147483648,pids.max512,cpu.max"100000 100000" andio.weight100.cpu.maxis a fixed time budget, so it holds the same on a 64-core host.Not done, and why
charts/app/templates/deployment.yaml). That rules out loop-mounting an ext4 or xfs image or setting a project quota; inside a user namespace only tmpfs, which is memory, can be mounted. The file-size limit caps one file, not their sum:fallocate -n(keep size) or many smaller files can still fill the shared disk until the existing check, which runs every 15 seconds, stops the cell. #10851 asks the operator to choose between a small privileged mount helper and a volume per child.io.maxper cell: in #10854.Each follow-up is linked so #10850 and #586 wait on it.
Tests
test/cell-kernel-limits.test.tsruns the cells' exact limit line under/bin/shand tries to break each limit:- a 50G
fallocatefails and allocates nothing; -ddstops at the cap, while a smaller file is written whole; - opening too many files gives EMFILE; - raising a limit back has no effect.test/disk-floor.test.tscovers the floor's wording at the 15% boundary, the refusal to make a child, a refused child start, and the vitals line.tscpass, and the tests covering the change pass. The bubblewrap part of the cell, including the hostname as the kernel actually sets it, is skipped here (no unix sockets in this sandbox); the forge's check runs it.To try: on a small child's shell,
fallocate -l 50G bignow fails with "File size limit exceeded", andhostnameprints the child's name.🤖 Generated with Claude Code
Closes #10850