sandbox: kill the step before letting a refused notification go - #30
Merged
Conversation
The CI job `The sandbox (linux/arm64)`, root cell, case `forkbomb (alt)` (`--allow=threads`, cap 64) failed twice in about ten runs of PR #27 and PR #29 with `stdout [forked 64], want []` while the report line `refused: process limit (64)` was correct. Measured on the Lima oracle (mc-k7, Ubuntu 26.04, kernel 7.0.0-30, aarch64, glibc) with a compiler built from origin/main: 14 runs in 100 as root, 13 in 100 unprivileged. A copy of forkbomb.mc that prints __errno_location() answered `forked 64 errno 38` -- ENOSYS -- on every failing run, never EAGAIN. Not answering a refused notification (step C note 12) is only half of "a refused call never returns": a pending notification is also released when its LISTENER goes away, and the kernel releases it with ENOSYS. P closes the listener in sb_go as soon as sb_supervise() returns, and the loop ends when the status pipe closes -- about a millisecond after the kill it just sent, while the kill reaches the STEP only through zap_pid_ns_processes() in J's exit path. A timestamped trace of a failing iteration shows refuse, kill, `status pipe closed -> break` and `sb_go: close the listener` inside one millisecond with the step alive; a 2 s sleep after the kill gave 0 failures in 60, which proves the ordering from the other side. The fix makes the order explicit. sb_refuse now SIGKILLs the task whose call it is (sb_kill_pid, by seccomp_notif.pid, which the kernel translates into the reader's pid namespace -- the number process_vm_readv already takes), then the box under it, and only then lets the notification go: sb_wait_gone() waits for POLLHUP on the listener, which is exactly `filter->users == 0`, asking for NO events so that a notification queued behind the refused one cannot wake it, bounded at the two seconds the wall clock already grants. After the fix, both cells, 200 iterations each: 0 with stdout, 0 with a wrong report. scripts/test-sandbox.sh is 55 ok / 0 failed / 1 skipped in each cell and sandbox-trace.sh --check is green (no profile was touched). 25 code lines in src/, nothing removed; stage0/, lib/ and tests/ untouched. check-inert against origin/main is identical everywhere; the five goldens move only for the bundle and the two new functions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
schivei
force-pushed
the
fix-forkbomb-flake
branch
from
September 6, 2026 00:37
af00391 to
96532b9
Compare
Collaborator
Author
|
Merging: CI 14/14 green on 96532b9 (both sandbox cells print |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sandbox: kill the step before letting a refused notification go
A patch.
stage0/,lib/andtests/are untouched; the code change is 25 lines insrc/sandbox.mcandsrc/seccomp.mc, nothing removed.The flake
CI job
The sandbox (linux/arm64), ROOT cell, caseforkbomb (alt)(the--allow=threadsvariant, cap 64) — twice in about ten runs (PR #27 run 33984786337, PR #29 run 33998821248, a
docs-only change):
with the report line
sandbox: refused: process limit (64)correct.Reproduced first, on the Lima oracle
mc-k7, Ubuntu 26.04, kernel 7.0.0-30, aarch64, glibc; the compiler built fromorigin/main(
build/mc-linux-arm64-gnu), 100 runs of the single case per cell:kernel.apparmor_restrict_unprivileged_userns=0)The mechanism, measured
1. The errno names it. A copy of
tests/sandbox/forkbomb.mcthat printsld32(__errno_location())of the fork that failed answered, on every failing run:never
EAGAIN(11), which is what theRLIMIT_NPROCbackstop would have given.ENOSYSis what the kernel hands a target whose pendingSECCOMP_RET_USER_NOTIFis releasedbecause the listener went away (
seccomp_unotify(7)). So not answering the notification(step C note 12) is only half of "a refused call never returns": the other half is that nobody
may close the listener while the step can still run.
2. Where the listener is closed.
sb_gocloses it as soon assb_supervise()returns, andthe supervisor loop ends when the status pipe closes — about a millisecond after the kill it just
sent. The kill, meanwhile, has not arrived:
sb_kill_box()SIGKILLs J and I, and the STEP (C)dies only through
zap_pid_ns_processes()in J's own exit path. A second probe put timestampedtraces in P; a failing iteration reads
— refusal, kill and close inside the same millisecond, with the step still alive.
3. The ordering proved from the other side. A third probe (a 2 s sleep in
sb_refuseafterthe kill, nothing else): 0 failures in 60 runs.
The fix
sb_refusenow does three things in one order, and the order is the whole of it:sb_kill_pid(ld32(sb_notifp() + SB_NF_PID)); // the task whose call this is sb_kill_box(); // the box under it sb_wait_gone(SB_GONE_MS); // and only then let the notification gosb_kill_pidcan name the task becauseseccomp_notif.pidis translated into the reader'spid namespace, which is P's — the same number
process_vm_readvalready takes insb_vm_read.sb_wait_gonewaits for POLLHUP on the listener, which is exactlyfilter->users == 0:no process under that filter is left, so no pending notification can be released into a program
that could still act on it. It asks for no events, because
POLLHUPis reported whateverthe mask says and a notification queued behind the refused one must not wake it — answering one
is precisely what must not happen there. Bounded at two seconds, the same grace the wall clock
already gives the box after its own kill.
The expectation was not relaxed:
forkbomb (alt)still asserts stdout[]and the report linerefused: process limit (64).After the fix
Same oracle, same two cells, the final build, 200 iterations each:
Full suite, both cells:
sh scripts/sandbox-trace.sh --checkgreen (the profile lists were not touched):The refusal path costs no measurable time — the box's processes die at once, so the wait returns
on the first
ppoll; the box overhead lines areplain 483 us, boxed 4187 us(root) andplain 472 us, boxed 4084 us(unprivileged), the machine's usual figures.Gates (macOS)
make bundlewas re-run BEFORE bootstrapping (93 files, raw 1170795 -> LZ 548311, blob 549473 B).make checkRC 0, zero FAIL, 7m59s:make check-linux-hostRC 0, all four cells, each after its ownmc2l.o == mc3l.oand withthe cross proof green:
scripts/check-inert.shagainst abuild/mc1built fromorigin/main:A sandbox fix emits no different byte; the goldens move for the bundle and the two new functions.
The five goldens
Rewritten once each, only after its own criterion (
mc2.sha256after the empty--dump-asmdiffand
cmp build/mc2.o build/mc3.o; the Linux pair deleted and re-recorded bymake check-linux-host; the Windows pair cross-computed pertests/golden/README.mdandconfirmed byte for byte by
build/mc2):Docs
docs/specs/M43.md§ Implementation notes — the forkbomb flake (new, four numbered points withthe measurements above)
docs/reference/sandbox.md— the "A refused call never returns" paragraph gains its other half,the order
CLAUDE.md§ State — one entry🤖 Generated with Claude Code