resource: release process slots on exit, not on wait - #230
Open
congwang-mk wants to merge 2 commits into
Open
congwang-mk wants to merge 2 commits into
congwang-mk wants to merge 2 commits into
Conversation
fork(2) was only notified when argv safety was required, on the theory that it carries no process-limit risk. It creates a process like any clone, so a raw fork(2) loop was never counted: 30 live children under a limit of 5. glibc's fork() is clone(2) underneath, which is why ordinary programs never showed this. Notify fork(2) unconditionally. Releasing slots on exit also depends on it: an uncounted birth followed by a counted death would drive the count below the real number of processes. Signed-off-by: Cong Wang <cwang@multikernel.io>
A slot was taken when a fork was allowed but freed when a blocking wait4/waitid was entered, assuming such a wait reaps one child. It is neither necessary nor sufficient for a process going away: - Children reaped with WNOHANG, auto-reaped under SIGCHLD SIG_IGN, or orphaned never freed their slot, so a long-lived sandbox eventually refused every fork (issue #229). - A blocking wait that reaps nothing (ECHILD) still freed a slot, so the limit could be bypassed: 40 live children under a limit of 10. Free the slot from the pidfd exit watcher instead, once per process. exit and exit_group are now notified so a child that makes no other notified syscall still registers, and handle_fork polls the pidfds before refusing a fork so it cannot lose a race with the watcher. A child killed by a signal before any notified syscall still leaks its slot (upward only), and zombies no longer count. Signed-off-by: Cong Wang <cwang@multikernel.io>
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.
Fixes #229.
Problem
The process limit took a slot when a fork was allowed, but freed it when a blocking
wait4/waitidwas entered, on the assumption that such a wait reaps exactly one child. Entering a wait is neither necessary nor sufficient for a process going away, so the count drifted in both directions.Reproduced on main (0.8.8):
WNOHANG(the report)SIGCHLDset toSIG_IGN, kernel auto-reapswaitpid(1, 0)(ECHILD) before each forkfork(2)syscall on x86_64The first three make the limit a lifetime fork budget, which is what the reporter hit with a long-lived agent. The last two let a sandbox run past the limit. Raw
fork(2)was never counted because it was only notified when argv safety was required; glibc'sfork()isclone(2), so ordinary programs never showed it.Fix
Two commits:
fork(2). It is now notified unconditionally, so every process creation reserves a slot.ProcessIndexkeyed byPidKey. A pidfd turns readable however the process dies, so_exit, signals and the OOM killer are all covered. The slot belongs to the thread-group leader, so registering a thread also registers its leader.Two gaps had to be closed for exit-based release to work:
exitandexit_groupare now notified, so it registers on its way out whilepidfd_openstill succeeds.handle_forkpolls the slot-holding pidfds before refusing a fork. The kernel marks the pidfd readable before the parent can observe the death, so this is deterministic.handle_wait, its dispatch entry and the BPF carve-outs for non-blocking waits are removed. No ptrace, user namespace or cgroup is involved.Known limits
openat,close,socket,cloneare always notified) and the leak only pushes the count up, so it cannot be used to get past the limit.Continue(ENOMEM, host RLIMIT_NPROC) also leaks a slot, as it did before.The issue also asks for a way to disable the limit. That is left out: with the accounting fixed, a large
-Pvalue is effectively unlimited, and treating 0 as unlimited would fail open for zeroed configs in the Go and C bindings.Tests
test_resource.rs, each forking more children in sequence than the limit allows at once:WNOHANGreaping,SIGCHLDauto-reap, orphaned grandchildren, concurrent short-lived children, the ECHILD wait loop (must stay at the limit), and rawfork(2). TheWNOHANG, auto-reap, orphan and ECHILD tests failed before the fix.max_processes=2), which exercises the at-limit pidfd poll.test_policy_fn.pyandtest_sandbox.pypass (97). The new process-limit tests passed 36 consecutive runs. The rest of the Python suite and the Go tests were not run locally.🤖 Generated with Claude Code