fix: reap orphaned processes instead of leaking zombies (#28) - #29
Conversation
Spacebot runs as PID 1 in the container, which makes it the namespace's init: it inherits every orphaned process, not just the ones it spawned. Shell commands routinely leave grandchildren behind — `sh -c "cargo build"` exits while its cargo/node/build-script descendants outlive it — and nothing was collecting them. 101 zombies accumulated in ~29h. Two layers now prevent that: - `tini` as PID 1 (ENTRYPOINT) reaps anything re-parented to it, including processes outliving spacebot's own startup and shutdown. - `process::reaper` reaps in-process when spacebot *is* PID 1, covering deployments that bypass the entrypoint. The reaper never calls `waitpid(-1, ...)`: that races Tokio's process driver, and the caller only learns which child it collected after the status is already consumed — losing it for the task that was awaiting. Instead it enumerates children from /proc and waits per-PID, skipping any a spawn site has claimed. Ownership is checked before the status is consumed, so the guarantee holds rather than being best-effort. Claiming also covers the reverse leak: `run_streaming` drops its Child when the 5s wait times out, so Tokio never reaps it and the SIGCHLD is long gone. Releasing a claim reaps that PID directly. `GET /api/status` now reports `zombie_processes` and `reaped_orphans`, making the acceptance criterion checkable without `docker exec`. Verified: 25 orphaned zombies -> 0 after a sweep, and claimed children retain exit codes 0/7/42 across 50 concurrent sweeps.
Code Review — PR #29: reap orphaned processes instead of leaking zombies1. SummaryThis PR adds a process reaper to prevent zombie accumulation from orphaned child processes. It introduces 2. FindingsP1 (Blocking)None. P2 (Should Fix)None. P3 (Nit)
(The earlier per-part notes about 3. SecurityNo security issues identified.
4. VerdictAPPROVE — the change is well-scoped and correct; remaining items are documentation/comment nits that can be folded into a follow-up or fixed before merge. AI Review · Verdict: APPROVE · Diff-Score: 0.90 |
Three P3 items from the review of #29, no behaviour change. - docs/docker.md opened by saying spacebot runs as PID 1, which the same section then contradicts by making tini the entrypoint. Lead with the actual arrangement (tini is PID 1, spacebot is its child) and say why the distinction matters, since that is the whole reason the reaper is conditional. - The SAFETY comment on waitpid justified WNOHANG but not the pointer. State the invariant that actually makes the call sound: `status` is a valid, aligned c_int local that outlives the call and is the only thing libc writes through. - `_owned` in run_streaming said what the binding was, not what it was for. `_reaper_claim` names the reason it must stay alive. Co-authored-by: shipyard-ci <shipyard-ci@spacedrive.com>
Closes #28
Problem
Spacebot runs as PID 1 in the container, which makes it the namespace's init — it inherits every orphaned process, not just the ones it spawned. Shell commands routinely leave grandchildren behind (
sh -c "cargo build"exits while itscargo/node/build-scriptdescendants outlive it), and nothing was collecting them. 101 zombies in ~29 h.I reproduced this live on the running instance while investigating — spawning 20 orphan-producing shells left 20 zombies parked on PID 1, exactly the reported shape:
Each zombie holds a PID until reaped, so the count only grows for the life of the container. At the observed rate an uncapped container reaches the PID ceiling in days — after which nothing can spawn and worker launches fail for a reason that looks nothing like the cause.
Fix
Both options from the issue, because they cover different windows:
1.
tinias PID 1 (ENTRYPOINT, issue option 2) — a real init that reaps anything re-parented to it, including processes that outlive spacebot's own startup and shutdown.-gforwards signals to the process group so shutdown stays clean.2.
src/process/reaper.rs(issue option 1) — reaps in-process when spacebot is PID 1, covering deployments that bypass the entrypoint (baredocker runwithout--init).Why not
waitpid(-1, ...)The obvious implementation races Tokio's process driver. Both listen for
SIGCHLD, and a blanket wait reaps whichever child exited first — the caller only learns which after the status is already consumed. If it was Tokio-owned, that status is gone and the awaiting task fails withECHILD.My first draft had exactly this bug, and the test I wrote for it caught it (
No child processeson a child that should have been protected). The reaper now enumerates children from/proc/self/task/*/childrenand waits per-PID, skipping any a spawn site hasclaimed. Ownership is checked before the status is consumed, which makes the guarantee real rather than best-effort.The reverse leak
run_streamingdrops itsChildwhen the 5 s wait times out — Tokio never reaps it and theSIGCHLDhas already come and gone. Releasing a claim now reaps that PID directly, so it is collected immediately instead of parked until some unrelated process happens to exit.Observability
GET /api/statusnow reports the acceptance metric directly, so it is checkable withoutdocker exec:zombie_processesisOption— absent rather than a misleading0where/procis unavailable.Verification
Build note:
cargo checkon the full crate fails onethnum1.5.2 (lockfile-pinned, transitive via lancedb→datafusion) under rustc 1.97 —error[E0512]: cannot transmute between types of different sizes. I confirmed this is pre-existing and unrelated: a bare crate with onlyethnum@=1.5.2reproduces it, and1.5.3compiles fine. No error references any file in this PR.So I verified the module standalone against the same rustc:
Both properties, together:
Unit tests: 10/10 passing, deterministic across 3 consecutive runs. Clippy: clean under
-D warnings. rustfmt: clean.tini (extracted from the actual
bookwormpackage, verified at/usr/bin/tini) in subreaper mode: 0 zombies, against 35 accumulating on the real PID 1 at the same moment.Scope
Touches 8 files. Three unrelated modified files in the shared checkout (
src/llm/pricing.rs,src/llm/anthropic/params.rs,src/agent/worker.rs) belong to a concurrent worker and were deliberately not staged, per spacedriveapp#224.