boot/numinit.sh
back to source
#!/bin/sh
# SPDX-License-Identifier: MIT
# NumericalOS init - shell PID-1 path.
# Walks BootPhase records by ordinal, resolves the unit DAG, runs units.
set -u
NUMOS_STATE_FILE=""
numos_die() {
echo "numos: HALT: $*" >&2
exit 1
}
numos_load_state() {
NUMOS_STATE_FILE="$1"
[ -f "$NUMOS_STATE_FILE" ] || numos_die "state not found: $1"
}
numos_phase_ordinals() {
grep '^P ' "$NUMOS_STATE_FILE" | cut -d' ' -f2 | sort -n
}
numos_phase_field() {
# numos_phase_field <ordinal> <name|on_failure|required_units>
line="$(grep "^P $1 " "$NUMOS_STATE_FILE" | head -n 1)"
[ -n "$line" ] || numos_die "no phase with ordinal $1"
case "$2" in
name) echo "$line" | cut -d' ' -f3 ;;
on_failure) echo "$line" | cut -d' ' -f4 ;;
required_units) echo "$line" | cut -d' ' -f5 | tr ',' ' ' | sed 's/^-$//' ;;
*) numos_die "unknown phase field: $2" ;;
esac
}
numos_unit_field() {
# numos_unit_field <name> <field>. exec is last on the line, so it keeps spaces.
line="$(grep "^U $1 " "$NUMOS_STATE_FILE" | head -n 1)"
[ -n "$line" ] || numos_die "unknown unit: $1"
case "$2" in
kind) echo "$line" | cut -d' ' -f3 ;;
restart) echo "$line" | cut -d' ' -f4 ;;
backoff_ms) echo "$line" | cut -d' ' -f5 ;;
backoff_max_ms) echo "$line" | cut -d' ' -f6 ;;
requires) echo "$line" | cut -d' ' -f9 | tr ',' ' ' | sed 's/^-$//' ;;
after) echo "$line" | cut -d' ' -f10 | tr ',' ' ' | sed 's/^-$//' ;;
exec) echo "$line" | cut -d' ' -f11- ;;
*) numos_die "unknown unit field: $2" ;;
esac
}
# Emit units in dependency order (Kahn's algorithm).
#
# Deliberately iterative, not recursive: POSIX sh has no `local`, so a
# recursive helper's variables are global and the recursive call clobbers the
# caller's loop variable. Two passes instead: expand the dependency closure,
# then repeatedly emit whichever units have all their deps already emitted.
numos_unit_deps() {
# numos_unit_deps <name>. Fails (nonzero) if the unit is unknown; caller
# must check the status, since exit inside a $(...) only kills the subshell.
reqs="$(numos_unit_field "$1" requires)" || return 1
afters="$(numos_unit_field "$1" after)" || return 1
echo "$reqs $afters"
}
numos_resolve_order() {
closure=""
pending="$*"
while [ -n "$(echo $pending)" ]; do
nextwave=""
for node in $pending; do
case " $closure " in
*" $node "*) continue ;;
esac
closure="$closure $node"
deps="$(numos_unit_deps "$node")" || numos_die "unit $node: dependency lookup failed"
nextwave="$nextwave $deps"
done
pending="$nextwave"
done
emitted=""
remaining="$closure"
while [ -n "$(echo $remaining)" ]; do
progress=0
stillwaiting=""
for node in $remaining; do
ready=1
deps="$(numos_unit_deps "$node")" || numos_die "unit $node: dependency lookup failed"
for dep in $deps; do
case " $emitted " in
*" $dep "*) ;;
*) ready=0 ;;
esac
done
if [ "$ready" = "1" ]; then
echo "$node"
emitted="$emitted $node"
progress=1
else
stillwaiting="$stillwaiting $node"
fi
done
remaining="$stillwaiting"
[ "$progress" = "1" ] || numos_die "unresolvable dependency order in: $remaining"
done
}
# Execute a unit's exec line synchronously and return its exit status.
# The NUMOS_DRY_RUN seam lives here, so every path that would execute
# something -- the phase walk and the supervisor alike -- reports instead.
# A unit's resource envelope, from its R record. Absent record or absent
# field yields the empty string, meaning unlimited.
#
# R <name> <cpu_pct|-> <ram_mb|-> <timeout_s|->
numos_resource_field() {
line="$(grep "^R $1 " "$NUMOS_STATE_FILE" 2>/dev/null | head -n 1)"
[ -n "$line" ] || { echo ""; return 0; }
case "$2" in
cpu_pct) value="$(echo "$line" | cut -d' ' -f3)" ;;
ram_mb) value="$(echo "$line" | cut -d' ' -f4)" ;;
timeout_s) value="$(echo "$line" | cut -d' ' -f5)" ;;
*) numos_die "unknown resource field: $2" ;;
esac
[ "$value" = "-" ] && value=""
echo "$value"
}
# Enforce what the shell floor can enforce, and REFUSE what it cannot.
#
# Declaring a limit that nothing applies is a lie told to whoever wrote the
# state: they set cpu_pct expecting protection and got none. So an envelope
# this floor cannot honour halts with a named reason rather than running the
# unit unbounded (docs/PRINCIPLES.md stewardship, and truthfulness/CCC 2469).
# Setting the field to `-` is how an operator opts out.
#
# ram_mb enforced with ulimit -v, which is per-process address space
# timeout_s enforced with timeout(1); busybox provides it
# cpu_pct NOT enforceable here. A percentage needs cgroups or a
# scheduler knob; ulimit -t is cumulative CPU seconds, which is
# a different quantity. Refused rather than approximated.
# Resolve a unit's envelope into globals, and halt on anything this floor
# cannot honour. MUST run in the parent shell, before a longrun unit is
# backgrounded, for two reasons:
#
# 1. numos_die inside a background job kills only that job. A cpu_pct halt
# would print and the boot would carry on - the swallowed-exit class
# this codebase has hit repeatedly.
# 2. The lookups are several greps. Doing them inside the backgrounded job
# keeps that process alive long enough that numos_longrun_still_running
# mistakes a unit which died instantly for a healthy one.
NUMOS_ENV_FOR=""
NUMOS_ENV_RAM=""
NUMOS_ENV_TIMEOUT=""
numos_resolve_envelope() {
name="$1"
cpu_pct="$(numos_resource_field "$name" cpu_pct)"
NUMOS_ENV_RAM="$(numos_resource_field "$name" ram_mb)"
NUMOS_ENV_TIMEOUT="$(numos_resource_field "$name" timeout_s)"
if [ -n "$cpu_pct" ]; then
numos_die "unit $name: resource_envelope.cpu_pct=$cpu_pct cannot be enforced by the shell floor (needs cgroups); set it to - to run without a CPU limit"
fi
if [ -n "$NUMOS_ENV_TIMEOUT" ]; then
command -v timeout >/dev/null 2>&1 ||
numos_die "unit $name: resource_envelope.timeout_s=$NUMOS_ENV_TIMEOUT declared but no timeout(1) is available to enforce it"
fi
NUMOS_ENV_FOR="$name"
}
numos_exec_unit() {
name="$1"
if [ "${NUMOS_DRY_RUN:-0}" = "1" ]; then
echo "RUN $name"
return 0
fi
cmd="$(numos_unit_field "$name" exec)" || numos_die "unit $name: exec lookup failed"
# Reuse what the parent already resolved; resolve here only for callers
# that run synchronously and did not pre-resolve.
[ "$NUMOS_ENV_FOR" = "$name" ] || numos_resolve_envelope "$name"
prelude=""
[ -n "$NUMOS_ENV_RAM" ] && prelude="ulimit -v $((NUMOS_ENV_RAM * 1024)) || exit 1; "
if [ -n "$NUMOS_ENV_TIMEOUT" ]; then
timeout "$NUMOS_ENV_TIMEOUT" sh -c "$prelude$cmd"
else
sh -c "$prelude$cmd"
fi
}
# Confirm a just-backgrounded longrun is still running.
#
# Reporting success the instant a daemon is forked made a missing binary
# (or `false`, or any crash-on-start) look like a healthy phase: the phase's
# on_failure never ran, and only steady-state restart policy noticed later.
# That is a lie about the phase. A short settle is required because some
# hosts (notably Git Bash on Windows) still report the child as alive for
# a beat after it has already decided to exit.
#
# This settle is NOT throttling and is NOT skipped by NUMOS_NO_SLEEP: that
# variable suppresses restart backoff and tick delays, not observability.
numos_longrun_still_running() {
pid="$1"
# Poll rather than a single snapshot: Git Bash has been observed to keep
# kill -0 succeeding for one beat after `false` (and missing binaries)
# have already decided to exit. Two or three sub-second samples catch
# that without turning a healthy daemon into a oneshot wait.
# Prefer fractional sleep; one whole-second sample is the floor where
# sleep(1) rejects fractions, rather than skipping the check.
n=0
while [ "$n" -lt 4 ]; do
if ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
n=$((n + 1))
sleep 0.05 2>/dev/null || {
# One whole-second sample is enough on strict POSIX sleep; do not
# multiply it by the loop or a crash-loop host waits for seconds.
[ "$n" -eq 1 ] && sleep 1
break
}
done
kill -0 "$pid" 2>/dev/null
}
# Start a unit according to its kind.
#
# oneshot and target run to completion: the phase walk needs their exit
# status to apply the phase's on_failure policy. A longrun unit is a daemon
# -- it does not return -- so PID 1 must not wait on one. It is started in
# the background and reported successful once it is observed still running;
# the seeded `join` unit is exactly this shape and would otherwise block
# the phase walk at phase 50 forever. Its PID is recorded so steady state
# can notice when it dies later and apply its restart policy -- see
# numos_supervise_tick. An immediately-exiting longrun is a phase failure.
numos_run_unit() {
name="$1"
kind="$(numos_unit_field "$name" kind)" || numos_die "unit $name: kind lookup failed"
case "$kind" in
oneshot|target)
numos_exec_unit "$name"
;;
longrun)
if [ "${NUMOS_DRY_RUN:-0}" = "1" ]; then
# Nothing is executed in dry run, so nothing can block: report
# synchronously to keep RUN lines in dependency order.
numos_exec_unit "$name"
return 0
fi
# Resolve in the parent: a halt inside the background job would be
# swallowed, and the lookups would keep that job alive long enough to
# defeat the immediate-death check below.
numos_resolve_envelope "$name"
numos_exec_unit "$name" &
pid=$!
numos_track "$name" "$pid"
if numos_longrun_still_running "$pid"; then
return 0
fi
# Leave the pidfile in place: phase on_failure must see the failure,
# and steady state (if the phase policy lets boot continue) still has
# to reap and apply the unit's restart policy. Reaping here would hide
# the unit from the supervisor and turn restart=always into a no-op.
echo "numos: longrun $name exited immediately" >&2
return 1
;;
*)
numos_die "unit $name: unknown kind: $kind"
;;
esac
}
# Delays double from initial up to the ceiling, then hold there.
numos_backoff_sequence() {
delay="$1"
ceiling="$2"
count="$3"
[ "$delay" -le "$ceiling" ] || delay="$ceiling"
n=0
while [ "$n" -lt "$count" ]; do
echo "$delay"
delay=$((delay * 2))
[ "$delay" -le "$ceiling" ] || delay="$ceiling"
n=$((n + 1))
done
}
# Compute the sleep(1) argument for a millisecond delay, without sleeping.
# Split out from numos_sleep_ms so tests can assert on the computed value
# without waiting on a real sleep. A nonzero delay must never compute to an
# argument that means "no wait" -- the fractional form below always carries
# a nonzero fractional part when ms is not an exact multiple of 1000, and
# numos_sleep_ms's whole-second fallback (below) separately guards the case
# where the fractional form is rejected by the running sleep(1).
numos_sleep_arg() {
ms="$1"
[ "$ms" -gt 0 ] || { echo 0; return 0; }
secs=$((ms / 1000))
rem=$((ms % 1000))
frac="$(printf '%03d' "$rem")"
echo "$secs.$frac"
}
numos_sleep_ms() {
[ "${NUMOS_NO_SLEEP:-0}" = "1" ] && return 0
ms="$1"
[ "$ms" -gt 0 ] || return 0
arg="$(numos_sleep_arg "$ms")"
# Prefer the fractional form (GNU coreutils and busybox both accept it).
# POSIX only guarantees whole-second sleep, so fall back rather than
# assume -- and round the fallback UP so a sub-second delay never
# collapses to "sleep 0" (a crash-looping unit would otherwise spin at
# full speed with zero throttling).
sleep "$arg" 2>/dev/null && return 0
whole=$(( (ms + 999) / 1000 ))
[ "$whole" -gt 0 ] || whole=1
sleep "$whole"
}
numos_run_phase() {
ordinal="$1"
required="$(numos_phase_field "$ordinal" required_units)" || numos_die "phase $ordinal: required_units lookup failed"
[ -n "$required" ] || return 0
order="$(numos_resolve_order $required)" || numos_die "phase $ordinal: dependency resolution failed"
for name in $order; do
numos_run_unit "$name" || return 1
done
return 0
}
NUMOS_DEGRADED=0
numos_degraded() {
echo "$NUMOS_DEGRADED"
}
numos_mark_degraded() {
NUMOS_DEGRADED=1
# Persist it. Degraded was an in-process shell variable, so nothing outside
# numinit -- including numctl, which builds the capability document -- could
# tell a degraded node from a healthy one. A node that cannot report its own
# degradation advertises a capability it does not have.
[ -d "$NUMOS_RUNDIR" ] && echo 1 > "$NUMOS_RUNDIR/degraded"
}
# A phase failed. What happens next is the phase's own on_failure policy.
numos_handle_phase_failure() {
ordinal="$1"
name="$(numos_phase_field "$ordinal" name)" || numos_die "phase $ordinal: name lookup failed"
policy="$(numos_phase_field "$ordinal" on_failure)" || numos_die "phase $ordinal: on_failure lookup failed"
case "$policy" in
halt)
numos_die "phase $name failed and is on_failure=halt"
;;
degrade)
numos_mark_degraded
echo "numos: DEGRADED: $name"
;;
continue)
echo "numos: WARN: phase $name failed, continuing"
;;
*)
numos_die "phase $name has an unknown on_failure policy"
;;
esac
}
# ------------------------------------------------------------------ steady state
#
# The phase walk brings a machine up; steady state is what keeps it up. It is
# also what makes this a valid PID 1 at all: a PID 1 that returns panics the
# kernel, so in a real boot this loop never exits. NUMOS_MAX_TICKS bounds it
# for tests and for nothing else.
#
# Supervision here is tick-driven over tracked PIDs rather than a blocking
# per-unit supervisor. A loop watching N units cannot afford to block on any
# one of them, which is why numos_supervise's shape does not fit and the
# policy logic lives here instead.
NUMOS_RUNDIR="${NUMOS_RUNDIR:-/run/numos}"
numos_rundir_init() {
mkdir -p "$NUMOS_RUNDIR/units" "$NUMOS_RUNDIR/health" ||
numos_die "cannot create runtime directory: $NUMOS_RUNDIR"
}
numos_track() {
echo "$2" > "$NUMOS_RUNDIR/units/$1.pid"
}
numos_alive() {
pidfile="$NUMOS_RUNDIR/units/$1.pid"
[ -f "$pidfile" ] || return 1
kill -0 "$(cat "$pidfile")" 2>/dev/null
}
# Collect a dead child's status. This is also the zombie reaping duty: the
# shell holds a terminated child's status until something waits for it.
numos_reap() {
pidfile="$NUMOS_RUNDIR/units/$1.pid"
pid="$(cat "$pidfile")"
wait "$pid" 2>/dev/null
status=$?
rm -f "$pidfile"
return "$status"
}
# Give up on a unit that will never come up.
#
# Backoff bounded the DELAY but nothing bounded the ATTEMPTS, so a unit that
# crash-loops from boot retried forever at the ceiling. That is not
# self-healing; it is an infinite retry with no exit condition, and it was
# invisible: no health predicate fired, no capability was retracted, and
# nothing prompted an operator to look. A permanently failing unit must reach
# a terminal state that something can observe.
#
# Quarantine is deliberately NOT a halt. One dead unit should not take a
# machine down; the node keeps running, marks itself degraded so a
# coordinator would stop scheduling onto it, and leaves a marker naming what
# gave up and after how many attempts.
NUMOS_MAX_ATTEMPTS="${NUMOS_MAX_ATTEMPTS:-10}"
numos_quarantined() {
[ -f "$NUMOS_RUNDIR/units/$1.quarantined" ]
}
numos_quarantine_unit() {
name="$1"
attempts="$2"
echo "$attempts" > "$NUMOS_RUNDIR/units/$name.quarantined"
rm -f "$NUMOS_RUNDIR/units/$name.pid"
numos_mark_degraded
echo "numos: QUARANTINED $name after $attempts attempts; node degraded"
}
numos_restart_unit() {
name="$1"
status="$2"
attemptfile="$NUMOS_RUNDIR/units/$name.attempt"
attempt=0
[ -f "$attemptfile" ] && attempt="$(cat "$attemptfile")"
attempt=$((attempt + 1))
echo "$attempt" > "$attemptfile"
if [ "$attempt" -gt "$NUMOS_MAX_ATTEMPTS" ]; then
numos_quarantine_unit "$name" "$((attempt - 1))"
return 0
fi
init_backoff="$(numos_unit_field "$name" backoff_ms)" ||
numos_die "unit $name: backoff_ms lookup failed"
ceiling="$(numos_unit_field "$name" backoff_max_ms)" ||
numos_die "unit $name: backoff_max_ms lookup failed"
# Validate before any arithmetic: $(( )) on a non-numeric value from the
# state file produces a raw shell error instead of a named halt.
case "$init_backoff" in
""|*[!0-9]*) numos_die "unit $name: backoff_ms is not a non-negative integer: $init_backoff" ;;
esac
case "$ceiling" in
""|*[!0-9]*) numos_die "unit $name: backoff_max_ms is not a non-negative integer: $ceiling" ;;
esac
delays="$(numos_backoff_sequence "$init_backoff" "$ceiling" "$attempt")" ||
numos_die "unit $name: backoff sequence generation failed"
delay="$(echo "$delays" | tail -n 1)"
echo "numos: restart $name attempt=$attempt delay=${delay}ms (exit $status)"
numos_sleep_ms "$delay"
# Resolve in the parent, exactly as numos_run_unit does before ITS
# background start. Without this the envelope check -- and its numos_die --
# runs inside the backgrounded job on every RESTART: an unenforceable
# cpu_pct would print a HALT that halts nothing, numos_track would record
# an already-dying pid, and the unit would crash-loop to quarantine while
# the boot carried on. Same defect the sibling call site documents; this
# was the one path the original fix did not cover.
numos_resolve_envelope "$name"
numos_exec_unit "$name" &
numos_track "$name" "$!"
}
numos_supervise_tick() {
for pidfile in "$NUMOS_RUNDIR"/units/*.pid; do
[ -e "$pidfile" ] || continue
name="$(basename "$pidfile" .pid)"
numos_alive "$name" && continue
# A quarantined unit is terminal: reap it once, then leave it alone.
if numos_quarantined "$name"; then
rm -f "$pidfile"
continue
fi
numos_reap "$name"
status=$?
policy="$(numos_unit_field "$name" restart)" ||
numos_die "unit $name: restart lookup failed"
case "$policy" in
never)
echo "numos: unit $name exited ($status), restart=never" ;;
on-failure)
if [ "$status" = "0" ]; then
echo "numos: unit $name completed, restart=on-failure"
else
numos_restart_unit "$name" "$status"
fi ;;
always)
numos_restart_unit "$name" "$status" ;;
*)
numos_die "unit $name: unknown restart policy: $policy" ;;
esac
done
}
numos_health_names() {
grep '^X ' "$NUMOS_STATE_FILE" 2>/dev/null | cut -d' ' -f2
}
numos_health_field() {
line="$(grep "^X $1 " "$NUMOS_STATE_FILE" | head -n 1)"
[ -n "$line" ] || numos_die "unknown health predicate: $1"
case "$2" in
interval_s) echo "$line" | cut -d' ' -f3 ;;
threshold) echo "$line" | cut -d' ' -f4 ;;
local_action) echo "$line" | cut -d' ' -f5 ;;
on_fire) echo "$line" | cut -d' ' -f6 | sed 's/^-$//' ;;
probe) echo "$line" | cut -d' ' -f7- ;;
*) numos_die "unknown health field: $2" ;;
esac
}
# Which unit declared this predicate as its health_probe (field 8 of a U
# record). Used by the restart-unit action, which otherwise has no way to
# know what it is supposed to restart.
numos_units_with_probe() {
grep '^U ' "$NUMOS_STATE_FILE" 2>/dev/null |
while read -r _tag uname _kind _restart _bms _bmax _mask probe _rest; do
[ "$probe" = "$1" ] && echo "$uname"
done
}
numos_health_fire() {
name="$1"
action="$(numos_health_field "$name" local_action)"
echo "numos: HEALTH-FIRE $name action=$action"
case "$action" in
none) ;;
degrade-node)
numos_mark_degraded
echo "numos: DEGRADED: health $name" ;;
restart-unit)
for owner in $(numos_units_with_probe "$name"); do
echo "numos: health $name restarting $owner"
numos_alive "$owner" && kill "$(cat "$NUMOS_RUNDIR/units/$owner.pid")" 2>/dev/null
done ;;
*)
numos_die "health $name: unknown local_action: $action" ;;
esac
# Escalation needs the network. Queue it unconditionally rather than
# blocking the loop on reachability: an offline machine must keep
# supervising itself, and the queue is what a later reconcile drains.
on_fire="$(numos_health_field "$name" on_fire)"
if [ -n "$on_fire" ]; then
echo "$name $on_fire" >> "$NUMOS_RUNDIR/escalations.queue"
echo "numos: queued escalation $on_fire for $name"
fi
}
# interval_s is counted in ticks, and a tick is NUMOS_TICK_S seconds (default
# 1), so the two agree by construction at the default and stay proportional
# if the tick is retuned.
numos_health_tick() {
for name in $(numos_health_names); do
countfile="$NUMOS_RUNDIR/health/$name.count"
failfile="$NUMOS_RUNDIR/health/$name.fails"
count=0
[ -f "$countfile" ] && count="$(cat "$countfile")"
count=$((count + 1))
interval="$(numos_health_field "$name" interval_s)"
if [ "$count" -lt "$interval" ]; then
echo "$count" > "$countfile"
continue
fi
echo 0 > "$countfile"
fails=0
[ -f "$failfile" ] && fails="$(cat "$failfile")"
probe="$(numos_health_field "$name" probe)"
if sh -c "$probe" >/dev/null 2>&1; then
[ "$fails" = "0" ] || echo "numos: health $name recovered"
echo 0 > "$failfile"
else
fails=$((fails + 1))
echo "$fails" > "$failfile"
threshold="$(numos_health_field "$name" threshold)"
echo "numos: health $name failed ($fails/$threshold)"
[ "$fails" -lt "$threshold" ] || numos_health_fire "$name"
fi
done
}
numos_steady_state() {
ticks=0
max="${NUMOS_MAX_TICKS:-0}"
echo "numos: steady state entered"
while : ; do
numos_supervise_tick
numos_health_tick
ticks=$((ticks + 1))
if [ "$max" != "0" ] && [ "$ticks" -ge "$max" ]; then
echo "numos: steady state stopped after $ticks ticks degraded=$(numos_degraded)"
return 0
fi
numos_sleep_ms "$(( ${NUMOS_TICK_S:-1} * 1000 ))"
done
}
numos_main() {
[ -n "${NUMOS_STATE:-}" ] || numos_die "NUMOS_STATE is unset"
numos_load_state "$NUMOS_STATE"
numos_rundir_init
for ordinal in $(numos_phase_ordinals); do
if ! numos_run_phase "$ordinal"; then
numos_handle_phase_failure "$ordinal"
fi
done
echo "numos: boot complete degraded=$(numos_degraded)"
numos_steady_state
}
# Shell-floor signal handling. TERM/INT exit with a named halt so a supervisor
# or human does not get a silent death. This is NOT static-binary SIGCHLD
# discipline and does not close the PID-1 residual on real hardware — that
# still needs QEMU/metal (and Spec 3 Zig for proper signal quality).
numos_on_term() {
echo "numos: HALT: received SIGTERM" >&2
exit 143
}
numos_on_int() {
echo "numos: HALT: received SIGINT" >&2
exit 130
}
# Terminate every unit this init started, before this init goes away.
#
# Longrun units are backgrounded with `&`, and nothing killed them when
# numinit exited. In a real boot numinit never returns, so this only fires on
# a halt or a signal -- exactly the moments when leaving orphaned daemons
# behind is wrong: PID 1 is going away and the processes it started would be
# reparented and abandoned.
#
# It also repairs the test instrument. Bounded runs (NUMOS_MAX_TICKS) leaked
# real sleeping children into the next test module, and those leaks starved
# later wall-clock assertions into spurious failures -- a suite that
# intermittently reported failures no targeted run could reproduce. An
# evidence oracle with a false-positive channel is a defect in the evidence.
numos_kill_tracked() {
[ -d "${NUMOS_RUNDIR:-}/units" ] || return 0
for pidfile in "$NUMOS_RUNDIR"/units/*.pid; do
[ -e "$pidfile" ] || continue
pid="$(cat "$pidfile" 2>/dev/null)" || continue
[ -n "$pid" ] || continue
kill "$pid" 2>/dev/null || :
done
return 0
}
if [ "${NUMOS_SOURCE_ONLY:-0}" != "1" ]; then
# EXIT only. Trapping TERM/INT here as well would override the named-halt
# handlers below; the default disposition terminates and that fires EXIT,
# so cleanup runs on every path without changing signal semantics.
trap numos_kill_tracked EXIT
trap numos_on_term TERM
trap numos_on_int INT
numos_main "$@"
fi