Files
fips/src/node/lifecycle/supervisor.rs
T
Johnathan Corgan d61d189572 node: split Running into Full/Degraded, add Failed health state
Determine node health at start completion instead of unconditionally
reaching Running. Zero transports up is now Failed (fatal): start()
tears down cleanly and returns an error, and the daemon exits. Any
configured optional child that failed to start - a transport beyond the
first, Nostr, mDNS, TUN, DNS, or a worker pool - leaves the node
Degraded but serving, with an operator warning naming what failed. All
configured children up is Full. A child the node was never asked to run
does not count against health.

The published NodeState gains Degraded and Failed variants, both visible
via control queries; Degraded is operational, Failed is not. The
lifecycle FSM gains the health states plus the PublishState action that
drives them - a health fork cannot be a single direct state write, which
is why the earlier commits deferred it to here.

Runtime child-exit health re-evaluation (a running child dying) is a
separate liveness-monitoring mechanism left for a follow-up; this commit
is start-time health only.
2026-07-13 00:46:31 +00:00

1175 lines
45 KiB
Rust

//! Node lifecycle supervisor — sans-IO core (Milestone-1 Step 1a).
//!
//! A synchronous `step(event) -> Vec<Action>` finite-state machine over the
//! fixed set of substrate children. It owns the *decision* of what to bring up
//! and tear down and in what order; the async driver in [`super`]
//! (`start()`/`stop()`) performs the actual I/O each [`Action`] names and reports
//! results back as [`Event`]s. The core reads no clock, performs no I/O, and
//! holds no runtime handles — time enters only as inputs (a future `Tick`/
//! `DrainDeadlineElapsed`, added with the `Draining` phase) — so it is
//! unit-testable with synthetic sequences and survives a later thread-boundary
//! move (design doc §6 Core 1, §8 "cores are sans-IO").
//!
//! ## Scope: the behavior-neutral rewrite
//!
//! This is the first of the three Step-1a commits and is strictly
//! behavior-preserving. The machine mirrors today's `start()`/`stop()` exactly:
//!
//! - every configured child is spawned in the current order, and optional
//! failures are warn/debug-and-continue (there is no `Degraded` yet — a
//! failed child simply drains from `pending` and start still reaches
//! `Running`, as today an even-zero-transport node does);
//! - teardown runs in today's order and, faithfully, does **not** stop the
//! encrypt/decrypt worker pools (they are spawned in `start()` but never torn
//! down in `stop()`);
//! - the machine authors only the `SpawnChild`/`StopChild` *ordering*, and the
//! driver keeps its `self.state` writes at their current positions. The
//! behavior-neutral relocation left the published `NodeState` transitions
//! byte-for-byte unchanged; the bounded-drain phase below adds exactly one new
//! published transition (`Draining`), written directly like the others.
//!
//! ## Scope: the bounded `Draining` phase (this commit)
//!
//! This commit adds the operator-visible bounded-drain additions and nothing
//! else: the [`SupState::Draining`] state, the [`Event::Drain`] /
//! [`Event::DrainDeadlineElapsed`] events, the drain [`Action`]s
//! ([`Action::BroadcastDisconnect`], [`Action::SetTimer`],
//! [`Action::SetPeeringDesired`], [`Action::SuspendReplenish`]), and the new
//! published [`NodeState::Draining`](crate::node::NodeState::Draining) —
//! written directly by the driver at drain entry, exactly like the other
//! `self.state` transitions this milestone uses. The existing immediate `Stop`
//! path is untouched. `Draining` and `Stop` share a single teardown-plan author
//! (`begin_stopping`), so the teardown ordering is defined once.
//!
//! ## Scope: the `Running{Full|Degraded}` + `Failed` health split (this commit)
//!
//! This commit lands the operator-visible start-completion health policy
//! (design doc §6/§9.1) and, with it, the FSM-owned [`Action::PublishState`]:
//!
//! - [`SupState::Running`] now carries a [`Health`] (`Full` or `Degraded`), and
//! [`SupState::Failed`] is the fatal path. When `Starting.pending` empties (or
//! the degenerate no-children path), the machine resolves health once
//! ([`SupervisorFsm::resolve_start_health`]): **zero transports up → `Failed`**
//! (fatal); **≥1 transport up but a configured optional child failed →
//! `Degraded`**; **everything configured came up → `Full`**. Not-configured
//! children never count (a node never asked to run DNS is not degraded for
//! lacking it); worker-pool failures are `Degraded` at most, never `Failed`.
//! - the health outcome is a fork that a single direct `self.state` write cannot
//! express, so the machine emits [`Action::PublishState`] carrying the resolved
//! [`NodeState`]; the driver writes it. The non-forking transitions
//! (`Starting`/`Draining`/`Stopping`/`Stopped`) keep their direct `self.state`
//! writes — only the start-completion health outcome routes through
//! `PublishState`, to minimize churn.
//! - the degenerate no-children path now resolves to `Failed` (zero transports),
//! **not** the old immediate-`Running`.
//!
//! Runtime child-liveness monitoring (a `ChildExited` event re-routing health
//! when a task/thread dies at runtime) is **deferred** (design doc §7): §9.1's
//! resolution is start-framed, and liveness monitoring is a substantial unbuilt
//! mechanism. This commit is start-time health only.
use std::collections::HashSet;
use std::sync::Arc;
use std::thread::JoinHandle;
use crate::node::NodeState;
use crate::transport::{PacketTx, TransportId};
use crate::upper::tun::{TunOutboundRx, TunTx};
/// A supervised substrate child (design doc §6 Core 1).
///
/// Each transport is an individual child keyed by its id so the later
/// required-vs-optional health policy can reason about partial N-of-M bring-up.
/// The TUN device is a compound unit at the driver (a reader thread plus a
/// writer thread); the supervisor tracks it as the single `Tun` child, and the
/// driver joins both threads when it executes `StopChild(Tun)`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Child {
/// A transport instance (UDP / TCP / Ethernet), keyed by its runtime id.
Transport(TransportId),
/// The off-task FMP-encrypt + UDP-send worker pool (`#[cfg(unix)]`).
EncryptWorkers,
/// The off-task FMP-decrypt worker pool (`#[cfg(unix)]`).
DecryptWorkers,
/// Nostr overlay rendezvous/discovery.
Nostr,
/// LAN mDNS / DNS-SD rendezvous.
Mdns,
/// The TUN device (reader + writer threads).
Tun,
/// The `.fips` DNS responder task.
Dns,
}
/// An input to the supervisor. Results of executing [`Action`]s are fed back as
/// `SubstrateUp` / `SubstrateFailed` / `ChildStopped`.
///
/// `Tick` and `ChildExited` (design doc §6) are **deferred**: `ChildExited`
/// belongs to the runtime child-liveness monitoring follow-up (this commit is
/// start-time health only). The bounded-drain events (`Drain` /
/// `DrainDeadlineElapsed`) and the start/up/failed/stop events are present here.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Event {
/// Begin bring-up. `transports` are the ids the driver has already created
/// (their ids are allocated at creation), in creation order; the booleans
/// mark which singleton children are configured. Valid from `Created` or
/// `Stopped`.
Start {
/// Created transport ids, in the order they must be started.
transports: Vec<TransportId>,
/// The `#[cfg(unix)]` encrypt worker pool is configured.
encrypt_workers: bool,
/// The `#[cfg(unix)]` decrypt worker pool is configured.
decrypt_workers: bool,
/// Nostr overlay discovery is enabled.
nostr: bool,
/// LAN mDNS discovery is enabled.
mdns: bool,
/// The TUN device is enabled.
tun: bool,
/// The DNS responder is enabled.
dns: bool,
},
/// A child the driver was asked to spawn came up.
SubstrateUp {
/// The child that started successfully.
child: Child,
},
/// A child the driver was asked to spawn failed to start. In the
/// behavior-neutral rewrite this is warn/debug-and-continue: the child
/// drains from `pending` and never joins the up-set (matching today), and
/// start still proceeds to `Running`.
SubstrateFailed {
/// The child that failed to start.
child: Child,
},
/// Begin an immediate teardown (no drain). Valid from `Running`. This is
/// the path `node.stop()` uses; unchanged from the behavior-neutral rewrite.
Stop,
/// Begin a bounded graceful drain. Valid from `Running`. Emits the drain
/// entry actions (broadcast Disconnect, arm the deadline timer, gate the
/// reconciler off) and moves to `Draining`; the driver then runs the
/// bounded drain window before feeding `DrainDeadlineElapsed`.
Drain {
/// Absolute drain deadline in driver-clock milliseconds, carried into
/// `Draining` and the `SetTimer` action for observability. The driver
/// owns the actual bounded wait.
deadline_ms: u64,
},
/// The bounded drain window has closed — either the deadline elapsed or all
/// peers drained early. Valid from `Draining`; begins the (shared) teardown
/// plan, transitioning to `Stopping`.
DrainDeadlineElapsed,
/// A child the driver was asked to stop has finished stopping.
ChildStopped {
/// The child that has been torn down.
child: Child,
},
}
/// A driver-scheduled timer the supervisor can arm (design doc §6). Only the
/// drain deadline exists for now; the handshake/rekey/liveness timers named in
/// §8 arrive with later cores.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Timer {
/// Fires when the bounded drain window closes. The driver feeds
/// [`Event::DrainDeadlineElapsed`] when it elapses (or earlier, when all
/// peers have drained).
DrainDeadline,
}
/// The reconciler's desired peering set (design doc §8 drain gate). Only
/// `Empty` is needed in this commit; the populated variants that the Step-1b
/// homeostatic reconciler converges toward land with that core.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PeeringDesired {
/// No peers desired. Entering `Draining` sets this so the reconciler stops
/// reconnecting the peers the drain just closed (§8: "Draining switches the
/// homeostat off").
Empty,
}
/// An effect the driver must perform. The core never performs I/O itself.
///
/// `PublishState` (design doc §6) lands here, with the `Running{Full|Degraded}`
/// health split: the start-completion health outcome is a fork
/// (`Full`/`Degraded`/`Failed`) that a single direct `self.state` write cannot
/// express, so the machine authors it as an action. The driver keeps its direct
/// `self.state` writes for the non-forking transitions (`Starting`/`Draining`/
/// `Stopping`/`Stopped`); only the start-completion health outcome routes through
/// `PublishState`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Action {
/// Bring up this child (the driver performs the spawn / start I/O and
/// reports `SubstrateUp` or `SubstrateFailed`).
SpawnChild(Child),
/// Publish the given operator-visible [`NodeState`]. Emitted at start
/// completion (when `Starting.pending` empties, or the degenerate
/// no-children path) to carry the resolved health outcome —
/// [`NodeState::Running`] (Full), [`NodeState::Degraded`], or
/// [`NodeState::Failed`] — to the driver, which writes it to the published
/// state (design doc §6/§9.1).
PublishState(NodeState),
/// Tear down this child (the driver performs the stop / join I/O and
/// reports `ChildStopped`).
StopChild(Child),
/// Broadcast a shutdown `Disconnect` to all sendable peers. Emitted once,
/// at drain entry; the drain teardown does not re-broadcast.
BroadcastDisconnect,
/// Arm a driver timer at the given absolute driver-clock milliseconds. In
/// this commit only `DrainDeadline` exists; the driver notes the deadline
/// and owns the bounded drain wait, so this is a documented no-op beyond
/// bookkeeping.
SetTimer(Timer, u64),
/// Set the reconciler's desired peering set (§8 drain gate). Documented
/// **no-op in this commit** — the reconciler that consumes it lands in
/// Step 1b; the driver logs/ignores it for now.
SetPeeringDesired(PeeringDesired),
/// Suspend peer replenishment (§8 drain gate). Documented **no-op in this
/// commit** for the same reason as `SetPeeringDesired`.
SuspendReplenish,
}
/// Start-completion health (design doc §9.1). Resolved once when
/// `Starting.pending` empties: `Full` iff every configured child came up,
/// `Degraded` iff ≥1 transport is up but some configured optional child failed.
/// Zero transports up is not a health — it is the fatal [`SupState::Failed`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Health {
/// Every configured child came up.
Full,
/// ≥1 transport is up, but one or more configured optional children failed
/// to start (a transport beyond the first, Nostr, mDNS, TUN, DNS, or a
/// worker-pool spawn). The node is operational (serving) but degraded.
Degraded {
/// The configured children that failed to start.
reasons: HashSet<Child>,
},
}
/// Reason for the fatal [`SupState::Failed`] state (design doc §9.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FailReason {
/// Zero transports came up at start completion. Without a transport the node
/// cannot serve, so this is fatal (the driver tears down and returns an
/// error), unlike the degraded-but-serving optional-child failures.
NoTransports,
}
/// Internal supervisor state (design doc §6). Richer than the published
/// [`NodeState`](crate::node::NodeState): `Starting`/`Stopping` carry the set of
/// children still resolving, and `Running` carries the resolved [`Health`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum SupState {
/// Constructed but not started.
Created,
/// Bringing children up; `pending` is the set not yet resolved.
Starting {
/// Children asked to spawn that have not yet reported up-or-failed.
pending: HashSet<Child>,
},
/// All children resolved and ≥1 transport up; node operational. Carries the
/// resolved [`Health`] (`Full` or `Degraded`).
Running {
/// Resolved start-completion health.
health: Health,
},
/// Start completed with zero transports up (design doc §9.1) — fatal. The
/// driver tears down any children that did come up and returns an error.
Failed {
/// Why the start failed.
reason: FailReason,
},
/// Bounded graceful-drain window (design doc §6/§8). Broadcast Disconnect
/// has gone out and the reconciler is gated off (desired peering set
/// emptied, replenishment suspended); teardown begins when
/// `DrainDeadlineElapsed` arrives. Logically sits between `Running` and
/// `Stopping`.
Draining {
/// Absolute drain deadline in driver-clock milliseconds (carried for
/// observability; the driver owns the actual wait).
deadline_ms: u64,
},
/// Tearing children down; `pending` is the set not yet stopped.
Stopping {
/// Children asked to stop that have not yet reported stopped.
pending: HashSet<Child>,
},
/// Fully torn down.
Stopped,
}
/// The lifecycle supervisor FSM.
///
/// Construct with [`SupervisorFsm::new`], feed [`Event`]s via [`SupervisorFsm::step`],
/// and execute the returned [`Action`]s. See the module docs for the
/// behavior-neutral scope.
#[derive(Clone, Debug)]
pub(crate) struct SupervisorFsm {
state: SupState,
/// Children currently up (present). Drives the teardown plan.
up: HashSet<Child>,
/// Configured children that failed to start during the current bring-up.
/// Feeds the `Degraded` health determination when `pending` empties.
failed: HashSet<Child>,
}
impl SupervisorFsm {
/// A fresh supervisor in `Created`.
pub(crate) fn new() -> Self {
Self {
state: SupState::Created,
up: HashSet::new(),
failed: HashSet::new(),
}
}
/// A supervisor seeded directly into `Running` with a known up-set.
///
/// The teardown driver (`stop()`) reconstructs the up-set from observed
/// runtime presence (`dns_task.is_some()`, transports keys, etc.) rather
/// than relying on a live machine persisted across start/stop, so that
/// teardown ordering is authored here regardless of how the node reached
/// `Running`. Feeding `Event::Stop` then yields the ordered `StopChild`
/// plan over exactly the present children.
pub(crate) fn running_with(up: impl IntoIterator<Item = Child>) -> Self {
Self {
state: SupState::Running {
health: Health::Full,
},
up: up.into_iter().collect(),
failed: HashSet::new(),
}
}
/// Current internal state (for the driver's bookkeeping and for tests).
#[cfg(test)]
pub(crate) fn state(&self) -> &SupState {
&self.state
}
/// The configured children that failed to start during bring-up. The driver
/// reads this on the `Degraded` start outcome to enumerate the degraded
/// children in an operator-visible `warn!`.
pub(in crate::node) fn failed(&self) -> &HashSet<Child> {
&self.failed
}
/// Whether the machine is in the bounded-drain window. The driver uses this
/// after the rx loop returns to decide between the drain-teardown path and
/// the immediate-`stop()` fallback.
pub(crate) fn is_draining(&self) -> bool {
matches!(self.state, SupState::Draining { .. })
}
/// Advance the machine by one event, returning the effects to perform.
pub(crate) fn step(&mut self, event: Event) -> Vec<Action> {
match event {
Event::Start {
transports,
encrypt_workers,
decrypt_workers,
nostr,
mdns,
tun,
dns,
} => self.on_start(
transports,
encrypt_workers,
decrypt_workers,
nostr,
mdns,
tun,
dns,
),
Event::SubstrateUp { child } => self.on_substrate_up(child),
Event::SubstrateFailed { child } => self.on_substrate_failed(child),
Event::Stop => self.on_stop(),
Event::Drain { deadline_ms } => self.on_drain(deadline_ms),
Event::DrainDeadlineElapsed => self.on_drain_deadline_elapsed(),
Event::ChildStopped { child } => self.on_child_stopped(child),
}
}
#[allow(clippy::too_many_arguments)]
fn on_start(
&mut self,
transports: Vec<TransportId>,
encrypt_workers: bool,
decrypt_workers: bool,
nostr: bool,
mdns: bool,
tun: bool,
dns: bool,
) -> Vec<Action> {
// Only meaningful from a not-running state (the driver also guards on
// `can_start`). Ignore otherwise.
if !matches!(self.state, SupState::Created | SupState::Stopped) {
return Vec::new();
}
// Canonical spawn order, mirroring today's `start()`:
// transports (creation order) → encrypt → decrypt → nostr → mdns →
// tun → dns. (The driver performs the peer-connect between mdns and
// tun; it is not a supervised child.)
let mut order: Vec<Child> = transports.into_iter().map(Child::Transport).collect();
if encrypt_workers {
order.push(Child::EncryptWorkers);
}
if decrypt_workers {
order.push(Child::DecryptWorkers);
}
if nostr {
order.push(Child::Nostr);
}
if mdns {
order.push(Child::Mdns);
}
if tun {
order.push(Child::Tun);
}
if dns {
order.push(Child::Dns);
}
self.up.clear();
self.failed.clear();
// A node with no children at all resolves health immediately. Zero
// transports up → `Failed` (design doc §9.1; this is the behavioral
// change from the old immediate-`Running`).
if order.is_empty() {
return vec![Action::PublishState(self.resolve_start_health())];
}
self.state = SupState::Starting {
pending: order.iter().copied().collect(),
};
order.into_iter().map(Action::SpawnChild).collect()
}
fn on_substrate_up(&mut self, child: Child) -> Vec<Action> {
let SupState::Starting { pending } = &mut self.state else {
return Vec::new();
};
pending.remove(&child);
let emptied = pending.is_empty();
self.up.insert(child);
if emptied {
vec![Action::PublishState(self.resolve_start_health())]
} else {
Vec::new()
}
}
fn on_substrate_failed(&mut self, child: Child) -> Vec<Action> {
// Record the failed child (design doc §9.1): a configured child that
// failed to start drives the `Degraded` determination when `pending`
// empties. It drains from `pending` and never joins the up-set.
let SupState::Starting { pending } = &mut self.state else {
return Vec::new();
};
pending.remove(&child);
let emptied = pending.is_empty();
self.failed.insert(child);
if emptied {
vec![Action::PublishState(self.resolve_start_health())]
} else {
Vec::new()
}
}
/// Resolve start-completion health (design doc §9.1) and set the resulting
/// state, returning the [`NodeState`] the driver should publish. Called once
/// when `Starting.pending` empties (or the degenerate no-children path):
///
/// - zero transports up → [`SupState::Failed`] / [`NodeState::Failed`];
/// - ≥1 transport up but some configured child failed → [`Health::Degraded`]
/// / [`NodeState::Degraded`];
/// - everything configured came up → [`Health::Full`] / [`NodeState::Running`].
///
/// Worker-pool failures are captured in `failed` like any other optional
/// child, so they contribute `Degraded` (never `Failed`) — the inline crypto
/// fallback keeps the node correct without the pools (design doc §9.1).
///
/// This is start-time health only. Runtime child-liveness monitoring (a
/// `ChildExited` event re-routing health when a task/thread dies at runtime)
/// is a deferred follow-up (design doc §7 / §9.1 is start-framed).
fn resolve_start_health(&mut self) -> NodeState {
let transports_up = self
.up
.iter()
.filter(|c| matches!(c, Child::Transport(_)))
.count();
if transports_up == 0 {
self.state = SupState::Failed {
reason: FailReason::NoTransports,
};
NodeState::Failed
} else if !self.failed.is_empty() {
self.state = SupState::Running {
health: Health::Degraded {
reasons: self.failed.clone(),
},
};
NodeState::Degraded
} else {
self.state = SupState::Running {
health: Health::Full,
};
NodeState::Running
}
}
fn on_stop(&mut self) -> Vec<Action> {
if !matches!(self.state, SupState::Running { .. }) {
return Vec::new();
}
self.begin_stopping()
}
fn on_drain(&mut self, deadline_ms: u64) -> Vec<Action> {
// Only a graceful drain from a running node (either health). Inert
// otherwise (matching `Stop`'s guard).
if !matches!(self.state, SupState::Running { .. }) {
return Vec::new();
}
self.state = SupState::Draining { deadline_ms };
// Drain entry, in order: broadcast the shutdown Disconnect, arm the
// deadline timer, then gate the reconciler off (desired = ∅, suspend
// replenishment) so it cannot reconnect the peers the drain just closed
// (§8). The up-set is left intact for the eventual teardown plan.
vec![
Action::BroadcastDisconnect,
Action::SetTimer(Timer::DrainDeadline, deadline_ms),
Action::SetPeeringDesired(PeeringDesired::Empty),
Action::SuspendReplenish,
]
}
fn on_drain_deadline_elapsed(&mut self) -> Vec<Action> {
// The bounded drain window closed (deadline or all-peers-gone). Author
// the same teardown plan `Stop` produces.
if !matches!(self.state, SupState::Draining { .. }) {
return Vec::new();
}
self.begin_stopping()
}
/// Author the teardown plan over the current up-set, transition to
/// `Stopping`, and return the ordered `StopChild` actions. Shared by the
/// immediate `Stop` path ([`Self::on_stop`]) and the drain-window-close path
/// ([`Self::on_drain_deadline_elapsed`]) so the teardown ordering is defined
/// exactly once.
fn begin_stopping(&mut self) -> Vec<Action> {
let order = self.teardown_order();
self.state = SupState::Stopping {
pending: order.iter().copied().collect(),
};
order.into_iter().map(Action::StopChild).collect()
}
fn on_child_stopped(&mut self, child: Child) -> Vec<Action> {
if let SupState::Stopping { pending } = &mut self.state {
pending.remove(&child);
self.up.remove(&child);
if pending.is_empty() {
self.state = SupState::Stopped;
}
}
Vec::new()
}
/// Teardown order over the up-set, mirroring today's `stop()`:
/// dns → nostr → mdns → transports (ascending id) → tun.
///
/// The encrypt/decrypt worker pools are deliberately excluded: today's
/// `stop()` spawns them in `start()` but never tears them down. Transports
/// are ordered by ascending id for determinism (today's `stop()` iterates
/// them in nondeterministic `HashMap` order, so this is neutral).
fn teardown_order(&self) -> Vec<Child> {
let mut order = Vec::new();
if self.up.contains(&Child::Dns) {
order.push(Child::Dns);
}
if self.up.contains(&Child::Nostr) {
order.push(Child::Nostr);
}
if self.up.contains(&Child::Mdns) {
order.push(Child::Mdns);
}
let mut transports: Vec<TransportId> = self
.up
.iter()
.filter_map(|c| match c {
Child::Transport(id) => Some(*id),
_ => None,
})
.collect();
transports.sort_by_key(|id| id.as_u32());
order.extend(transports.into_iter().map(Child::Transport));
if self.up.contains(&Child::Tun) {
order.push(Child::Tun);
}
order
}
}
/// Owner of the node's lifecycle-managed substrate handles plus the sans-IO
/// [`SupervisorFsm`] that authors their spawn/teardown ordering.
///
/// The fields moved here off `Node` are exactly the children the supervisor
/// governs — the packet-send channel, the TUN reader/writer plumbing, the DNS
/// responder task, the Nostr/LAN rendezvous drivers, and (on unix) the
/// encrypt/decrypt worker pools — together with the published `NodeState`.
/// This is a pure relocation: the driver (`start()`/`stop()`) reaches each
/// field through `self.supervisor.*`, and the initializers are the same ones
/// `Node::new` used.
pub(crate) struct Supervisor {
/// Node operational state (the published `NodeState`; the driver keeps its
/// verbatim writes here at their current positions).
pub(in crate::node) state: NodeState,
/// Packet sender for transports.
pub(in crate::node) packet_tx: Option<PacketTx>,
/// TUN packet sender channel.
pub(in crate::node) tun_tx: Option<TunTx>,
/// Receiver for outbound packets from the TUN reader.
pub(in crate::node) tun_outbound_rx: Option<TunOutboundRx>,
/// TUN reader thread handle.
pub(in crate::node) tun_reader_handle: Option<JoinHandle<()>>,
/// TUN writer thread handle.
pub(in crate::node) tun_writer_handle: Option<JoinHandle<()>>,
/// Shutdown pipe: writing to this fd unblocks the TUN reader thread on macOS.
/// On Linux, deleting the interface via netlink serves the same purpose.
#[cfg(target_os = "macos")]
pub(in crate::node) tun_shutdown_fd: Option<std::os::unix::io::RawFd>,
/// Receiver for resolved identities from the DNS responder.
pub(in crate::node) dns_identity_rx: Option<crate::upper::dns::DnsIdentityRx>,
/// DNS responder task handle.
pub(in crate::node) dns_task: Option<tokio::task::JoinHandle<()>>,
/// Node-side driver state for the Nostr overlay peer-rendezvous
/// subsystem: the engine handle, its startup timestamp, the one-shot
/// startup-sweep latch, and the per-peer bootstrap-transport bookkeeping
/// adopted from NAT-traversal handoffs.
pub(in crate::node) nostr_rendezvous: crate::nostr::RendezvousDriver,
/// mDNS / DNS-SD responder + browser for local-link peer discovery.
/// Identity is unverified at this layer — the Noise XX handshake
/// initiated against an mDNS-observed endpoint is what proves the
/// peer holds the matching private key.
pub(in crate::node) lan_rendezvous: Option<Arc<crate::mdns::LanRendezvous>>,
/// Off-task FMP-encrypt + UDP-send worker pool. Unix-only —
/// the worker issues direct sendmmsg(2) / sendmsg+UDP_GSO calls
/// on raw fds via `AsRawFd`. None on Windows or when the worker
/// pool failed to spawn.
#[cfg(unix)]
pub(crate) encrypt_workers: Option<crate::node::encrypt_worker::EncryptWorkerPool>,
/// Off-task FMP decrypt worker pool — receiver-side mirror of
/// `encrypt_workers`. Workers are shards: each owns its session
/// state directly in a thread-local `HashMap` (no `RwLock`,
/// no `Mutex` per packet). Hash-by-cache-key dispatch.
#[cfg(unix)]
pub(crate) decrypt_workers: Option<crate::node::decrypt_worker::DecryptWorkerPool>,
/// The sans-IO lifecycle FSM authoring spawn/teardown ordering.
pub(in crate::node) fsm: SupervisorFsm,
}
impl Supervisor {
/// A fresh supervisor with all handles empty and the FSM in `Created`,
/// matching the field initializers `Node::new` previously used.
pub(crate) fn new() -> Self {
Self {
state: NodeState::Created,
packet_tx: None,
tun_tx: None,
tun_outbound_rx: None,
tun_reader_handle: None,
tun_writer_handle: None,
#[cfg(target_os = "macos")]
tun_shutdown_fd: None,
dns_identity_rx: None,
dns_task: None,
nostr_rendezvous: crate::nostr::RendezvousDriver::default(),
lan_rendezvous: None,
#[cfg(unix)]
encrypt_workers: None,
#[cfg(unix)]
decrypt_workers: None,
fsm: SupervisorFsm::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tid(n: u32) -> TransportId {
TransportId::new(n)
}
fn start_full() -> Event {
Event::Start {
transports: vec![tid(1), tid(2)],
encrypt_workers: true,
decrypt_workers: true,
nostr: true,
mdns: true,
tun: true,
dns: true,
}
}
#[test]
fn start_emits_spawn_in_canonical_order() {
let mut s = SupervisorFsm::new();
let actions = s.step(start_full());
assert_eq!(
actions,
vec![
Action::SpawnChild(Child::Transport(tid(1))),
Action::SpawnChild(Child::Transport(tid(2))),
Action::SpawnChild(Child::EncryptWorkers),
Action::SpawnChild(Child::DecryptWorkers),
Action::SpawnChild(Child::Nostr),
Action::SpawnChild(Child::Mdns),
Action::SpawnChild(Child::Tun),
Action::SpawnChild(Child::Dns),
]
);
assert!(matches!(s.state(), SupState::Starting { .. }));
}
#[test]
fn all_configured_up_reaches_full() {
// Everything configured came up (2 transports + all optional children)
// → Full; the pending-emptying step publishes `Running`.
let mut s = SupervisorFsm::new();
let spawns = s.step(start_full());
let children: Vec<Child> = spawns
.into_iter()
.map(|a| match a {
Action::SpawnChild(c) => c,
_ => panic!("unexpected action"),
})
.collect();
let last = children.len() - 1;
for (i, child) in children.into_iter().enumerate() {
let out = s.step(Event::SubstrateUp { child });
if i == last {
assert_eq!(out, vec![Action::PublishState(NodeState::Running)]);
} else {
assert_eq!(out, vec![]);
}
}
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Full
}
);
assert!(s.failed().is_empty());
}
#[test]
fn configured_optional_child_failure_is_degraded() {
// A configured optional child (mDNS) fails but ≥1 transport is up →
// Degraded, with the failed child in `reasons`. The node stays
// operational and tears down cleanly (the failed child never joined the
// up-set, so it is excluded from teardown; workers excluded by design).
let mut s = SupervisorFsm::new();
s.step(start_full());
for child in [
Child::Transport(tid(1)),
Child::Transport(tid(2)),
Child::EncryptWorkers,
Child::DecryptWorkers,
Child::Nostr,
] {
assert_eq!(s.step(Event::SubstrateUp { child }), vec![]);
}
// mdns fails, tun comes up, dns comes up last (empties pending).
assert_eq!(
s.step(Event::SubstrateFailed { child: Child::Mdns }),
vec![]
);
assert_eq!(s.step(Event::SubstrateUp { child: Child::Tun }), vec![]);
assert_eq!(
s.step(Event::SubstrateUp { child: Child::Dns }),
vec![Action::PublishState(NodeState::Degraded)]
);
let mut expected_reasons = HashSet::new();
expected_reasons.insert(Child::Mdns);
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Degraded {
reasons: expected_reasons.clone()
}
}
);
assert_eq!(s.failed(), &expected_reasons);
let stops = s.step(Event::Stop);
// mdns must not appear in teardown; workers excluded by design.
assert_eq!(
stops,
vec![
Action::StopChild(Child::Dns),
Action::StopChild(Child::Nostr),
Action::StopChild(Child::Transport(tid(1))),
Action::StopChild(Child::Transport(tid(2))),
Action::StopChild(Child::Tun),
]
);
}
#[test]
fn worker_pool_failure_is_degraded_not_failed() {
// A worker-pool spawn failure is Degraded at most, never Failed
// (inline crypto fallback keeps the node correct). One transport is up.
let mut s = SupervisorFsm::new();
s.step(Event::Start {
transports: vec![tid(1)],
encrypt_workers: true,
decrypt_workers: false,
nostr: false,
mdns: false,
tun: false,
dns: false,
});
assert_eq!(
s.step(Event::SubstrateUp {
child: Child::Transport(tid(1))
}),
vec![]
);
assert_eq!(
s.step(Event::SubstrateFailed {
child: Child::EncryptWorkers
}),
vec![Action::PublishState(NodeState::Degraded)]
);
let mut expected = HashSet::new();
expected.insert(Child::EncryptWorkers);
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Degraded { reasons: expected }
}
);
}
#[test]
fn not_configured_child_does_not_cause_degraded() {
// A node that never asked to run mDNS/TUN/DNS/Nostr is not degraded for
// lacking them: only a configured-and-failed child counts. One transport
// configured and up, nothing else configured → Full.
let mut s = SupervisorFsm::new();
s.step(Event::Start {
transports: vec![tid(1)],
encrypt_workers: false,
decrypt_workers: false,
nostr: false,
mdns: false,
tun: false,
dns: false,
});
assert_eq!(
s.step(Event::SubstrateUp {
child: Child::Transport(tid(1))
}),
vec![Action::PublishState(NodeState::Running)]
);
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Full
}
);
}
#[test]
fn zero_transports_up_is_failed_via_child_failures() {
// Transports were configured but all failed → zero transports up →
// Failed (fatal), even though other children came up. Failed takes
// priority over Degraded.
let mut s = SupervisorFsm::new();
s.step(Event::Start {
transports: vec![tid(1)],
encrypt_workers: false,
decrypt_workers: false,
nostr: true,
mdns: false,
tun: false,
dns: false,
});
assert_eq!(
s.step(Event::SubstrateFailed {
child: Child::Transport(tid(1))
}),
vec![]
);
assert_eq!(
s.step(Event::SubstrateUp {
child: Child::Nostr
}),
vec![Action::PublishState(NodeState::Failed)]
);
assert_eq!(
s.state(),
&SupState::Failed {
reason: FailReason::NoTransports
}
);
}
#[test]
fn no_children_is_failed_immediately() {
// The degenerate empty-`Start` path: zero transports → Failed (the
// behavioral change from the old immediate-Running).
let mut s = SupervisorFsm::new();
let actions = s.step(Event::Start {
transports: vec![],
encrypt_workers: false,
decrypt_workers: false,
nostr: false,
mdns: false,
tun: false,
dns: false,
});
assert_eq!(actions, vec![Action::PublishState(NodeState::Failed)]);
assert_eq!(
s.state(),
&SupState::Failed {
reason: FailReason::NoTransports
}
);
}
#[test]
fn stop_teardown_order_excludes_workers() {
let mut s = SupervisorFsm::new();
s.step(start_full());
for child in [
Child::Transport(tid(2)),
Child::Transport(tid(1)),
Child::EncryptWorkers,
Child::DecryptWorkers,
Child::Nostr,
Child::Mdns,
Child::Tun,
Child::Dns,
] {
s.step(Event::SubstrateUp { child });
}
let stops = s.step(Event::Stop);
assert_eq!(
stops,
vec![
Action::StopChild(Child::Dns),
Action::StopChild(Child::Nostr),
Action::StopChild(Child::Mdns),
// transports ascending by id regardless of up-report order
Action::StopChild(Child::Transport(tid(1))),
Action::StopChild(Child::Transport(tid(2))),
Action::StopChild(Child::Tun),
]
);
assert!(matches!(s.state(), SupState::Stopping { .. }));
}
#[test]
fn all_children_stopped_reaches_stopped() {
let mut s = SupervisorFsm::new();
s.step(start_full());
// Every spawned child reports an outcome: five come up, three fail.
// `pending` drains fully; one transport (tid(1)) is up so the node
// reaches `Running`, but a configured transport (tid(2)) and two
// configured optional children failed → Degraded.
for child in [
Child::Transport(tid(1)),
Child::EncryptWorkers,
Child::Nostr,
Child::Tun,
Child::Dns,
] {
s.step(Event::SubstrateUp { child });
}
for child in [Child::Transport(tid(2)), Child::DecryptWorkers, Child::Mdns] {
s.step(Event::SubstrateFailed { child });
}
assert!(matches!(
s.state(),
SupState::Running {
health: Health::Degraded { .. }
}
));
// Only the children that came up are torn down; the failed ones never
// joined the up-set.
let stops = s.step(Event::Stop);
assert_eq!(
stops,
vec![
Action::StopChild(Child::Dns),
Action::StopChild(Child::Nostr),
Action::StopChild(Child::Transport(tid(1))),
Action::StopChild(Child::Tun),
]
);
for a in stops {
let child = match a {
Action::StopChild(c) => c,
_ => panic!("unexpected action"),
};
assert_eq!(s.step(Event::ChildStopped { child }), vec![]);
}
assert_eq!(s.state(), &SupState::Stopped);
}
#[test]
fn late_substrate_up_in_running_is_inert() {
let mut s = SupervisorFsm::new();
s.step(Event::Start {
transports: vec![tid(1)],
encrypt_workers: false,
decrypt_workers: false,
nostr: false,
mdns: false,
tun: false,
dns: false,
});
s.step(Event::SubstrateUp {
child: Child::Transport(tid(1)),
});
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Full
}
);
// A stray event in Running produces nothing and does not change state.
assert_eq!(
s.step(Event::SubstrateUp {
child: Child::Nostr
}),
vec![]
);
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Full
}
);
}
#[test]
fn drain_from_running_emits_entry_actions_and_enters_draining() {
let mut s = SupervisorFsm::running_with([
Child::Dns,
Child::Nostr,
Child::Transport(tid(1)),
Child::Tun,
]);
let actions = s.step(Event::Drain { deadline_ms: 5_000 });
// Order matters: broadcast → arm timer → gate reconciler off.
assert_eq!(
actions,
vec![
Action::BroadcastDisconnect,
Action::SetTimer(Timer::DrainDeadline, 5_000),
Action::SetPeeringDesired(PeeringDesired::Empty),
Action::SuspendReplenish,
]
);
assert_eq!(s.state(), &SupState::Draining { deadline_ms: 5_000 });
}
#[test]
fn drain_deadline_elapsed_yields_teardown_and_enters_stopping() {
let mut s = SupervisorFsm::running_with([
Child::Dns,
Child::Nostr,
Child::Mdns,
Child::Transport(tid(2)),
Child::Transport(tid(1)),
Child::Tun,
]);
s.step(Event::Drain { deadline_ms: 2_000 });
let stops = s.step(Event::DrainDeadlineElapsed);
// Same ordering the immediate `Stop` path authors: dns → nostr → mdns →
// transports (ascending id) → tun.
assert_eq!(
stops,
vec![
Action::StopChild(Child::Dns),
Action::StopChild(Child::Nostr),
Action::StopChild(Child::Mdns),
Action::StopChild(Child::Transport(tid(1))),
Action::StopChild(Child::Transport(tid(2))),
Action::StopChild(Child::Tun),
]
);
assert!(matches!(s.state(), SupState::Stopping { .. }));
}
#[test]
fn drain_teardown_matches_immediate_stop_teardown() {
// The drain path and the immediate-stop path must produce the identical
// StopChild plan over the same up-set (single teardown author).
let up = [
Child::Dns,
Child::Nostr,
Child::Mdns,
Child::Transport(tid(1)),
Child::Transport(tid(3)),
Child::Tun,
];
let mut immediate = SupervisorFsm::running_with(up);
let stop_plan = immediate.step(Event::Stop);
let mut drained = SupervisorFsm::running_with(up);
drained.step(Event::Drain { deadline_ms: 1_000 });
let drain_plan = drained.step(Event::DrainDeadlineElapsed);
assert_eq!(stop_plan, drain_plan);
}
#[test]
fn drain_is_inert_from_non_running() {
// From `Created`.
let mut s = SupervisorFsm::new();
assert_eq!(s.step(Event::Drain { deadline_ms: 1_000 }), vec![]);
assert_eq!(s.state(), &SupState::Created);
// From `Stopping` (seed a drain, close its window, then try to drain
// again — inert).
let mut s2 = SupervisorFsm::running_with([Child::Transport(tid(1))]);
s2.step(Event::Drain { deadline_ms: 1_000 });
s2.step(Event::DrainDeadlineElapsed);
assert!(matches!(s2.state(), SupState::Stopping { .. }));
assert_eq!(s2.step(Event::Drain { deadline_ms: 1_000 }), vec![]);
assert!(matches!(s2.state(), SupState::Stopping { .. }));
}
#[test]
fn drain_deadline_elapsed_is_inert_outside_draining() {
// Inert from `Running` (no drain in progress).
let mut s = SupervisorFsm::running_with([Child::Transport(tid(1))]);
assert_eq!(s.step(Event::DrainDeadlineElapsed), vec![]);
assert_eq!(
s.state(),
&SupState::Running {
health: Health::Full
}
);
}
}