mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
The encrypt and decrypt worker pools discarded their thread handles, so a worker that panicked left the node reporting full health while every packet hashed to that worker was dropped behind a DEBUG line. A worker thread that could not be started panicked start-up instead. The pools now keep each worker's thread handle and report how many are live. A worker that cannot be started is logged and left dead, and the node starts degraded instead of aborting; a pool with no live worker is not installed, so its traffic takes the main-loop path. A sweep on the rx loop tick notices a worker that exits at runtime, logs a warning naming the pool and the live and configured counts, and reports the pool to the supervisor, which publishes Degraded. Losing workers never fails the node: the pools are an offload, and Windows runs without them. A dispatch refused by an exited worker is now counted and logged at WARN, rate-limited, in place of the DEBUG line.
1877 lines
71 KiB
Rust
1877 lines
71 KiB
Rust
//! Node lifecycle supervisor — sans-IO core.
|
|
//!
|
|
//! 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 (cores are sans-IO).
|
|
//!
|
|
//! ## Scope: the behavior-neutral rewrite
|
|
//!
|
|
//! This module 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 module 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 module implements the operator-visible start-completion health policy
|
|
//! 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 (a `ChildExited` event re-routing health when a
|
|
//! task/thread dies at runtime) came later. Its producers are the children that
|
|
//! report their own exit (TUN threads, the DNS task, the mDNS/Nostr monitor)
|
|
//! and, for the crypto worker pools, the rx loop tick's liveness sweep, which
|
|
//! reports a pool's child as exited when it loses a worker.
|
|
//!
|
|
//! ## Scope: interface presence, and `Degraded` as a level (this commit)
|
|
//!
|
|
//! Interface-bound transports are now *sometimes bound*: a transport whose
|
|
//! interface is missing at start comes up [`Absent`] and binds later, and one
|
|
//! whose interface goes away at runtime unbinds and rebinds when it returns.
|
|
//! Two things follow for this machine.
|
|
//!
|
|
//! - **A second reason set.** [`Event::ChildAbsent`] / [`Event::ChildPresent`]
|
|
//! move a child in and out of `absent`, which feeds `Degraded` exactly like
|
|
//! `failed` does. It is kept separate because it is *reversible* and `failed`
|
|
//! is not: a child that failed to start stays failed for the bring-up, while
|
|
//! an absent interface is expected to come back.
|
|
//! - **`Degraded` is a level, not a latch.** Nothing ever removed from `failed`,
|
|
//! which was correct while no child could recover — a monotonic set accurately
|
|
//! described a one-way door. Once recovery exists the assumption inverts: plug
|
|
//! the WAN back in and the node would stay `Degraded` until the process
|
|
//! restarted, and `Degraded` would come to mean "something broke at some point
|
|
//! since boot" rather than "something is broken now".
|
|
//! [`Self::classify_health`](SupervisorFsm::classify_health) is therefore
|
|
//! recomputed on every transition **in both directions**.
|
|
//!
|
|
//! An absent transport still counts as *up*. It came up — `start_async` returns
|
|
//! `Ok` with the transport absent — so it does not push a single-transport node
|
|
//! into the fatal [`FailReason::NoTransports`], which would make a node that
|
|
//! merely booted before its wifi exit instead of waiting. Absence degrades; it
|
|
//! never kills.
|
|
//!
|
|
//! There is deliberately **no restart action**. Rebinding is owned by the
|
|
//! transport's own binder task, which is where the file descriptor and the
|
|
//! presence watcher live; the FSM is told what happened and republishes health.
|
|
//!
|
|
//! [`Absent`]: crate::transport::ethernet::Presence::Absent
|
|
|
|
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.
|
|
///
|
|
/// 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` is **deferred** (the per-tick reconciler backstop lands
|
|
/// with the cadence work). `ChildExited` is present: it feeds runtime
|
|
/// child-liveness monitoring, routing health the same way a start failure does.
|
|
#[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 supervised child's task or thread exited on its own at runtime — not
|
|
/// in response to a `StopChild`. Valid from `Running`; routes
|
|
/// health the same way a start failure does (the last transport out →
|
|
/// `Failed`, an optional child out → `Degraded`), but at runtime `Failed` is
|
|
/// a published health signal, not a teardown — the driver keeps serving. No
|
|
/// restart (the FSM has no restart action). Inert outside `Running`.
|
|
ChildExited {
|
|
/// The child whose task/thread exited.
|
|
child: Child,
|
|
},
|
|
/// An interface-bound child lost its interface — it was never there at
|
|
/// start, or it went away at runtime. The child stays *up* (the transport
|
|
/// object survives detach, only its socket goes) but contributes
|
|
/// `Degraded`. Valid while `Running`; while `Starting` the edge is recorded
|
|
/// so start-completion health already reflects it.
|
|
ChildAbsent {
|
|
/// The child whose interface is absent.
|
|
child: Child,
|
|
},
|
|
/// An interface-bound child's interface came back and it rebound. Clears
|
|
/// the absence and republishes health, which is how `Degraded` becomes
|
|
/// reversible.
|
|
ChildPresent {
|
|
/// The child whose interface is present again.
|
|
child: Child,
|
|
},
|
|
}
|
|
|
|
/// A driver-scheduled timer the supervisor can arm. Only the
|
|
/// drain deadline exists for now; the handshake/rekey/liveness timers
|
|
/// 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 (drain gate). Only
|
|
/// `Empty` is needed for now; the populated variants that the
|
|
/// 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 ("Draining switches the
|
|
/// homeostat off").
|
|
Empty,
|
|
}
|
|
|
|
/// An effect the driver must perform. The core never performs I/O itself.
|
|
///
|
|
/// `PublishState` 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.
|
|
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 (drain gate). Documented
|
|
/// **no-op for now** — the reconciler that consumes it is not yet
|
|
/// built; the driver logs/ignores it for now.
|
|
SetPeeringDesired(PeeringDesired),
|
|
/// Suspend peer replenishment (drain gate). Documented **no-op for
|
|
/// now** for the same reason as `SetPeeringDesired`.
|
|
SuspendReplenish,
|
|
}
|
|
|
|
/// Start-completion health. 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 that did not start every worker). 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.
|
|
#[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. 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 — 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. 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.
|
|
///
|
|
/// One-way within a bring-up: a start failure is not recoverable, so
|
|
/// nothing removes from this set until the next `Start`.
|
|
failed: HashSet<Child>,
|
|
/// Children that are up but whose network interface is currently absent.
|
|
///
|
|
/// Reversible, unlike [`Self::failed`] — that is the whole reason it is a
|
|
/// second set rather than more entries in the first. Feeds `Degraded` the
|
|
/// same way, and empties as interfaces come back.
|
|
absent: HashSet<Child>,
|
|
}
|
|
|
|
impl SupervisorFsm {
|
|
/// A fresh supervisor in `Created`.
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
state: SupState::Created,
|
|
up: HashSet::new(),
|
|
failed: HashSet::new(),
|
|
absent: 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(),
|
|
absent: 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. Kept for
|
|
/// the tests that pin the failure-vs-absence split; the driver reports
|
|
/// [`Self::degraded_children`], which is the union of the two.
|
|
#[cfg(test)]
|
|
pub(in crate::node) fn failed(&self) -> &HashSet<Child> {
|
|
&self.failed
|
|
}
|
|
|
|
/// Children whose interface is currently absent.
|
|
pub(in crate::node) fn absent(&self) -> &HashSet<Child> {
|
|
&self.absent
|
|
}
|
|
|
|
/// Every child currently contributing `Degraded` — the ones that failed to
|
|
/// start plus the ones whose interface is away. This is what an operator
|
|
/// wants named when the node reports `Degraded`.
|
|
pub(in crate::node) fn degraded_children(&self) -> HashSet<Child> {
|
|
self.failed.union(&self.absent).copied().collect()
|
|
}
|
|
|
|
/// 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),
|
|
Event::ChildExited { child } => self.on_child_exited(child),
|
|
Event::ChildAbsent { child } => self.on_child_absent(child),
|
|
Event::ChildPresent { child } => self.on_child_present(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();
|
|
self.absent.clear();
|
|
|
|
// A node with no children at all resolves health immediately. Zero
|
|
// transports up → `Failed` (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: 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. Called once when
|
|
/// `Starting.pending` empties (or the degenerate no-children path); the
|
|
/// classification is shared with runtime child-exit via
|
|
/// [`Self::classify_health`].
|
|
fn resolve_start_health(&mut self) -> NodeState {
|
|
self.classify_health()
|
|
}
|
|
|
|
/// Classify health from the current `up` / `failed` / `absent` sets and set
|
|
/// the resulting state, returning the [`NodeState`] the driver should
|
|
/// publish. Shared by start-completion ([`Self::resolve_start_health`]),
|
|
/// runtime child-exit ([`Self::on_child_exited`]) and the presence edges
|
|
/// ([`Self::on_child_absent`] / [`Self::on_child_present`]):
|
|
///
|
|
/// - zero transports up → [`SupState::Failed`] / [`NodeState::Failed`];
|
|
/// - ≥1 transport up but some child in `failed` or `absent` →
|
|
/// [`Health::Degraded`] / [`NodeState::Degraded`];
|
|
/// - everything up, nothing failed, nothing absent → [`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.
|
|
///
|
|
/// A transport whose interface is absent is still counted among
|
|
/// `transports_up`: it came up, it is simply not bound. Excluding it would
|
|
/// make a single-ethernet node that booted before its wifi resolve to the
|
|
/// fatal [`FailReason::NoTransports`] and exit — which is the failure this
|
|
/// whole mechanism exists to remove. Absence degrades; it never kills.
|
|
///
|
|
/// Recomputed in **both** directions. This function is the reason
|
|
/// `Degraded` is a level rather than a latch.
|
|
fn classify_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.absent.is_empty() {
|
|
self.state = SupState::Running {
|
|
health: Health::Degraded {
|
|
reasons: self.degraded_children(),
|
|
},
|
|
};
|
|
NodeState::Degraded
|
|
} else {
|
|
self.state = SupState::Running {
|
|
health: Health::Full,
|
|
};
|
|
NodeState::Running
|
|
}
|
|
}
|
|
|
|
/// An interface-bound child lost its interface.
|
|
///
|
|
/// The child stays in the up-set: the transport object survives detach —
|
|
/// config, id, statistics and neighbor buffer persist, only the descriptor
|
|
/// and its loops go. Only the reason set changes.
|
|
///
|
|
/// While `Starting` the edge is recorded silently; start-completion health
|
|
/// picks it up when `pending` empties, so a boot race resolves to
|
|
/// `Degraded` on the first publish rather than publishing `Full` and
|
|
/// immediately correcting it. Inert outside `Starting` / `Running`: a
|
|
/// teardown in flight owns its own bookkeeping.
|
|
fn on_child_absent(&mut self, child: Child) -> Vec<Action> {
|
|
match self.state {
|
|
SupState::Starting { .. } => {
|
|
self.absent.insert(child);
|
|
Vec::new()
|
|
}
|
|
SupState::Running { .. } => {
|
|
if !self.absent.insert(child) {
|
|
return Vec::new();
|
|
}
|
|
vec![Action::PublishState(self.classify_health())]
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// An interface-bound child's interface came back.
|
|
///
|
|
/// Clears the absence and republishes. A child that is not currently
|
|
/// recorded absent produces nothing — a duplicate edge is not an event.
|
|
fn on_child_present(&mut self, child: Child) -> Vec<Action> {
|
|
match self.state {
|
|
SupState::Starting { .. } => {
|
|
self.absent.remove(&child);
|
|
Vec::new()
|
|
}
|
|
SupState::Running { .. } => {
|
|
if !self.absent.remove(&child) {
|
|
return Vec::new();
|
|
}
|
|
vec![Action::PublishState(self.classify_health())]
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
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. 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);
|
|
self.absent.remove(&child);
|
|
if pending.is_empty() {
|
|
self.state = SupState::Stopped;
|
|
}
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
/// A supervised child exited on its own at runtime. Only
|
|
/// meaningful while `Running`: startup (`Starting`), drain (`Draining`), and
|
|
/// teardown (`Stopping`) own their own child bookkeeping through the
|
|
/// `pending` / `up` sets and the `SubstrateUp` / `SubstrateFailed` /
|
|
/// `ChildStopped` events, so a stray exit signal in those states is ignored.
|
|
///
|
|
/// The exit is routed exactly like a start-time failure via
|
|
/// [`Self::classify_health`] — the last transport out → `Failed`, an optional
|
|
/// child out → `Degraded{+child}` — the runtime analogue of the
|
|
/// start-time policy. Two differences from start: `Failed` here is a
|
|
/// published health signal only (the driver keeps serving, per the resolved
|
|
/// runtime policy — no auto-teardown), and there is no restart (the FSM has
|
|
/// no restart action). If the exiting child is not currently up (a duplicate
|
|
/// signal, or one that never came up) the machine is unchanged and emits
|
|
/// nothing.
|
|
fn on_child_exited(&mut self, child: Child) -> Vec<Action> {
|
|
if !matches!(self.state, SupState::Running { .. }) {
|
|
return Vec::new();
|
|
}
|
|
if !self.up.remove(&child) {
|
|
return Vec::new();
|
|
}
|
|
// An exit supersedes an absence: the child is gone, not waiting.
|
|
self.absent.remove(&child);
|
|
self.failed.insert(child);
|
|
vec![Action::PublishState(self.classify_health())]
|
|
}
|
|
|
|
/// 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 and FreeBSD. On Linux, deleting the interface via netlink
|
|
/// serves the same purpose.
|
|
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
|
|
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<()>>,
|
|
/// Address the DNS responder actually bound, read back from the socket
|
|
/// after `bind` so a port-0 config resolves to the assigned port. `Some`
|
|
/// only while the responder is up; published to embedders through
|
|
/// [`Node::dns_local_addr`](crate::Node::dns_local_addr).
|
|
pub(in crate::node) dns_local_addr: Option<std::net::SocketAddr>,
|
|
/// Sends a new peer-alias base to the running DNS responder; `Some` only
|
|
/// while it runs.
|
|
pub(in crate::node) dns_aliases:
|
|
Option<tokio::sync::watch::Sender<crate::upper::hosts::HostMap>>,
|
|
|
|
/// Sender for each UDP listen socket the transport spawn binds — its raw
|
|
/// fd and the instance name it was configured under — armed by
|
|
/// [`Node::enable_app_owned_udp_fd`](crate::Node::enable_app_owned_udp_fd)
|
|
/// and fired from the transport-spawn arm of `start()` once the socket is
|
|
/// bound. `None` unless the embedder armed it.
|
|
#[cfg(unix)]
|
|
pub(in crate::node) udp_fd_tx: Option<std::sync::mpsc::Sender<crate::node::AppOwnedUdpSocket>>,
|
|
|
|
/// 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 no worker
|
|
/// thread could be started.
|
|
#[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.
|
|
/// None on Windows, with `FIPS_DECRYPT_WORKERS=0`, or when no worker
|
|
/// thread could be started.
|
|
#[cfg(unix)]
|
|
pub(crate) decrypt_workers: Option<crate::node::decrypt_worker::DecryptWorkerPool>,
|
|
|
|
/// Pools a test has built for start-up to install in place of spawning
|
|
/// its own.
|
|
#[cfg(all(test, unix))]
|
|
pub(in crate::node) staged_pools: StagedPools,
|
|
|
|
/// Transport-medium change detection: the receiver the rx loop drains and
|
|
/// the detector task behind it.
|
|
///
|
|
/// Deliberately **not** a [`Child`]. The FSM's children are the substrate
|
|
/// the node's health is defined against — a detector that dies makes
|
|
/// recovery slower, not the node unhealthy, and giving it a `Child` would
|
|
/// put it in the start plan, the teardown order and the N-of-M health
|
|
/// policy for no gain. It is spawned after bring-up and aborted in
|
|
/// teardown, like the runtime child-liveness monitor.
|
|
pub(in crate::node) netmon_rx: Option<crate::node::netmon::NetChangeRx>,
|
|
/// Handle for the detector task, aborted at teardown.
|
|
pub(in crate::node) netmon_task: Option<tokio::task::JoinHandle<()>>,
|
|
|
|
/// The sans-IO lifecycle FSM authoring spawn/teardown ordering.
|
|
pub(in crate::node) fsm: SupervisorFsm,
|
|
}
|
|
|
|
/// Crypto worker pools a test hands to start-up, each taken by the first
|
|
/// start of its pool's child.
|
|
#[cfg(all(test, unix))]
|
|
#[derive(Default)]
|
|
pub(in crate::node) struct StagedPools {
|
|
pub encrypt: Option<crate::node::encrypt_worker::EncryptWorkerPool>,
|
|
pub decrypt: Option<crate::node::decrypt_worker::DecryptWorkerPool>,
|
|
}
|
|
|
|
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(any(target_os = "macos", target_os = "freebsd"))]
|
|
tun_shutdown_fd: None,
|
|
dns_identity_rx: None,
|
|
dns_task: None,
|
|
dns_local_addr: None,
|
|
dns_aliases: None,
|
|
#[cfg(unix)]
|
|
udp_fd_tx: None,
|
|
nostr_rendezvous: crate::nostr::RendezvousDriver::default(),
|
|
lan_rendezvous: None,
|
|
#[cfg(unix)]
|
|
encrypt_workers: None,
|
|
#[cfg(unix)]
|
|
decrypt_workers: None,
|
|
#[cfg(all(test, unix))]
|
|
staged_pools: StagedPools::default(),
|
|
netmon_rx: None,
|
|
netmon_task: 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
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_optional_is_degraded() {
|
|
// An optional child (Nostr) exits at runtime with a transport still up →
|
|
// Degraded, the exited child recorded in `reasons` and dropped from `up`.
|
|
let mut s = SupervisorFsm::running_with([
|
|
Child::Transport(tid(1)),
|
|
Child::Nostr,
|
|
Child::Tun,
|
|
Child::Dns,
|
|
]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Nostr
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
let mut reasons = HashSet::new();
|
|
reasons.insert(Child::Nostr);
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Running {
|
|
health: Health::Degraded {
|
|
reasons: reasons.clone()
|
|
}
|
|
}
|
|
);
|
|
assert_eq!(s.failed(), &reasons);
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_transport_beyond_first_is_degraded() {
|
|
// One of several transports exits → Degraded (≥1 transport remains up),
|
|
// not Failed.
|
|
let mut s =
|
|
SupervisorFsm::running_with([Child::Transport(tid(1)), Child::Transport(tid(2))]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert!(matches!(
|
|
s.state(),
|
|
SupState::Running {
|
|
health: Health::Degraded { .. }
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_last_transport_is_failed() {
|
|
// The only transport exits → Failed (published), but no teardown action:
|
|
// per the runtime policy the driver keeps serving on the Failed health
|
|
// signal; the FSM emits only `PublishState(Failed)`.
|
|
let mut s = SupervisorFsm::running_with([Child::Transport(tid(1)), Child::Dns]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Failed)]
|
|
);
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Failed {
|
|
reason: FailReason::NoTransports
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_accumulates_reasons_then_fails_on_last_transport() {
|
|
// Runtime exits accumulate in `reasons` and stay Degraded while a
|
|
// transport survives; the last transport out flips to Failed.
|
|
let mut s = SupervisorFsm::running_with([
|
|
Child::Transport(tid(1)),
|
|
Child::Transport(tid(2)),
|
|
Child::Nostr,
|
|
]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Nostr
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
// Still Degraded (tid(2) up); reasons carry both prior exits.
|
|
let mut reasons = HashSet::new();
|
|
reasons.insert(Child::Nostr);
|
|
reasons.insert(Child::Transport(tid(1)));
|
|
assert_eq!(s.failed(), &reasons);
|
|
// Last transport out → Failed.
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Transport(tid(2))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Failed)]
|
|
);
|
|
assert!(matches!(s.state(), SupState::Failed { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_is_inert_outside_running() {
|
|
// From `Created`: no producer should fire pre-Running, but a stray signal
|
|
// is ignored.
|
|
let mut created = SupervisorFsm::new();
|
|
assert_eq!(
|
|
created.step(Event::ChildExited {
|
|
child: Child::Nostr
|
|
}),
|
|
vec![]
|
|
);
|
|
assert_eq!(created.state(), &SupState::Created);
|
|
|
|
// From `Draining`: a child exiting during the drain window is the drain's
|
|
// own teardown, owned by the `ChildStopped` path — ignore it here.
|
|
let mut draining = SupervisorFsm::running_with([Child::Transport(tid(1)), Child::Dns]);
|
|
draining.step(Event::Drain { deadline_ms: 1_000 });
|
|
assert_eq!(
|
|
draining.step(Event::ChildExited { child: Child::Dns }),
|
|
vec![]
|
|
);
|
|
assert!(matches!(draining.state(), SupState::Draining { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn child_exited_unknown_child_is_noop() {
|
|
// A child not in the up-set (duplicate signal, or never up) leaves the
|
|
// machine unchanged and emits nothing.
|
|
let mut s = SupervisorFsm::running_with([Child::Transport(tid(1))]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::Nostr
|
|
}),
|
|
vec![]
|
|
);
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Running {
|
|
health: Health::Full
|
|
}
|
|
);
|
|
assert!(s.failed().is_empty());
|
|
}
|
|
|
|
// ── Interface presence ────────────────────────────────────────────────
|
|
//
|
|
// The absence set is separate from `failed` because it is reversible, and
|
|
// reversibility is what turns `Degraded` from a latch into a level.
|
|
|
|
/// Helper: bring a full node up cleanly and leave it `Running{Full}`.
|
|
fn running_node() -> SupervisorFsm {
|
|
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,
|
|
Child::Mdns,
|
|
Child::Tun,
|
|
Child::Dns,
|
|
] {
|
|
s.step(Event::SubstrateUp { child });
|
|
}
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Running {
|
|
health: Health::Full
|
|
}
|
|
);
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn an_absent_interface_degrades_a_running_node() {
|
|
let mut s = running_node();
|
|
assert_eq!(
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert!(s.absent().contains(&Child::Transport(tid(1))));
|
|
// Absence is not failure: the two sets stay distinct.
|
|
assert!(s.failed().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_returning_interface_clears_degraded() {
|
|
// The regression this whole design turns on: plug the WAN back in and
|
|
// the node must leave `Degraded`, not carry it until the process
|
|
// restarts. `Degraded` is a level, not a latch.
|
|
let mut s = running_node();
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
assert_eq!(
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Running)]
|
|
);
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Running {
|
|
health: Health::Full
|
|
}
|
|
);
|
|
assert!(s.absent().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn health_reflects_the_last_interface_still_away() {
|
|
// Two interfaces away, one returns: still Degraded. Only the empty
|
|
// absence set publishes Full.
|
|
let mut s = running_node();
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(2)),
|
|
});
|
|
assert_eq!(
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert_eq!(
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(2))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Running)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_duplicate_presence_edge_is_not_an_event() {
|
|
let mut s = running_node();
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
assert_eq!(
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![],
|
|
"re-reporting the same absence must not republish"
|
|
);
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
assert_eq!(
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![],
|
|
"re-reporting the same return must not republish"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn absence_during_bringup_resolves_degraded_on_the_first_publish() {
|
|
// The boot race. The transport reports absence from inside its own
|
|
// start, then reports up. Start-completion health must already know,
|
|
// so the node never publishes `Full` and corrects itself.
|
|
let mut s = SupervisorFsm::new();
|
|
s.step(start_full());
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
for child in [
|
|
Child::Transport(tid(1)),
|
|
Child::Transport(tid(2)),
|
|
Child::EncryptWorkers,
|
|
Child::DecryptWorkers,
|
|
Child::Nostr,
|
|
Child::Mdns,
|
|
Child::Tun,
|
|
] {
|
|
assert_eq!(s.step(Event::SubstrateUp { child }), vec![]);
|
|
}
|
|
assert_eq!(
|
|
s.step(Event::SubstrateUp { child: Child::Dns }),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
let mut reasons = HashSet::new();
|
|
reasons.insert(Child::Transport(tid(1)));
|
|
assert_eq!(
|
|
s.state(),
|
|
&SupState::Running {
|
|
health: Health::Degraded { reasons }
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_lone_absent_transport_degrades_rather_than_fails() {
|
|
// A single-ethernet node that boots before its wifi. The transport
|
|
// came up — absence is a state, not a start failure — so it counts
|
|
// among the transports up and the node serves. Resolving to `Failed`
|
|
// here would make the daemon exit on the very race this mechanism
|
|
// exists to survive.
|
|
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::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
assert_eq!(
|
|
s.step(Event::SubstrateUp {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert!(
|
|
!matches!(s.state(), SupState::Failed { .. }),
|
|
"an absent interface must never be the fatal no-transports case"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_exit_supersedes_an_absence() {
|
|
// A transport that is away and then exits is gone, not waiting: it
|
|
// leaves the up-set and moves from `absent` to `failed`, so a later
|
|
// spurious `ChildPresent` cannot resurrect it into `Full`.
|
|
let mut s = running_node();
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
s.step(Event::ChildExited {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
assert!(s.absent().is_empty());
|
|
assert!(s.failed().contains(&Child::Transport(tid(1))));
|
|
assert_eq!(
|
|
s.step(Event::ChildPresent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![]
|
|
);
|
|
assert!(matches!(
|
|
s.state(),
|
|
SupState::Running {
|
|
health: Health::Degraded { .. }
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn presence_edges_are_inert_outside_starting_and_running() {
|
|
// Teardown and drain own their own bookkeeping; a late edge from a
|
|
// binder that has not noticed the stop must not author actions.
|
|
let mut created = SupervisorFsm::new();
|
|
assert_eq!(
|
|
created.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![]
|
|
);
|
|
|
|
let mut draining = running_node();
|
|
draining.step(Event::Drain { deadline_ms: 1 });
|
|
assert_eq!(
|
|
draining.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1))
|
|
}),
|
|
vec![]
|
|
);
|
|
assert!(
|
|
draining.is_draining(),
|
|
"a presence edge must not end a drain"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_new_start_clears_the_previous_absences() {
|
|
let mut s = running_node();
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
s.step(Event::Stop);
|
|
for child in [
|
|
Child::Dns,
|
|
Child::Nostr,
|
|
Child::Mdns,
|
|
Child::Transport(tid(1)),
|
|
Child::Transport(tid(2)),
|
|
Child::Tun,
|
|
] {
|
|
s.step(Event::ChildStopped { child });
|
|
}
|
|
s.step(start_full());
|
|
assert!(s.absent().is_empty());
|
|
}
|
|
|
|
/// The crypto worker pools are a performance offload with a main-loop path
|
|
/// behind them, so losing one degrades the node and can never be what
|
|
/// fails it.
|
|
#[test]
|
|
fn a_worker_pool_exit_is_degraded_and_never_failed() {
|
|
let mut s = SupervisorFsm::running_with([
|
|
Child::Transport(tid(1)),
|
|
Child::EncryptWorkers,
|
|
Child::DecryptWorkers,
|
|
]);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::EncryptWorkers
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert_eq!(
|
|
s.step(Event::ChildExited {
|
|
child: Child::DecryptWorkers
|
|
}),
|
|
vec![Action::PublishState(NodeState::Degraded)]
|
|
);
|
|
assert!(matches!(
|
|
s.state(),
|
|
SupState::Running {
|
|
health: Health::Degraded { .. }
|
|
}
|
|
));
|
|
let degraded = s.degraded_children();
|
|
assert!(degraded.contains(&Child::EncryptWorkers));
|
|
assert!(degraded.contains(&Child::DecryptWorkers));
|
|
}
|
|
|
|
#[test]
|
|
fn degraded_children_is_the_union_of_both_reason_sets() {
|
|
let mut s = SupervisorFsm::new();
|
|
s.step(start_full());
|
|
s.step(Event::ChildAbsent {
|
|
child: Child::Transport(tid(1)),
|
|
});
|
|
s.step(Event::SubstrateFailed { child: Child::Mdns });
|
|
for child in [
|
|
Child::Transport(tid(1)),
|
|
Child::Transport(tid(2)),
|
|
Child::EncryptWorkers,
|
|
Child::DecryptWorkers,
|
|
Child::Nostr,
|
|
Child::Tun,
|
|
Child::Dns,
|
|
] {
|
|
s.step(Event::SubstrateUp { child });
|
|
}
|
|
let degraded = s.degraded_children();
|
|
assert!(degraded.contains(&Child::Mdns));
|
|
assert!(degraded.contains(&Child::Transport(tid(1))));
|
|
assert_eq!(degraded.len(), 2);
|
|
}
|
|
}
|