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}