nx_restart_guard.nx source
↩ module page · 62 lines · 4638 B
1// nx_restart_guard.nx -- SUPERVISOR CRASH-LOOP GUARD (closes hosting_research gap #4
2// "watchdog-restart-loop-guard", 3/0 CONFIRMED -- the root cause of the live 2026-06-16 nx_hostctl
3// crash-loop). A bedrock supervision primitive any supervisor (nx_hostctl / nx_keeper / nx_torrent_up)
4// composes so a daemon that crashes immediately on start is NOT respawned forever.
5//
6// module: nishi-core.supervision.restart_guard
7// capability: DAEMON_ROBUSTNESS
8//
9// INCUMBENT (benchmark, sourced via the Nishi researcher -> hosting_research.tsv): systemd unit restart
10// policy -- Restart=on-failure + RestartSec (wait before restart, default 100ms) + StartLimitBurst (N,
11// default 5) within StartLimitIntervalSec (T, default 10s): more than N restarts in T -> unit enters
12// "failed", systemd stops trying. We MATCH that burst-limit semantics, and EXCEED it on two measured axes:
13// (1) DETERMINISTIC / REPLAYABLE -- given the same restart timestamps, the SAME verdict every run, so a
14// supervision session can be replayed bit-for-bit (systemd's behavior depends on wall-clock + monotonic
15// boot time and cannot be replayed). The caller passes `now`; nothing reads a hidden clock here.
16// (2) EXPONENTIAL BACKOFF -- rg_backoff_ms doubles the wait each restart (capped), vs systemd's FIXED
17// RestartSec, so a flapping daemon backs off instead of hammering at a constant rate.
18//
19// STATE per daemon (caller-owned, 2 words): ws[0] = window-start timestamp, cnt[0] = restarts in window.
20
21const RG_DEF_INTERVAL_MS: i64 = 10000 // systemd StartLimitIntervalSec default (10s)
22const RG_DEF_BURST: i64 = 5 // systemd StartLimitBurst default
23const RG_DEF_BASE_MS: i64 = 100 // systemd RestartSec default (100ms) = our backoff base
24const RG_DEF_MAX_MS: i64 = 30000 // backoff ceiling (30s) so it never waits unboundedly
25// HEALTH-RESET threshold: a daemon that ran healthy for >= this long since its last restart is NOT in a TIGHT
26// crash-loop -> reset its crash counter (auto-recovery). MUST be comfortably above the supervisor poll cadence
27// (nx_hostctl polls ~15s, so a dead daemon always shows elapsed>=~15s) -- set to 60s so only daemons that die
28// within ~1min REPEATEDLY are treated as crash-looping; anything that ran a full minute is "healthy -> reset".
29const RG_HEALTH_RESET_MS: i64 = 60000
30
31// S-CLASS crash-loop containment = Kubernetes CrashLoopBackOff + Erlang/OTP restart-intensity + CAPPED EXPONENTIAL
32// BACKOFF (grounded in the banked SOTA: knowledge/library/rel_erlang_otp.txt, rel_backoff.txt, rel_circuit_breaker.txt).
33// State per daemon (caller-owned, 2 words): ws[0] = last-restart timestamp (ms), cnt[0] = consecutive-crash count.
34// Called by the supervisor ONLY when the daemon is dead. `cap_ms` = backoff ceiling; `base_ms` = first backoff step.
35// (The hostctl reuses its two guard consts for these positionally.)
36// - HEALTH-RESET: ran healthily for >= RG_HEALTH_RESET_MS since the last restart -> cnt=0 (loop over; auto-recover).
37// - FIRST death (cnt==0): restart immediately (minimal downtime for a one-off crash).
38// - REPEATED crashes: WAIT an exponential backoff (base doubling, capped at cap_ms) before each restart, so a hard
39// crash-loop is CONTAINED to ~1 restart per cap_ms (e.g. 5 min) -- bounded CPU/log blast radius. This RETIRES the
40// old flat "5/window-reset-each-window" which allowed a slow INFINITE loop (5 restarts every 2 min forever).
41// returns 1 (restart NOW) or 0 (wait -- the loop is being contained by backoff).
42func rg_should_restart(ws: *i64, cnt: *i64, now: i64, cap_ms: i64, base_ms: i64) -> i64 {
43 let elapsed: i64 = now - ws[0]
44 if elapsed >= RG_HEALTH_RESET_MS { cnt[0] = 0 } // ran healthy >= reset thresh -> auto-recover
45 var backoff: i64 = 0
46 if cnt[0] > 0 { backoff = rg_backoff_ms(cnt[0], base_ms, cap_ms) } // exp backoff ONLY on repeated crashes
47 if elapsed < backoff { return 0 } // backoff not elapsed -> WAIT (contain the loop)
48 ws[0] = now
49 cnt[0] = cnt[0] + 1
50 return 1
51}
52
53// exponential backoff before the Nth restart: base * 2^(count-1), capped at max. count is 1-based.
54func rg_backoff_ms(restart_count: i64, base_ms: i64, max_ms: i64) -> i64 {
55 if restart_count <= 1 { return base_ms }
56 var v: i64 = base_ms; var i: i64 = 1
57 while i < restart_count { v = v * 2; if v >= max_ms { return max_ms } i = i + 1 }
58 return v
59}
60
61// how many restarts remain before the loop-guard trips (window-aware inspector).
62func rg_remaining(cnt: i64, burst: i64) -> i64 { let r: i64 = burst - cnt; if r < 0 { return 0 } return r }