Merge branch 'master' into next

This commit is contained in:
Johnathan Corgan
2026-06-10 02:43:47 +00:00
9 changed files with 847 additions and 36 deletions
+35 -13
View File
@@ -13,9 +13,12 @@ pub mod firewall_state;
pub mod listening;
pub mod protocol;
pub mod queries;
pub mod read_handle;
pub mod snapshot;
use crate::config::ControlConfig;
use protocol::{Request, Response};
use read_handle::{ControlReadHandle, snapshot_dispatch};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot};
use tracing::{debug, info, warn};
@@ -36,6 +39,7 @@ pub type ControlMessage = (Request, oneshot::Sender<Response>);
async fn handle_connection_generic<S>(
stream: S,
control_tx: mpsc::Sender<ControlMessage>,
read_handle: ControlReadHandle,
) -> Result<(), Box<dyn std::error::Error>>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
@@ -73,15 +77,23 @@ where
// Parse the request
match serde_json::from_str::<Request>(line.trim()) {
Ok(request) => {
// Send to main loop and wait for response
let (resp_tx, resp_rx) = oneshot::channel();
if control_tx.send((request, resp_tx)).await.is_err() {
Response::error("node shutting down")
} else {
match tokio::time::timeout(IO_TIMEOUT, resp_rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => Response::error("response channel closed"),
Err(_) => Response::error("query timeout"),
// First try to serve the request entirely off-loop from the
// read handle. In R0 this always returns None (no query is
// cut over yet); R1+ adds the per-command snapshot branches.
match snapshot_dispatch(&request, &read_handle) {
Some(resp) => resp,
None => {
// Send to main loop and wait for response
let (resp_tx, resp_rx) = oneshot::channel();
if control_tx.send((request, resp_tx)).await.is_err() {
Response::error("node shutting down")
} else {
match tokio::time::timeout(IO_TIMEOUT, resp_rx).await {
Ok(Ok(resp)) => resp,
Ok(Err(_)) => Response::error("response channel closed"),
Err(_) => Response::error("query timeout"),
}
}
}
}
}
@@ -229,7 +241,11 @@ mod unix_impl {
/// 3. Wait for the response via oneshot
/// 4. Write the response as one line of JSON
/// 5. Close the connection
pub async fn accept_loop(self, control_tx: mpsc::Sender<ControlMessage>) {
pub(crate) async fn accept_loop(
self,
control_tx: mpsc::Sender<ControlMessage>,
read_handle: ControlReadHandle,
) {
loop {
let (stream, _addr) = match self.listener.accept().await {
Ok(conn) => conn,
@@ -240,8 +256,9 @@ mod unix_impl {
};
let tx = control_tx.clone();
let handle = read_handle.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_generic(stream, tx).await {
if let Err(e) = handle_connection_generic(stream, tx, handle).await {
debug!(error = %e, "Control connection error");
}
});
@@ -341,7 +358,11 @@ mod windows_impl {
///
/// Each accepted connection is handled in a spawned task using the
/// shared `handle_connection_generic` protocol handler.
pub async fn accept_loop(self, control_tx: mpsc::Sender<ControlMessage>) {
pub(crate) async fn accept_loop(
self,
control_tx: mpsc::Sender<ControlMessage>,
read_handle: ControlReadHandle,
) {
loop {
let (stream, addr) = match self.listener.accept().await {
Ok(conn) => conn,
@@ -358,8 +379,9 @@ mod windows_impl {
}
let tx = control_tx.clone();
let handle = read_handle.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_generic(stream, tx).await {
if let Err(e) = handle_connection_generic(stream, tx, handle).await {
debug!(error = %e, "Control connection error");
}
});
+401 -1
View File
@@ -88,6 +88,58 @@ pub fn show_status(node: &Node) -> Value {
})
}
/// Off-loop variant of [`show_status`]: renders from the
/// [`ControlReadHandle`](super::read_handle::ControlReadHandle) in the control
/// task. Reads the effectively-immutable `NodeContext`, the `MetricsRegistry`
/// counters, and the tick-published [`StatsSnapshot`](super::snapshot::StatsSnapshot)
/// (rings + scalar gauges/counts), with no `Node` state, so it never
/// round-trips the rx_loop. Output is byte-identical to [`show_status`].
pub(crate) fn show_status_from_handle(handle: &super::read_handle::ControlReadHandle) -> Value {
let ctx = handle.context();
let stats = handle.stats();
let pid = std::process::id();
let exe_path = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "-".into());
let uptime_secs = ctx.started_at.elapsed().as_secs();
let fwd = handle.metrics().forwarding.snapshot();
const SPARK_N: usize = 30;
let hist = &stats.history;
let sparklines = json!({
"mesh_size": hist.recent(Metric::MeshSize, SPARK_N),
"tree_depth": hist.recent(Metric::TreeDepth, SPARK_N),
"peer_count": hist.recent(Metric::PeerCount, SPARK_N),
"bytes_in": hist.recent(Metric::BytesIn, SPARK_N),
"bytes_out": hist.recent(Metric::BytesOut, SPARK_N),
"loss_rate": hist.recent(Metric::LossRate, SPARK_N),
});
json!({
"version": crate::version::short_version(),
"npub": ctx.identity.npub(),
"node_addr": hex::encode(ctx.identity.node_addr().as_bytes()),
"ipv6_addr": format!("{}", ctx.identity.address()),
"state": format!("{}", stats.state),
"is_leaf_only": ctx.is_leaf_only,
"peer_count": stats.peer_count,
"session_count": stats.session_count,
"link_count": stats.link_count,
"transport_count": stats.transport_count,
"connection_count": stats.connection_count,
"tun_state": format!("{}", stats.tun_state),
"tun_name": stats.tun_name.as_deref().unwrap_or("-"),
"effective_ipv6_mtu": stats.effective_ipv6_mtu,
"control_socket": &ctx.config.node.control.socket_path,
"pid": pid,
"exe_path": exe_path,
"uptime_secs": uptime_secs,
"estimated_mesh_size": stats.estimated_mesh_size,
"forwarding": serde_json::to_value(&fwd).unwrap_or_default(),
"sparklines": sparklines,
})
}
/// `show_acl` — Loaded peer ACL state.
pub fn show_acl(node: &Node) -> Value {
let status = node.peer_acl_status();
@@ -977,6 +1029,166 @@ pub fn show_stats_all_history(node: &Node, params: Option<&Value>) -> super::pro
}))
}
/// Off-loop display name for the stats-history error paths. The full
/// [`Node::peer_display_name`](crate::node::Node) lookup also consults the
/// host map and live peer/session tables, which are not in the snapshot;
/// off-loop we resolve a configured alias, else fall back to truncated hex.
/// Only reached on the "peer not tracked" error branch, never on the golden
/// happy path.
fn snapshot_display_name(
aliases: &std::collections::HashMap<NodeAddr, String>,
addr: &NodeAddr,
) -> String {
match aliases.get(addr) {
Some(name) => name.clone(),
None => addr.short_hex(),
}
}
/// Off-loop variant of [`show_stats_history`]: serves one metric's series from
/// the tick-published [`StatsSnapshot`](super::snapshot::StatsSnapshot) rings
/// (node-level or per-peer) in the control task, off the rx_loop. Output is
/// byte-identical to [`show_stats_history`] for the series; the "peer not
/// tracked" error message uses [`snapshot_display_name`] (alias-or-hex).
pub(crate) fn show_stats_history_from_handle(
handle: &super::read_handle::ControlReadHandle,
params: Option<&Value>,
) -> super::protocol::Response {
use super::protocol::Response;
let Some(params) = params else {
return Response::error("missing params for show_stats_history");
};
let metric_name = match params.get("metric").and_then(|v| v.as_str()) {
Some(v) => v,
None => return Response::error("missing 'metric' parameter"),
};
let window_str = params
.get("window")
.and_then(|v| v.as_str())
.unwrap_or("10m");
let window = match parse_duration(window_str) {
Ok(d) => d,
Err(e) => return Response::error(e),
};
let granularity_str = params
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("1s");
let granularity = match Granularity::from_str(granularity_str) {
Ok(g) => g,
Err(e) => return Response::error(e),
};
let peer_npub = params.get("peer").and_then(|v| v.as_str());
let stats = handle.stats();
let hist = &stats.history;
if let Some(npub) = peer_npub {
let addr = match parse_peer_npub(npub) {
Ok(a) => a,
Err(e) => return Response::error(e),
};
let peer_metric = match PeerMetric::from_str(metric_name) {
Ok(m) => m,
Err(e) => return Response::error(e),
};
match hist.peer_query(&addr, peer_metric, window, granularity) {
Some(series) => Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null)),
None => Response::error(format!(
"peer not tracked in stats history: {}",
snapshot_display_name(&stats.peer_aliases, &addr)
)),
}
} else {
let metric = match Metric::from_str(metric_name) {
Ok(m) => m,
Err(e) => return Response::error(e),
};
let series = hist.query(metric, window, granularity);
Response::ok(serde_json::to_value(&series).unwrap_or(Value::Null))
}
}
/// Off-loop variant of [`show_stats_all_history`]: serves every node-level
/// (or per-peer) series from the tick-published
/// [`StatsSnapshot`](super::snapshot::StatsSnapshot) rings, off the rx_loop.
/// Output is byte-identical to [`show_stats_all_history`]; the "peer not
/// tracked" error message uses [`snapshot_display_name`] (alias-or-hex).
pub(crate) fn show_stats_all_history_from_handle(
handle: &super::read_handle::ControlReadHandle,
params: Option<&Value>,
) -> super::protocol::Response {
use super::protocol::Response;
let params = params.cloned().unwrap_or_else(|| json!({}));
let window_str = params
.get("window")
.and_then(|v| v.as_str())
.unwrap_or("10m");
let window = match parse_duration(window_str) {
Ok(d) => d,
Err(e) => return Response::error(e),
};
let granularity_str = params
.get("granularity")
.and_then(|v| v.as_str())
.unwrap_or("1s");
let granularity = match Granularity::from_str(granularity_str) {
Ok(g) => g,
Err(e) => return Response::error(e),
};
let peer_npub = params.get("peer").and_then(|v| v.as_str());
let stats = handle.stats();
let hist = &stats.history;
let series: Vec<Value> = if let Some(npub) = peer_npub {
let addr = match parse_peer_npub(npub) {
Ok(a) => a,
Err(e) => return Response::error(e),
};
if !hist.has_peer(&addr) {
return Response::error(format!(
"peer not tracked in stats history: {}",
snapshot_display_name(&stats.peer_aliases, &addr)
));
}
ALL_PEER_METRICS
.iter()
.map(|m| {
let s = hist
.peer_query(&addr, *m, window, granularity)
.unwrap_or_else(|| crate::node::stats_history::Series {
metric: m.name(),
unit: m.unit(),
granularity_seconds: granularity.seconds(),
values: Vec::new(),
});
serde_json::to_value(&s).unwrap_or(Value::Null)
})
.collect()
} else {
ALL_METRICS
.iter()
.map(|m| {
let s = hist.query(*m, window, granularity);
serde_json::to_value(&s).unwrap_or(Value::Null)
})
.collect()
};
Response::ok(json!({
"granularity_seconds": granularity.seconds(),
"window_seconds": window.as_secs(),
"peer": peer_npub,
"series": series,
}))
}
/// `show_stats_peers` — Enumerate peers tracked in the stats history
/// with their lifecycle metadata. Used by operator tools to populate
/// peer selectors and to confirm a peer is in the retention window.
@@ -1125,7 +1337,25 @@ pub fn show_stats_history_all_peers(
/// [`crate::control::listening`] and [`crate::control::firewall_state`]
/// for the per-half implementations.
pub fn show_listening_sockets(node: &Node) -> Value {
let fips0 = crate::FipsAddress::from_node_addr(node.identity().node_addr()).to_ipv6();
render_listening_sockets(node.identity().node_addr())
}
/// Off-loop variant of [`show_listening_sockets`]: renders from the
/// [`ControlReadHandle`](super::read_handle::ControlReadHandle) in the control
/// task. Reads only the node identity (from `NodeContext`) plus host-OS facts
/// (`/proc` socket enumeration, nftables firewall classification) — no `Node`
/// state — so it never round-trips the rx_loop.
pub(crate) fn show_listening_sockets_from_handle(
handle: &super::read_handle::ControlReadHandle,
) -> Value {
render_listening_sockets(handle.context().identity.node_addr())
}
/// Shared renderer for the listening-sockets panel. Given the node's
/// `NodeAddr` it derives the fips0 address, enumerates listening sockets, and
/// classifies each against the shipped firewall baseline.
fn render_listening_sockets(node_addr: &NodeAddr) -> Value {
let fips0 = crate::FipsAddress::from_node_addr(node_addr).to_ipv6();
let sockets = super::listening::enumerate(fips0);
let classifier = super::firewall_state::FilterClassifier::query();
@@ -1152,6 +1382,26 @@ pub fn show_listening_sockets(node: &Node) -> Value {
})
}
/// `show_metrics` — Counter-family snapshot served off the rx_loop.
///
/// Renders every counter family in the [`MetricsRegistry`] as a flat JSON
/// object keyed by family name. Each family's value is its
/// `*StatsSnapshot` (a `u64`-per-counter struct). Counter-only by design:
/// gauges and histograms that need live `Node` state are out of scope and
/// stay on the rx_loop path. This is the Prometheus-exporter enabler — an
/// automated scraper reads this without ever touching the hot path.
pub(crate) fn show_metrics_from_handle(handle: &super::read_handle::ControlReadHandle) -> Value {
let m = handle.metrics();
json!({
"forwarding": m.forwarding.snapshot(),
"discovery": m.discovery.snapshot(),
"tree": m.tree.snapshot(),
"bloom": m.bloom.snapshot(),
"congestion": m.congestion.snapshot(),
"errors": m.errors.snapshot(),
})
}
/// Dispatch a command string to the appropriate query function.
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
match command {
@@ -1547,4 +1797,154 @@ mod tests {
);
}
}
// ---- off-loop (snapshot_dispatch) coverage ---------------------------
/// `show_metrics` is counter-only and served off the rx_loop. Raw
/// counter values are runtime-varying, so instead of a value-exact
/// golden fixture this pins the *shape*: every counter family appears
/// as a key, each maps to an object, and a representative counter key
/// is present in each family. This catches family renames / drops
/// without flaking on live values.
#[test]
fn show_metrics_shape_covers_all_families() {
let node = build_test_node();
let handle = node.control_read_handle();
let value = show_metrics_from_handle(&handle);
let obj = value.as_object().expect("show_metrics renders an object");
let expected_families = [
("forwarding", "received_packets"),
("discovery", "req_received"),
("tree", "accepted"),
("bloom", "accepted"),
("congestion", "ce_forwarded"),
("errors", "coords_required"),
];
assert_eq!(
obj.len(),
expected_families.len(),
"show_metrics has exactly {} counter families, got keys {:?}",
expected_families.len(),
obj.keys().collect::<Vec<_>>()
);
for (family, sample_key) in expected_families {
let fam = obj
.get(family)
.unwrap_or_else(|| panic!("missing counter family {family}"))
.as_object()
.unwrap_or_else(|| panic!("family {family} is not an object"));
assert!(
fam.contains_key(sample_key),
"family {family} missing expected counter {sample_key}"
);
// On a fresh node every counter is zero.
assert_eq!(
fam.get(sample_key).and_then(Value::as_u64),
Some(0),
"fresh-node counter {family}.{sample_key} should be 0"
);
}
}
/// The three R1 cutover queries are served off-loop via
/// `snapshot_dispatch`; everything else (state-bearing queries,
/// mutations) returns `None` and falls through to the rx_loop path.
#[test]
fn snapshot_dispatch_serves_only_cutover_queries() {
use super::super::protocol::Request;
use super::super::read_handle::snapshot_dispatch;
let node = build_test_node();
let handle = node.control_read_handle();
let req = |command: &str| Request {
command: command.to_string(),
params: None,
};
// Cut over to off-loop serving. The parameterized stats-series
// queries need their params to render; everything else is
// parameterless.
let req_params = |command: &str, params: Option<Value>| Request {
command: command.to_string(),
params,
};
let off_loop = [
("show_listening_sockets", None),
("show_stats_list", None),
("show_metrics", None),
("show_status", None),
(
"show_stats_history",
Some(json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" })),
),
(
"show_stats_all_history",
Some(json!({ "window": "10s", "granularity": "1s" })),
),
];
for (cmd, params) in off_loop {
let resp = snapshot_dispatch(&req_params(cmd, params), &handle)
.unwrap_or_else(|| panic!("{cmd} must be served off-loop"));
assert_eq!(resp.status, "ok", "{cmd} off-loop response not ok");
}
// Still on the rx_loop path: state-bearing queries that need live
// peer membership / npub, and all mutations.
for cmd in [
"show_peers",
"show_stats_peers",
"show_stats_history_all_peers",
"connect",
"disconnect",
] {
assert!(
snapshot_dispatch(&req(cmd), &handle).is_none(),
"{cmd} must fall through to the rx_loop path"
);
}
}
/// The tick-published `StatsSnapshot` reflects node state: after a
/// simulated `record_stats_history()` tick, the snapshot's counts and
/// scalar gauges match the node, and the off-loop `show_status` render
/// equals the on-loop `show_status` render byte-for-byte.
#[test]
fn stats_snapshot_reflects_state_after_tick() {
let mut node = build_test_node();
// Before any tick the seeded snapshot is empty.
let handle = node.control_read_handle();
assert!(
!handle.stats().history.has_data(),
"seed snapshot has no history before first tick"
);
// Advance one tick (the natural publisher site).
node.record_stats_history();
let handle = node.control_read_handle();
let snap = handle.stats();
assert!(
snap.history.has_data(),
"snapshot history reflects the tick"
);
// Scalar gauges / counts match the node's live accessors.
assert_eq!(snap.peer_count, node.peer_count());
assert_eq!(snap.session_count, node.session_count());
assert_eq!(snap.link_count, node.link_count());
assert_eq!(snap.transport_count, node.transport_count());
assert_eq!(snap.connection_count, node.connection_count());
assert_eq!(snap.estimated_mesh_size, node.estimated_mesh_size());
assert_eq!(snap.effective_ipv6_mtu, node.effective_ipv6_mtu());
// Off-loop render must equal the on-loop render byte-for-byte.
let on_loop = render(show_status(&node));
let off_loop = render(show_status_from_handle(&handle));
assert_eq!(
on_loop, off_loop,
"off-loop show_status must match on-loop output"
);
}
}
+127
View File
@@ -0,0 +1,127 @@
//! Read-only handle the control accept loop holds, so pure-snapshot `show_*`
//! queries can render off the rx_loop hot path instead of round-tripping the
//! mpsc → rx_loop oneshot.
//!
//! This is the stable seam of the control read-isolation milestone
//! (TASK-2026-0152, phase R0). The handle bundles the state that is already
//! independently shareable, and grows one `ArcSwap` snapshot cell per phase as
//! each subsystem's read state is published from its natural mutator:
//!
//! - `context` / `metrics` — already `Arc`-shared (refactor steps B/C).
//! - `stats` (R2) — `ArcSwap<StatsSnapshot>`: stats_history dual-ring + the
//! scalar gauges `show_status` needs, published from the tick.
//! - `routing` (R3) — `ArcSwap<RoutingSnapshot>`: tree / bloom / coord /
//! identity, published from their announce / discovery mutators.
//! - `entities` (R4) — `ArcSwap<EntitySnapshot>`: peers / sessions / links /
//! connections / transports, published per-entity with `Vec<Arc<Row>>`
//! structural sharing.
//!
//! Publisher placement follows the Q1 rules in
//! `design/fast-path-refactoring-r0-read-handle.md`: every snapshot is
//! published at its state's natural mutation site (on-change), never by the
//! contended rx_loop task it is meant to bypass.
//!
//! R0 ships only the type and the dispatch seam ([`snapshot_dispatch`]); no
//! query reads the handle yet. Cutover begins in R1.
use std::sync::Arc;
use arc_swap::ArcSwap;
use crate::node::context::NodeContext;
use crate::node::metrics::MetricsRegistry;
use super::protocol::{Request, Response};
use super::snapshot::StatsSnapshot;
/// Cloneable read-only view of node state for off-loop control serving.
///
/// All fields are `Arc` / `ArcSwap` handles, so cloning is cheap and a clone
/// can be held by every accepted control connection. Fields are consumed
/// starting R1 as `show_*` queries cut over to off-loop rendering; until then
/// they are wired but unread.
#[derive(Clone)]
pub(crate) struct ControlReadHandle {
/// Effectively-immutable node context (config, identity, limits).
context: Arc<NodeContext>,
/// Metrics registry (counters / gauges) for `show_stats_*`.
metrics: Arc<MetricsRegistry>,
/// stats_history dual-ring read copy + the scalar gauges/counts
/// `show_status` needs, published from the tick (R2, Q1-b).
stats: Arc<ArcSwap<StatsSnapshot>>,
}
impl ControlReadHandle {
/// Build the handle from the node's already-shared state. Called once at
/// control-socket spawn time; the result is cloned per connection. The
/// `stats` cell is the same `Arc` the tick publishes into, so every clone
/// observes fresh snapshots.
pub(crate) fn new(
context: Arc<NodeContext>,
metrics: Arc<MetricsRegistry>,
stats: Arc<ArcSwap<StatsSnapshot>>,
) -> Self {
Self {
context,
metrics,
stats,
}
}
/// Borrow the effectively-immutable node context.
pub(crate) fn context(&self) -> &NodeContext {
&self.context
}
/// Borrow the metrics registry.
pub(crate) fn metrics(&self) -> &MetricsRegistry {
&self.metrics
}
/// Load the latest published stats snapshot (the freshest available by
/// construction; no IO_TIMEOUT staleness gate, per Q1-e).
pub(crate) fn stats(&self) -> arc_swap::Guard<Arc<StatsSnapshot>> {
self.stats.load()
}
}
/// Attempt to serve a request entirely from the read handle, off the rx_loop.
///
/// Returns `Some(response)` when the command is a pure-snapshot query that has
/// been cut over to off-loop rendering, or `None` when it must take the
/// mpsc → rx_loop path (parameterized queries, mutations, and any query not
/// yet cut over).
///
/// Cutover queries (R1) read only `NodeContext` / `MetricsRegistry` (the state
/// the read handle already bundles) plus host-OS facts (`/proc`, nftables), so
/// they render entirely in the control task without touching `Node`.
pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -> Option<Response> {
use crate::control::queries;
match request.command.as_str() {
"show_listening_sockets" => Some(Response::ok(
queries::show_listening_sockets_from_handle(handle),
)),
"show_stats_list" => Some(Response::ok(queries::show_stats_list())),
"show_metrics" => Some(Response::ok(queries::show_metrics_from_handle(handle))),
// R2: served from the tick-published `StatsSnapshot` (rings + scalar
// gauges/counts). `show_status` and the two node-level/per-peer series
// queries carry enough data in the snapshot to render faithfully
// off-loop, including the parameterized series selectors (the snapshot
// holds the full rings, so any metric / window / granularity is
// satisfiable). `show_stats_peers` and `show_stats_history_all_peers`
// stay on the rx_loop path: they need live peer membership
// (`is_active`) and per-peer npub, which are Category-E state not yet
// in the snapshot.
"show_status" => Some(Response::ok(queries::show_status_from_handle(handle))),
"show_stats_history" => Some(queries::show_stats_history_from_handle(
handle,
request.params.as_ref(),
)),
"show_stats_all_history" => Some(queries::show_stats_all_history_from_handle(
handle,
request.params.as_ref(),
)),
_ => None,
}
}
+79
View File
@@ -0,0 +1,79 @@
//! Read-side state snapshots published from the node's natural mutators so
//! pure-snapshot `show_*` queries render off the rx_loop hot path.
//!
//! [`StatsSnapshot`] is the R2 reference implementation of the canonical
//! snapshot pattern (see `design/fast-path-refactoring-r0-read-handle.md`): a
//! read-only data bundle published via `ArcSwap` from the tick after
//! `StatsHistory::tick()`. It carries
//!
//! - the `stats_history` read-side rings (the "dual-ring": the live mutable
//! ring stays on the tick; this is the cloned read copy), and
//! - the cheap scalar gauges `show_status` needs (`estimated_mesh_size`,
//! node `state`, `tun_state`, `tun_name`, `effective_ipv6_mtu`, and the
//! peer / session / link / connection / transport counts), plus
//! `peer_aliases` (effectively immutable after construction).
//!
//! The snapshot holds *data*, not rendered `Response` envelopes (Q1-d):
//! rendering happens in the control task off the rx_loop. Staleness is bounded
//! by the tick interval and is never staler than the underlying data, which
//! also advances only on the tick (Q1-b).
use std::collections::HashMap;
use std::sync::Arc;
use crate::identity::NodeAddr;
use crate::node::NodeState;
use crate::node::stats_history::StatsHistory;
use crate::upper::tun::TunState;
/// Read-only snapshot of the stats-history rings plus the scalar gauges and
/// counts `show_status` reports. Published from the tick (Q1-b).
#[derive(Clone)]
pub(crate) struct StatsSnapshot {
/// Cloned read copy of the history rings (the dual-ring read side).
pub history: Arc<StatsHistory>,
/// Cached estimated mesh size, or `None` when no estimate is available.
pub estimated_mesh_size: Option<u64>,
/// Node operational state.
pub state: NodeState,
/// TUN device state.
pub tun_state: TunState,
/// TUN interface name, if active.
pub tun_name: Option<String>,
/// Effective IPv6 MTU over the mesh.
pub effective_ipv6_mtu: u16,
/// Number of pending connections (handshake in progress).
pub connection_count: usize,
/// Number of authenticated peers.
pub peer_count: usize,
/// Number of active links.
pub link_count: usize,
/// Number of active transports.
pub transport_count: usize,
/// Number of active sessions.
pub session_count: usize,
/// Configured peer aliases, keyed by `NodeAddr`. Effectively immutable
/// after construction; shared to avoid a per-tick map clone.
pub peer_aliases: Arc<HashMap<NodeAddr, String>>,
}
impl StatsSnapshot {
/// Build an empty snapshot for seeding the `ArcSwap` cell at construction,
/// before the first tick has published real state.
pub(crate) fn empty() -> Self {
Self {
history: Arc::new(StatsHistory::new()),
estimated_mesh_size: None,
state: NodeState::Created,
tun_state: TunState::Disabled,
tun_name: None,
effective_ipv6_mtu: 0,
connection_count: 0,
peer_count: 0,
link_count: 0,
transport_count: 0,
session_count: 0,
peer_aliases: Arc::new(HashMap::new()),
}
}
}