Skip to content

resource: release process slots on exit, not on wait - #230

Open
congwang-mk wants to merge 2 commits into
mainfrom
proc-limit-exit-accounting
Open

congwang-mk wants to merge 2 commits into
mainfrom
proc-limit-exit-accounting

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Fixes #229.

Problem

The process limit took a slot when a fork was allowed, but freed it when a blocking wait4/waitid was 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):

Case Limit Result
Children reaped with WNOHANG (the report) 50 fork 50 fails with EAGAIN
SIGCHLD set to SIG_IGN, kernel auto-reaps 10 fork 10 fails
Double fork, orphans reaped by init 10 fork fails after about 10 rounds
Blocking waitpid(1, 0) (ECHILD) before each fork 10 40 live children (bypass)
Raw fork(2) syscall on x86_64 5 30 live children (bypass)

The 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's fork() is clone(2), so ordinary programs never showed it.

Fix

Two commits:

  1. Count bare fork(2). It is now notified unconditionally, so every process creation reserves a slot.
  2. Release slots on exit, not on wait. The pidfd exit watcher frees the slot, exactly once per process, through a slot table in ProcessIndex keyed by PidKey. 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:

  • A child that forks and exits without any notified syscall was never registered. exit and exit_group are now notified, so it registers on its way out while pidfd_open still succeeds.
  • The watcher runs asynchronously, so a parent that reaps a child and forks again at once could beat it. handle_fork polls 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

  • A child killed by a signal before it makes any notified syscall is never registered, so its slot leaks. The window is small (openat, close, socket, clone are always notified) and the leak only pushes the count up, so it cannot be used to get past the limit.
  • A fork the kernel rejects after Continue (ENOMEM, host RLIMIT_NPROC) also leaks a slot, as it did before.
  • Zombies no longer count against the limit, since the slot is freed at exit rather than at reap.

The issue also asks for a way to disable the limit. That is left out: with the accounting fixed, a large -P value is effectively unlimited, and treating 0 as unlimited would fail open for zeroed configs in the Go and C bindings.

Tests

  • New integration tests in test_resource.rs, each forking more children in sequence than the limit allows at once: WNOHANG reaping, SIGCHLD auto-reap, orphaned grandchildren, concurrent short-lived children, the ECHILD wait loop (must stay at the limit), and raw fork(2). The WNOHANG, auto-reap, orphan and ECHILD tests failed before the fix.
  • The existing blocking-wait test now runs with no headroom (max_processes=2), which exercises the at-limit pidfd poll.
  • Locally: Rust lib (771) and integration (444) suites pass in release mode with one test thread; Python test_policy_fn.py and test_sandbox.py pass (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

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process limit leaks slots for children reaped with WNOHANG, eventually refusing all forks

1 participant