code wiki / (root) / nx_restart_guard.nx

nx_restart_guard.nx source

↩ module page · 119 lines · 8806 B

1// nx_restart_guard.nx -- Prevents a crashing daemon from restarting indefinitely by enforcing burst limits and exponential backoff. 26123 nishihost/buildroot/runtime/nx_restart_guard.nx 3// nx_restart_guard.nx -- SUPERVISOR CRASH-LOOP GUARD (closes hosting_research gap #4 4// "watchdog-restart-loop-guard", 3/0 CONFIRMED -- the root cause of the live 2026-06-16 nx_hostctl 5// crash-loop). A bedrock supervision primitive any supervisor (nx_hostctl / nx_keeper / nx_torrent_up) 6// composes so a daemon that crashes immediately on start is NOT respawned forever. 7// 8// module: nishi-core.supervision.restart_guard 9// capability: DAEMON_ROBUSTNESS 10// 11// INCUMBENT (benchmark, sourced via the Nishi researcher -> hosting_research.tsv): systemd unit restart 12// policy -- Restart=on-failure + RestartSec (wait before restart, default 100ms) + StartLimitBurst (N, 13// default 5) within StartLimitIntervalSec (T, default 10s): more than N restarts in T -> unit enters 14// "failed", systemd stops trying. We MATCH that burst-limit semantics, and EXCEED it on two measured axes: 15// (1) DETERMINISTIC / REPLAYABLE -- given the same restart timestamps, the SAME verdict every run, so a 16// supervision session can be replayed bit-for-bit (systemd's behavior depends on wall-clock + monotonic 17// boot time and cannot be replayed). The caller passes `now`; nothing reads a hidden clock here. 18// (2) EXPONENTIAL BACKOFF -- rg_backoff_ms doubles the wait each restart (capped), vs systemd's FIXED 19// RestartSec, so a flapping daemon backs off instead of hammering at a constant rate. 20// 21// STATE per daemon (caller-owned, 2 words): ws[0] = window-start timestamp, cnt[0] = restarts in window. 22 23const RG_DEF_INTERVAL_MS: i64 = 10000 // systemd StartLimitIntervalSec default (10s) 24const RG_DEF_BURST: i64 = 5 // systemd StartLimitBurst default 25const RG_DEF_BASE_MS: i64 = 100 // systemd RestartSec default (100ms) = our backoff base 26const RG_DEF_MAX_MS: i64 = 30000 // backoff ceiling (30s) so it never waits unboundedly 27// HEALTH-RESET threshold: a daemon that ran healthy for >= this long since its last restart is NOT in a TIGHT 28// crash-loop -> reset its crash counter (auto-recovery). MUST be comfortably above the supervisor poll cadence 29// (nx_hostctl polls ~15s, so a dead daemon always shows elapsed>=~15s) -- set to 60s so only daemons that die 30// within ~1min REPEATEDLY are treated as crash-looping; anything that ran a full minute is "healthy -> reset". 31const RG_HEALTH_RESET_MS: i64 = 60000 32 33// S-CLASS crash-loop containment = Kubernetes CrashLoopBackOff + Erlang/OTP restart-intensity + CAPPED EXPONENTIAL 34// BACKOFF (grounded in the banked SOTA: knowledge/library/rel_erlang_otp.txt, rel_backoff.txt, rel_circuit_breaker.txt). 35// State per daemon (caller-owned, 2 words): ws[0] = last-restart timestamp (ms), cnt[0] = consecutive-crash count. 36// Called by the supervisor ONLY when the daemon is dead. `cap_ms` = backoff ceiling; `base_ms` = first backoff step. 37// (The hostctl reuses its two guard consts for these positionally.) 38// - HEALTH-RESET: ran healthily for >= RG_HEALTH_RESET_MS since the last restart -> cnt=0 (loop over; auto-recover). 39// - FIRST death (cnt==0): restart immediately (minimal downtime for a one-off crash). 40// - REPEATED crashes: WAIT an exponential backoff (base doubling, capped at cap_ms) before each restart, so a hard 41// crash-loop is CONTAINED to ~1 restart per cap_ms (e.g. 5 min) -- bounded CPU/log blast radius. This RETIRES the 42// old flat "5/window-reset-each-window" which allowed a slow INFINITE loop (5 restarts every 2 min forever). 43// returns 1 (restart NOW) or 0 (wait -- the loop is being contained by backoff). 44func rg_should_restart(ws: *i64, cnt: *i64, now: i64, cap_ms: i64, base_ms: i64) -> i64 { 45 let elapsed: i64 = now - ws[0] 46 if elapsed >= RG_HEALTH_RESET_MS { cnt[0] = 0 } // ran healthy >= reset thresh -> auto-recover 47 var backoff: i64 = 0 48 if cnt[0] > 0 { backoff = rg_backoff_ms(cnt[0], base_ms, cap_ms) } // exp backoff ONLY on repeated crashes 49 if elapsed < backoff { return 0 } // backoff not elapsed -> WAIT (contain the loop) 50 ws[0] = now 51 cnt[0] = cnt[0] + 1 52 return 1 53} 54 55// exponential backoff before the Nth restart: base * 2^(count-1), capped at max. count is 1-based. 56func rg_backoff_ms(restart_count: i64, base_ms: i64, max_ms: i64) -> i64 { 57 if restart_count <= 1 { return base_ms } 58 var v: i64 = base_ms; var i: i64 = 1 59 while i < restart_count { v = v * 2; if v >= max_ms { return max_ms } i = i + 1 } 60 return v 61} 62 63// how many restarts remain before the loop-guard trips (window-aware inspector). 64func rg_remaining(cnt: i64, burst: i64) -> i64 { let r: i64 = burst - cnt; if r < 0 { return 0 } return r } 65 66// ---- BIND-GRACE: alive-but-never-listening (2026-09-02, debt 1788361379) ---------------------------------------- 67// THE STATE PID-LIVENESS CANNOT SEE. MEASURED: a supervisor respawned nx_docportal_admin_daemon after a SIGSEGV and 68// the respawn spun 15.9h of CPU inside its shard pre-warm with its port never opened; proc_alive_by_name was 69// satisfied throughout, so /search answered 503 for ~16h while every health surface read UP. A daemon that opens 70// its port only AFTER a warm-up (10-15s here, once 6 min behind a registry lock) makes a single refusal worthless 71// as evidence and a kill on it a crash-loop generator; a refusal that PERSISTS past a grace is evidence. 72// PURE, like rg_should_restart: the supervisor supplies what it observed (alive, socket dead as CONFIRMED by two 73// connect-only refusals, how many consecutive polls the socket has read dead, the grace in polls) and this decides. 74// 1 = the grace is exhausted, kill the wedged husk and respawn. 0 = nothing to do (healthy, dead-by-PID which the 75// liveness path owns, or still inside the grace -- the caller keeps counting). Refereed by nx_restart_guard_gate. 76// ---- STALE-CYCLE LIVENESS (2026-09-02) -------------------------------------------------------------- 77// A process that is ALIVE BY NAME is not thereby DOING ITS JOB: nx_daemon_supervisor's /status page is 78// served by a forked child that outlives its parent, so name-liveness read ALIVE for a dead fleet 79// supervisor indefinitely (measured the day the name-only guard shipped). The parent proves itself by 80// ADVANCING a counter every loop; this predicate turns that into a decision a supervisor can act on and a 81// gate can pin. st[0] = last counter seen, st[1] = consecutive observations without an advance. cyc < 0 82// means "no observation" and resets the streak WITHOUT declaring staleness -- silence is not evidence of 83// death (the surface is a courtesy). Returns 1 exactly when the counter has been observed unchanged on 84// stale_polls CONSECUTIVE calls, and resets the streak so the caller acts once per episode, not on every 85// poll after. PURE: no I/O, so nx_restart_guard_gate pins every branch. 86func rg_stale_cycle(st: *i64, cyc: i64, stale_polls: i64) -> i64 { 87 if cyc < 0 { st[1] = 0; return 0 } 88 if cyc != st[0] { st[0] = cyc; st[1] = 0; return 0 } 89 st[1] = st[1] + 1 90 if st[1] < stale_polls { return 0 } 91 st[1] = 0 92 return 1 93} 94 95// SILENCE WITH A NAME IS THE ORPHAN'S SIGNATURE (2026-09-02). rg_stale_cycle deliberately treats a /status that does 96// not answer, or answers without a counter, as no evidence -- but an ORPHANED status child (its parent dead before 97// the first publish) answers every poll with an EMPTY body forever: name-alive, port answering, counter absent. 98// Measured that day on BOTH supervisor generations (an 83-byte body, no cycle line, for over forty minutes), while 99// the guard above correctly abstained and nothing revived the fleet supervisor. Silence that long with the name 100// alive is not a courtesy withheld; it is the one shape a dead parent leaves behind. st[2] = consecutive silent 101// observations. Returns 1 exactly on the silent_polls-th consecutive silent poll and resets the streak so the 102// caller acts once per episode; any counter observation (cyc >= 0) resets it; an unset bound never authorises a 103// kill. PURE: no I/O, so nx_restart_guard_gate pins every branch (T16-T19). 104func rg_silent_stale(st: *i64, cyc: i64, silent_polls: i64) -> i64 { 105 if cyc >= 0 { st[2] = 0; return 0 } 106 if silent_polls <= 0 { return 0 } 107 st[2] = st[2] + 1 108 if st[2] < silent_polls { return 0 } 109 st[2] = 0 110 return 1 111} 112 113func rg_bind_grace_expired(alive: i64, socket_dead: i64, dead_polls: i64, grace_polls: i64) -> i64 { 114 if alive != 1 { return 0 } 115 if socket_dead != 1 { return 0 } 116 if grace_polls <= 0 { return 0 } // an unset grace can never authorise a kill: abstain, never acquit-by-kill 117 if dead_polls < grace_polls { return 0 } 118 return 1 119}