code wiki / (root) / nx_daemon_reap_lib.nx

nx_daemon_reap_lib.nx source

↩ module page · 85 lines · 5536 B

1// nx_daemon_reap_lib.nx -- THE ONE CHILD REAPER for every daemon in the estate that forks. 2// 3// WHY (measured 2026-09-03, filed as debt 1788453613, worklist knowledge/status/daemon_reap_worklist.md): 4// two `ps ... | grep defunct` samples 8 seconds apart on the NAS showed the SAME zombie pids with etimes 5// rising 5s -> 13s. That is a LEAK, not the one-loop lag a reaping daemon shows. Sixteen daemons each 6// forked a child at one instant and none of them waited. The control was in the same data: nx_tools_api_se 7// and sites.elf CHANGED zombie pid between the two samples -- those two reap, and their children rotate. 8// Five more zombies had never been reaped at all, the worst 32.8 DAYS old with ppid 1. 9// 10// AND THE FIX ALREADY EXISTED, IN EXACTLY ONE SIBLING. buildroot/runtime/bin/nx_sites_daemon.nx:532 runs a 11// non-blocking drain on every accept-loop iteration and :540 block-reaps one child at the concurrency cap. 12// It is correct, it is commented, and it was never shared -- so sixteen organs re-derived the same fork 13// pattern without it. *A FIX THAT LIVES IN ONE ORGAN AND NOT ITS SIBLINGS IS HALF A FIX, AND THE MISSING 14// HALF IS INVISIBLE UNTIL SOMETHING COUNTS THE ZOMBIES.* 15// 16// CHECK-BEFORE-BUILD, RECORDED: nx_capsearch over 7,264 organs (corpus_complete=1) for 17// "daemon reap child wait shared lib" returned nx_orphan_reap (kills orphaned gate daemons by cmdline), 18// nx_tmpstore_reap (scratch seg-stores), nx_lock_reap_core (stale LOCKS), nx_toolreg_reap (registry rows) 19// and nx_jobclaim_reap_gate (dead job claims). NOT ONE OF THEM WAITS ON A CHILD PROCESS. This lib is the 20// missing primitive, not a second ruler. 21// 22// WHY A LIB AND NOT SIXTEEN EDITS: when N organs must agree, make disagreement impossible by construction. 23// The WNOHANG-vs-blocking distinction, the -1 (any child) argument and the live-counter arithmetic are 24// exactly the three things sixteen hand-written copies would each get subtly wrong. 25// 26// THE STATUS BUFFER IS THE CALLER'S, ON PURPOSE. A daemon mmaps it ONCE before its accept loop (and before 27// its first fork, so children inherit it copy-on-write: one page for the whole process tree). Allocating 28// inside these functions would put an mmap in the hot loop of every forking daemon in the estate. 29// license_tier: ORIGINAL No hw writes (Rule 26). lib (no main) 30import "nx_syscalls.nx" 31 32// wait4 options. WNOHANG=1: return immediately if no child has exited. 0: block until one does. 33const DR_WNOHANG: i64 = 1 34const DR_BLOCKING: i64 = 0 35// wait4 pid argument: -1 means ANY child, which is what a fork-per-connection server wants. Waiting on a 36// specific pid is a different job (a oneshot tool that forked exactly one helper) and is deliberately not 37// wrapped here -- sys_wait4(pid, st, 0) is already clear at those call sites. 38const DR_ANY_CHILD: i64 = 0 - 1 39 40// Drain every child that has ALREADY exited, without blocking. Returns how many were reaped. 41// 0 is the normal steady-state answer and is NOT an error -- it means nothing had exited since last call. 42// A negative wait4 (ECHILD: no children at all) ends the loop and also returns 0, so a daemon that has not 43// forked yet is indistinguishable from one whose children are all still running. That is correct: neither 44// state has anything to reap, and inventing a third answer here would push a decision into a function that 45// has no business making it. 46// CALL THIS ON EVERY LOOP ITERATION, INCLUDING IDLE ONES. The incumbent's listener carries a socket 47// timeout specifically so accept() returns periodically when idle and this drain still runs -- otherwise 48// the parent blocks in accept() while its finished children sit as zombies. A reaper that only runs when 49// new work arrives stops reaping exactly when traffic stops, which is when nobody is watching. 50func dr_reap_nonblocking(status: *i64) -> i64 { 51 var n: i64 = 0 52 while sys_wait4(DR_ANY_CHILD, status, DR_WNOHANG) > 0 { n = n + 1 } 53 return n 54} 55 56// Block until exactly one child exits. Returns 1 if one was reaped, 0 if there was none to wait for. 57// THIS IS THE CONCURRENCY THROTTLE, not a cleanup path: call it when live children have reached the cap, 58// so the fork rate becomes self-limiting instead of unbounded. On a box whose array is the bottleneck, 59// an unbounded fork-per-connection server is a load source with no ceiling. 60func dr_reap_one(status: *i64) -> i64 { 61 if sys_wait4(DR_ANY_CHILD, status, DR_BLOCKING) > 0 { return 1 } 62 return 0 63} 64 65// The two above, composed the way the incumbent composes them, maintaining the caller's live counter. 66// live[0] is the number of outstanding children. Returns the number reaped this call. 67// A caller that keeps no counter can use dr_reap_nonblocking directly; a caller that throttles needs the 68// count to be right, and a hand-maintained counter that drifts is how a cap silently stops capping. 69func dr_drain(status: *i64, live: *i64) -> i64 { 70 let n: i64 = dr_reap_nonblocking(status) 71 live[0] = live[0] - n 72 if live[0] < 0 { live[0] = 0 } 73 return n 74} 75 76// Throttle: if live[0] has reached cap, block for one child. Returns 1 if it waited and reaped, else 0. 77// Clamped at 0 for the same reason dr_drain clamps: a counter that goes negative turns a cap into a 78// permanent grant, which is the failure direction nobody notices. 79func dr_throttle(status: *i64, live: *i64, cap_children: i64) -> i64 { 80 if live[0] < cap_children { return 0 } 81 let r: i64 = dr_reap_one(status) 82 live[0] = live[0] - r 83 if live[0] < 0 { live[0] = 0 } 84 return r 85}