control: serve the full show_* read surface off the rx_loop via a read-snapshot plane

Complete the control-plane read-isolation work: every pure-read show_*
query now renders in the control accept task from published read
snapshots, so none round-trips the data-plane receive loop. Only the
mutating connect/disconnect commands still reach that loop.

Three subsystem snapshots are published via ArcSwap and served through the
read handle's snapshot_dispatch:

- A routing read view (spanning tree, bloom filters, coordinate cache,
  identity cache, and the discovery F-queue summary scalars), published
  from the tick, serving show_tree/show_bloom/show_cache/show_routing/
  show_identity_cache.
- A per-entity read view (peers, sessions, links, connections, transports,
  and the MMP link/session views) as Vec<Arc<Row>> tables reconciled
  against the prior snapshot so a republish reuses unchanged rows by
  pointer and re-allocates only changed or new rows, keeping the per-tick
  publish cost bounded as the peer/session count grows. Serves
  show_peers/show_sessions/show_links/show_connections/show_transports/
  show_mmp.
- The stats snapshot is extended with the peer-ACL status and a per-peer
  metadata map (is_active, npub, display name), resolved at publish time,
  serving show_acl and the two per-peer stats queries.

Display names and other cross-subsystem fields are resolved at publish
time; time-relative fields are derived at render time from captured
absolute timestamps, so rendered output is byte-identical to the prior
on-loop handlers, which are retained as the equality oracle.

With every read query served off-loop, the show_* branch is removed from
the rx_loop control handler and the now-dead on-loop dispatcher deleted.
The snapshot projections are forward-compatible with the later structural
extraction of the derived-state and session tables: they become thin
views over the extracted types without changing the read-handle interface.
This commit is contained in:
Johnathan Corgan
2026-06-10 17:45:49 +00:00
parent 063c3a194a
commit 81cd10d5db
5 changed files with 2543 additions and 99 deletions
+1242 -83
View File
File diff suppressed because it is too large Load Diff
+63 -5
View File
@@ -32,7 +32,7 @@ use crate::node::context::NodeContext;
use crate::node::metrics::MetricsRegistry;
use super::protocol::{Request, Response};
use super::snapshot::StatsSnapshot;
use super::snapshot::{EntitySnapshot, RoutingSnapshot, StatsSnapshot};
/// Cloneable read-only view of node state for off-loop control serving.
///
@@ -49,6 +49,13 @@ pub(crate) struct ControlReadHandle {
/// stats_history dual-ring read copy + the scalar gauges/counts
/// `show_status` needs, published from the tick (R2, Q1-b).
stats: Arc<ArcSwap<StatsSnapshot>>,
/// Category-D derived/routing/cache read view (tree / bloom / coord /
/// identity + F-queue scalars), published from the tick (R3).
routing: Arc<ArcSwap<RoutingSnapshot>>,
/// Category-E per-entity table read view (peers / sessions / links /
/// connections / transports + mmp), published from the tick with
/// `Vec<Arc<Row>>` structural sharing (R4).
entities: Arc<ArcSwap<EntitySnapshot>>,
}
impl ControlReadHandle {
@@ -60,11 +67,15 @@ impl ControlReadHandle {
context: Arc<NodeContext>,
metrics: Arc<MetricsRegistry>,
stats: Arc<ArcSwap<StatsSnapshot>>,
routing: Arc<ArcSwap<RoutingSnapshot>>,
entities: Arc<ArcSwap<EntitySnapshot>>,
) -> Self {
Self {
context,
metrics,
stats,
routing,
entities,
}
}
@@ -83,6 +94,18 @@ impl ControlReadHandle {
pub(crate) fn stats(&self) -> arc_swap::Guard<Arc<StatsSnapshot>> {
self.stats.load()
}
/// Load the latest published Category-D routing snapshot (freshest
/// available by construction; no staleness gate, per Q1-e).
pub(crate) fn routing(&self) -> arc_swap::Guard<Arc<RoutingSnapshot>> {
self.routing.load()
}
/// Load the latest published Category-E entity snapshot (freshest available
/// by construction; no staleness gate, per Q1-e).
pub(crate) fn entities(&self) -> arc_swap::Guard<Arc<EntitySnapshot>> {
self.entities.load()
}
}
/// Attempt to serve a request entirely from the read handle, off the rx_loop.
@@ -104,15 +127,22 @@ pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -
)),
"show_stats_list" => Some(Response::ok(queries::show_stats_list())),
"show_metrics" => Some(Response::ok(queries::show_metrics_from_handle(handle))),
// R5: peer-ACL status, served from the tick-published `StatsSnapshot`.
// The ACL is an `arc_swap::ArcSwap<PeerAcl>` reloaded only on the tick;
// its status projection is captured at the same tick.
"show_acl" => Some(Response::ok(queries::show_acl_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.
// satisfiable).
//
// R5 closes out the per-peer stats queries: `show_stats_peers` and
// `show_stats_history_all_peers` now read the snapshot's per-peer
// `peer_meta` (live `is_active`, resolved npub / display name, captured
// at publish time) joined against the `history` rings, so they no longer
// need live `&Node` and render off-loop too.
"show_status" => Some(Response::ok(queries::show_status_from_handle(handle))),
"show_stats_history" => Some(queries::show_stats_history_from_handle(
handle,
@@ -122,6 +152,34 @@ pub(crate) fn snapshot_dispatch(request: &Request, handle: &ControlReadHandle) -
handle,
request.params.as_ref(),
)),
"show_stats_peers" => Some(Response::ok(queries::show_stats_peers_from_handle(handle))),
"show_stats_history_all_peers" => Some(queries::show_stats_history_all_peers_from_handle(
handle,
request.params.as_ref(),
)),
// R3: served from the tick-published `RoutingSnapshot` (tree / bloom /
// coord cache / identity cache + F-queue scalars). Display names are
// resolved at publish time, so these render entirely off-loop. The
// counter-family `stats` blocks come from the `MetricsRegistry` (also
// in the handle). All five are parameterless.
"show_tree" => Some(Response::ok(queries::show_tree_from_handle(handle))),
"show_bloom" => Some(Response::ok(queries::show_bloom_from_handle(handle))),
"show_cache" => Some(Response::ok(queries::show_cache_from_handle(handle))),
"show_routing" => Some(Response::ok(queries::show_routing_from_handle(handle))),
"show_identity_cache" => Some(Response::ok(queries::show_identity_cache_from_handle(
handle,
))),
// R4: served from the tick-published `EntitySnapshot` (per-entity
// `Vec<Arc<Row>>` tables with structural sharing). Display names,
// tree-relationship flags, and Nostr-traversal state are resolved at
// publish time, so these render entirely off-loop. All six are
// parameterless.
"show_peers" => Some(Response::ok(queries::show_peers_from_handle(handle))),
"show_sessions" => Some(Response::ok(queries::show_sessions_from_handle(handle))),
"show_links" => Some(Response::ok(queries::show_links_from_handle(handle))),
"show_connections" => Some(Response::ok(queries::show_connections_from_handle(handle))),
"show_transports" => Some(Response::ok(queries::show_transports_from_handle(handle))),
"show_mmp" => Some(Response::ok(queries::show_mmp_from_handle(handle))),
_ => None,
}
}
+636
View File
@@ -23,6 +23,7 @@ use std::sync::Arc;
use crate::identity::NodeAddr;
use crate::node::NodeState;
use crate::node::acl::PeerAclStatus;
use crate::node::stats_history::StatsHistory;
use crate::upper::tun::TunState;
@@ -55,6 +56,32 @@ pub(crate) struct StatsSnapshot {
/// 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>>,
/// Loaded peer-ACL status (`show_acl`). The ACL itself is an
/// `arc_swap::ArcSwap<PeerAcl>` mutated only by the tick's `reload_peer_acl`;
/// the human-readable status is a cheap projection of it (R5).
pub acl_status: PeerAclStatus,
/// Per-stats-history-peer metadata resolved against the live peer/session
/// tables and host map at publish time (`show_stats_peers` /
/// `show_stats_history_all_peers`), keyed by `NodeAddr`. The lifecycle
/// timestamps and per-peer metric rings stay in `history`; this map carries
/// only the cross-subsystem fields a renderer can't derive from the rings
/// alone (`is_active`, resolved `npub`, resolved `display_name`) (R5).
pub peer_meta: Arc<HashMap<NodeAddr, StatsPeerMeta>>,
}
/// Cross-subsystem metadata for one peer tracked in the stats-history rings,
/// resolved at publish time. Joined against `StatsSnapshot::history`'s rings
/// (lifecycle timestamps + metric series) by the off-loop `show_stats_peers` /
/// `show_stats_history_all_peers` renderers.
#[derive(Clone)]
pub(crate) struct StatsPeerMeta {
/// Whether this peer is currently in the live authenticated-peer table.
pub is_active: bool,
/// Resolved npub (live peer npub, or `node_addr` hex when not a live peer),
/// matching the on-loop `show_stats_peers` fallback.
pub npub: String,
/// Display name resolved via `Node::peer_display_name` at publish time.
pub display_name: String,
}
impl StatsSnapshot {
@@ -74,6 +101,615 @@ impl StatsSnapshot {
transport_count: 0,
session_count: 0,
peer_aliases: Arc::new(HashMap::new()),
acl_status: empty_acl_status(),
peer_meta: Arc::new(HashMap::new()),
}
}
}
/// An empty/default [`PeerAclStatus`] for seeding the snapshot before the first
/// tick publishes the real ACL status. `PeerAclStatus` does not derive
/// `Default`, so this spells out the inert "no ACL loaded" shape.
fn empty_acl_status() -> PeerAclStatus {
PeerAclStatus {
allow_file: String::new(),
deny_file: String::new(),
enforcement_active: false,
effective_mode: String::new(),
default_decision: String::new(),
allow_all: false,
deny_all: false,
allow_file_entries: Vec::new(),
deny_file_entries: Vec::new(),
allow_entries: Vec::new(),
deny_entries: Vec::new(),
}
}
// =====================================================================
// RoutingSnapshot (R3 — Category-D derived/routing/cache read view)
// =====================================================================
/// Read-only snapshot of the Category-D derived/routing/cache subsystems that
/// the pure-snapshot `show_tree` / `show_bloom` / `show_cache` / `show_routing`
/// / `show_identity_cache` queries render. Published via `ArcSwap`.
///
/// The R0 stub (`design/fast-path-refactoring-r0-read-handle.md`) names a
/// single combined `ArcSwap<RoutingSnapshot>` for R3. This is that cell: one
/// cohesive routing view holding the four subsystems (tree / bloom / coord
/// cache / identity cache) plus the F-queue summary scalars.
///
/// **Publisher placement (Q1).** The four subsystems mutate at many scattered
/// handler sites (28 `coord_cache_mut` call sites, 16 `tree_state_mut`, ~32
/// identity-cache touches), and every projected row needs a *display name*
/// resolved against the live peer/session tables and host map — Category-E
/// state reachable only with `&Node`. Wiring an on-change `publish_*` at each
/// mutation site would be large, error-prone surgery, and each call would still
/// need `&Node` to resolve names across subsystem boundaries. So this snapshot
/// is published from the **tick** (Q1-b acceptable-at-mutator / the documented
/// interim the spec permits, mirroring R2's stats publish): the tick is the one
/// site with coherent `&Node` access to resolve all display names together. A
/// single combined cell is the natural shape because there is exactly one
/// publisher — the multi-mutator "rebuild the whole snapshot N times" hazard
/// that Q1-c warns against does not arise.
///
/// The snapshot holds *data* (typed rows + scalars), not rendered `Response`
/// envelopes (Q1-d); rendering happens off the rx_loop in the control task. The
/// counter-family `stats` blocks the queries also emit come from the
/// `MetricsRegistry` (already `Arc`-shared in the handle) at render time, not
/// from this snapshot.
///
/// Time-relative fields (`age_ms`, `idle_ms`) are derived at render time from
/// the captured absolute timestamps, so the rendered age stays fresh relative
/// to the read, exactly as the on-loop queries computed it.
///
/// Forward-compat: when step 5 structurally extracts the Category-D subsystems
/// into typed types, these projections become thin views over them without
/// changing the read-handle interface or this publisher placement.
#[derive(Clone)]
pub(crate) struct RoutingSnapshot {
/// Spanning-tree read view (`show_tree`).
pub tree: TreeView,
/// Bloom-filter read view (`show_bloom`).
pub bloom: BloomView,
/// Coordinate-cache read view (`show_cache`, `show_routing`).
pub cache: CacheView,
/// F-queue / discovery routing scalars + rows (`show_routing`).
pub routing: RoutingView,
/// Identity-cache read view (`show_identity_cache`, `show_routing`).
pub identity: IdentityView,
}
impl RoutingSnapshot {
/// 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 {
tree: TreeView::default(),
bloom: BloomView::default(),
cache: CacheView::default(),
routing: RoutingView::default(),
identity: IdentityView::default(),
}
}
}
/// Zero `NodeAddr` for empty/seed views (all-zero 16 bytes).
fn zero_addr() -> NodeAddr {
NodeAddr::from_bytes([0u8; 16])
}
/// Spanning-tree read view for `show_tree`.
#[derive(Clone)]
pub(crate) struct TreeView {
pub my_node_addr: NodeAddr,
pub root: NodeAddr,
pub is_root: bool,
pub depth: usize,
/// `my_coords` entries as `NodeAddr`s (rendered as hex).
pub my_coords: Vec<NodeAddr>,
pub parent: NodeAddr,
pub parent_display_name: String,
pub declaration_sequence: u64,
pub declaration_signed: bool,
pub peer_tree_count: usize,
pub peers: Vec<TreePeerRow>,
}
impl Default for TreeView {
fn default() -> Self {
Self {
my_node_addr: zero_addr(),
root: zero_addr(),
is_root: false,
depth: 0,
my_coords: Vec::new(),
parent: zero_addr(),
parent_display_name: String::new(),
declaration_sequence: 0,
declaration_signed: false,
peer_tree_count: 0,
peers: Vec::new(),
}
}
}
/// One peer's tree position in `show_tree`.
#[derive(Clone)]
pub(crate) struct TreePeerRow {
pub node_addr: NodeAddr,
pub display_name: String,
/// Present only when the peer's coordinates are known.
pub coords: Option<TreePeerCoords>,
}
/// Coordinate detail for a tree peer (present only when known).
#[derive(Clone)]
pub(crate) struct TreePeerCoords {
pub depth: usize,
pub root: NodeAddr,
pub coord_path: Vec<NodeAddr>,
pub distance_to_us: usize,
}
/// Bloom-filter read view for `show_bloom`.
#[derive(Clone)]
pub(crate) struct BloomView {
pub own_node_addr: NodeAddr,
pub is_leaf_only: bool,
pub sequence: u64,
pub leaf_dependents: Vec<NodeAddr>,
pub peer_filters: Vec<BloomPeerRow>,
}
impl Default for BloomView {
fn default() -> Self {
Self {
own_node_addr: zero_addr(),
is_leaf_only: false,
sequence: 0,
leaf_dependents: Vec::new(),
peer_filters: Vec::new(),
}
}
}
/// One peer's bloom-filter state in `show_bloom`.
#[derive(Clone)]
pub(crate) struct BloomPeerRow {
pub peer: NodeAddr,
pub display_name: String,
pub has_filter: bool,
pub filter_sequence: u64,
/// Present only when the peer has supplied an inbound filter.
pub filter: Option<BloomPeerFilter>,
}
/// Inbound-filter statistics for a bloom peer (present only when known).
#[derive(Clone)]
pub(crate) struct BloomPeerFilter {
/// Estimated cardinality (`None` when undefined for the saturation),
/// matching `BloomFilter::estimated_count`'s `Option<f64>`.
pub estimated_count: Option<f64>,
pub set_bits: usize,
pub fill_ratio: f64,
}
/// Coordinate-cache read view for `show_cache` (and the cache scalars in
/// `show_routing`).
#[derive(Clone, Default)]
pub(crate) struct CacheView {
pub count: usize,
pub max_entries: usize,
pub fill_ratio: f64,
pub default_ttl_ms: u64,
pub expired: usize,
pub avg_age_ms: u64,
pub entries: Vec<CacheEntryRow>,
}
/// One coordinate-cache entry in `show_cache`.
#[derive(Clone)]
pub(crate) struct CacheEntryRow {
pub node_addr: NodeAddr,
pub display_name: String,
pub depth: usize,
pub coord_path: Vec<NodeAddr>,
/// Absolute creation time (Unix ms); `age_ms` derived at render time.
pub created_at: u64,
pub last_used_ms: u64,
pub path_mtu: Option<u16>,
}
/// F-queue / discovery routing read view for `show_routing`.
#[derive(Clone, Default)]
pub(crate) struct RoutingView {
pub pending_lookups: Vec<PendingLookupRow>,
pub pending_tun_destinations: usize,
pub pending_tun_packets: usize,
pub recent_requests: usize,
pub retries: Vec<RetryRow>,
}
/// One in-flight discovery lookup in `show_routing`.
#[derive(Clone)]
pub(crate) struct PendingLookupRow {
pub target: NodeAddr,
pub display_name: String,
/// Absolute initiation time (Unix ms); `age_ms` derived at render time.
pub initiated_ms: u64,
pub last_sent_ms: u64,
pub attempt: u8,
}
/// One connection-retry entry in `show_routing`.
#[derive(Clone)]
pub(crate) struct RetryRow {
pub node_addr: NodeAddr,
pub display_name: String,
pub retry_count: u32,
pub retry_after_ms: u64,
pub auto_reconnect: bool,
}
/// Identity-cache read view for `show_identity_cache` (and the
/// `identity_cache_entries` scalar in `show_routing`).
#[derive(Clone, Default)]
pub(crate) struct IdentityView {
pub entries: Vec<IdentityRow>,
pub max_entries: usize,
}
/// One identity-cache entry in `show_identity_cache`.
#[derive(Clone)]
pub(crate) struct IdentityRow {
pub node_addr: NodeAddr,
pub npub: String,
pub display_name: String,
pub ipv6_addr: String,
pub last_seen_ms: u64,
}
// =====================================================================
// EntitySnapshot (R4 — Category-E per-entity table read views)
// =====================================================================
/// Read-only snapshot of the Category-E per-entity tables that the
/// pure-snapshot `show_peers` / `show_sessions` / `show_links` /
/// `show_connections` / `show_transports` / `show_mmp` queries render.
/// Published via `ArcSwap`.
///
/// The R0 stub (`design/fast-path-refactoring-r0-read-handle.md`) pre-scopes
/// R4 as `entities — ArcSwap<EntitySnapshot>: peers / sessions / links /
/// connections / transports, published per-entity with `Vec<Arc<Row>>`
/// structural sharing`. This is that cell.
///
/// **Structural sharing (the umbrella mandate).** Every entity table is a
/// `Vec<Arc<Row>>`, so a republish in which only one row changed re-allocates
/// only that one `Arc<Row>` — the unchanged rows are reused by pointer from the
/// previous snapshot (`Arc::ptr_eq`-stable). The publisher diffs each freshly
/// projected row against the prior published row by value (`PartialEq`) and
/// keeps the old `Arc` when they are equal. A clone of the snapshot for each
/// accepted control connection is then a vector of cheap pointer clones, not a
/// deep table copy. This is what keeps the per-tick publish cost off the hot
/// path at scale, as the umbrella requires for R4.
///
/// **Publisher placement (Q1).** Like R3, this is published from the **tick**,
/// not per-mutator. Two reasons, both stronger than for R3:
///
/// 1. Every projected row needs a *display name* resolved against the live
/// peer/session tables and host map (`&Node`), and `show_peers` additionally
/// needs the live tree state to derive `is_parent` / `is_child` and the
/// Nostr-discovery failure-state map — cross-subsystem reads available only
/// with `&Node`.
/// 2. Most of the projected fields (link/session traffic counters, MMP
/// metrics, `last_seen`, noise counters, replay/decrypt counters) are
/// mutated continuously on the **data plane / rx_loop**, not at the discrete
/// peer/session/link lifecycle mutators. Per-lifecycle-mutator publication
/// (Q1-a) would therefore not even capture freshness for those fields; the
/// tick is the natural cadence at which this read view advances.
///
/// The diff-and-reuse therefore satisfies the structural-sharing goal the
/// umbrella mandates (only changed rows re-allocate) while keeping a single
/// coherent `&Node` publisher — the "no monolithic per-tick *re-allocation* of
/// every row" warning is honored because unchanged rows are reused, not rebuilt.
/// This is the documented acceptable interim (the spec's tick-publish-with-
/// Arc-reuse fallback), consistent with R3.
///
/// The snapshot holds typed rows (Q1-d data, not rendered `Response`
/// envelopes). Time-relative fields (`idle_ms`) are derived at render time from
/// captured absolute timestamps, so the rendered age stays fresh relative to
/// the read, exactly as the on-loop queries computed it.
///
/// Forward-compat: step 10 later extracts the session table into a typed
/// `(transport_id, our_index)`-indexed type; these projections then become thin
/// views over it without changing the read-handle interface or this publisher
/// placement.
#[derive(Clone)]
pub(crate) struct EntitySnapshot {
/// `show_peers` rows.
pub peers: Vec<Arc<PeerRow>>,
/// `show_sessions` rows.
pub sessions: Vec<Arc<SessionRow>>,
/// `show_links` rows.
pub links: Vec<Arc<LinkRow>>,
/// `show_connections` rows.
pub connections: Vec<Arc<ConnectionRow>>,
/// `show_transports` rows.
pub transports: Vec<Arc<TransportRow>>,
/// `show_mmp` link-layer rows (peers with an MMP instance).
pub mmp_peers: Vec<Arc<MmpPeerRow>>,
/// `show_mmp` session-layer rows (sessions with an MMP instance).
pub mmp_sessions: Vec<Arc<MmpSessionRow>>,
}
impl EntitySnapshot {
/// 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 {
peers: Vec::new(),
sessions: Vec::new(),
links: Vec::new(),
connections: Vec::new(),
transports: Vec::new(),
mmp_peers: Vec::new(),
mmp_sessions: Vec::new(),
}
}
}
/// Per-peer link/transport/connectivity fields for `show_peers` derived from a
/// peer's resolved link (present only when the link is found).
#[derive(Clone, PartialEq)]
pub(crate) struct PeerLinkInfo {
pub direction: String,
/// Transport type name, present only when the transport handle is found.
pub transport_type: Option<String>,
}
/// Nostr-traversal failure-state for a peer's npub in `show_peers`. Always
/// emitted (the on-loop query emits a default object even when absent); the
/// `present` flag distinguishes "seen by Nostr discovery" from the default.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerNostrState {
pub consecutive_failures: u32,
pub cooldown_until_ms: Option<u64>,
pub last_observed_skew_ms: Option<i64>,
}
/// Noise session counters surfaced in `show_peers` (present when the peer has a
/// Noise session).
#[derive(Clone, PartialEq)]
pub(crate) struct PeerNoiseCounters {
pub send_counter: u64,
pub highest_recv_counter: u64,
}
/// Link/session MMP metrics surfaced inline in `show_peers` (and the
/// per-session block in `show_sessions`). Fields mirror the on-loop projection;
/// `Option` fields are emitted only when present.
#[derive(Clone, PartialEq)]
pub(crate) struct EntityMmp {
pub mode: String,
pub srtt_ms: Option<f64>,
pub loss_rate: f64,
pub etx: f64,
pub goodput_bps: f64,
pub delivery_ratio_forward: f64,
pub delivery_ratio_reverse: f64,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
/// `lqi` (peers) / `sqi` (sessions): present only when both `srtt_ms` and
/// `smoothed_etx` are present. Precomputed so the render is a plain emit.
pub quality_index: Option<f64>,
/// Session-only: path MTU (`show_sessions`). `None` for peer rows.
pub path_mtu: Option<u16>,
}
/// Link-layer stat counters for a peer in `show_peers`.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerLinkStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
}
/// One authenticated peer in `show_peers`. Holds every field the on-loop
/// `show_peers` emits; `Option` fields gate the conditionally-emitted keys.
#[derive(Clone, PartialEq)]
pub(crate) struct PeerRow {
pub node_addr: NodeAddr,
pub npub: String,
pub display_name: String,
pub ipv6_addr: String,
pub connectivity: String,
pub link_id: u64,
pub authenticated_at_ms: u64,
pub last_seen_ms: u64,
pub has_tree_position: bool,
pub has_bloom_filter: bool,
pub filter_sequence: u64,
pub is_parent: bool,
pub is_child: bool,
pub transport_addr: Option<String>,
pub link_info: Option<PeerLinkInfo>,
pub tree_depth: Option<usize>,
pub stats: PeerLinkStats,
pub replay_suppressed: u32,
pub consecutive_decrypt_failures: u32,
pub nostr_traversal: PeerNostrState,
pub noise: Option<PeerNoiseCounters>,
pub our_session_index: Option<u32>,
pub rekey_in_progress: bool,
pub rekey_draining: bool,
pub current_k_bit: bool,
pub mmp: Option<EntityMmp>,
}
/// Traffic counters for a session in `show_sessions`.
#[derive(Clone, PartialEq)]
pub(crate) struct SessionStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
}
/// One end-to-end session in `show_sessions`.
#[derive(Clone, PartialEq)]
pub(crate) struct SessionRow {
pub remote_addr: NodeAddr,
pub display_name: String,
pub state: &'static str,
pub is_initiator: bool,
pub last_activity_ms: u64,
pub npub: String,
pub stats: SessionStats,
/// Handshake resend count, emitted only while not established.
pub resend_count: Option<u32>,
/// Established-only health block (session_start_ms, current_k_bit,
/// coords_warmup_remaining, is_draining). `None` while handshaking.
pub established: Option<SessionEstablished>,
pub mmp: Option<EntityMmp>,
}
/// Established-session health fields in `show_sessions` (emitted only when the
/// session is established).
#[derive(Clone, PartialEq)]
pub(crate) struct SessionEstablished {
pub session_start_ms: u64,
pub current_k_bit: bool,
pub coords_warmup_remaining: u8,
pub is_draining: bool,
}
/// Stat counters for a link in `show_links`.
#[derive(Clone, PartialEq)]
pub(crate) struct LinkStats {
pub packets_sent: u64,
pub packets_recv: u64,
pub bytes_sent: u64,
pub bytes_recv: u64,
pub last_recv_ms: u64,
}
/// One active link in `show_links`.
#[derive(Clone, PartialEq)]
pub(crate) struct LinkRow {
pub link_id: u64,
pub transport_id: u32,
pub remote_addr: String,
pub direction: String,
pub state: String,
pub created_at_ms: u64,
pub stats: LinkStats,
}
/// One pending handshake in `show_connections`. `idle_ms` is derived at render
/// time from the captured `last_activity_ms`.
#[derive(Clone, PartialEq)]
pub(crate) struct ConnectionRow {
pub link_id: u64,
pub direction: String,
pub handshake_state: String,
pub started_at_ms: u64,
/// Absolute last-activity time (Unix ms); `idle_ms` derived at render time.
pub last_activity_ms: u64,
pub resend_count: u32,
/// Expected peer npub, emitted only when the connection has an expected
/// identity.
pub expected_peer: Option<String>,
}
/// One transport instance in `show_transports`. The `stats` and
/// `tor_monitoring` fields are stored as already-projected `serde_json::Value`
/// (data, produced by the transport handle), not as rendered `Response`
/// envelopes.
#[derive(Clone, PartialEq)]
pub(crate) struct TransportRow {
pub transport_id: u32,
pub transport_type: String,
pub state: String,
pub mtu: u16,
pub name: Option<String>,
pub local_addr: Option<String>,
pub tor_mode: Option<String>,
pub onion_address: Option<String>,
pub tor_monitoring: Option<serde_json::Value>,
pub stats: serde_json::Value,
}
/// MMP trend labels for a peer's link-layer block in `show_mmp` (each present
/// only when the corresponding trend is initialized).
#[derive(Clone, PartialEq)]
pub(crate) struct MmpTrends {
pub rtt_trend: Option<&'static str>,
pub loss_trend: Option<&'static str>,
pub goodput_trend: Option<&'static str>,
pub jitter_trend: Option<&'static str>,
}
/// One peer's link-layer MMP block in `show_mmp`.
#[derive(Clone, PartialEq)]
pub(crate) struct MmpPeerRow {
pub peer: NodeAddr,
pub display_name: String,
pub mode: String,
pub loss_rate: f64,
pub etx: f64,
pub goodput_bps: f64,
pub spin_bit_initiator: bool,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
pub srtt_ms: Option<f64>,
/// `lqi`: present only when both `srtt_ms` and `smoothed_etx` are present.
pub lqi: Option<f64>,
pub trends: MmpTrends,
pub delivery_ratio_forward: f64,
pub delivery_ratio_reverse: f64,
pub ecn_ce_count: u32,
}
/// One session's session-layer MMP block in `show_mmp`.
#[derive(Clone, PartialEq)]
pub(crate) struct MmpSessionRow {
pub remote: NodeAddr,
pub display_name: String,
pub mode: String,
pub loss_rate: f64,
pub etx: f64,
pub path_mtu: u16,
pub smoothed_loss: Option<f64>,
pub smoothed_etx: Option<f64>,
pub srtt_ms: Option<f64>,
/// `sqi`: present only when both `srtt_ms` and `smoothed_etx` are present.
pub sqi: Option<f64>,
}
/// Reconcile a freshly-projected entity table against the previously published
/// one, preserving structural sharing: an `Arc<Row>` from `prev` is reused
/// (kept by pointer) whenever a new row matches an old row by identity `key`
/// **and** compares equal by value, so only changed/new rows allocate a fresh
/// `Arc`. This is the `Vec<Arc<Row>>` discipline the R4 umbrella mandates — a
/// single-row change re-allocates one row, not the whole table, keeping the
/// per-tick publish cost off the hot path at scale.
///
/// `key` extracts a stable, hashable identity (e.g. `node_addr`, `link_id`) so
/// matching is order-independent across the source table's iteration order.
pub(crate) fn reconcile_rows<R, K, F>(prev: &[Arc<R>], new_rows: Vec<R>, key: F) -> Vec<Arc<R>>
where
R: PartialEq,
K: std::hash::Hash + Eq,
F: Fn(&R) -> K,
{
let index: HashMap<K, &Arc<R>> = prev.iter().map(|arc| (key(arc), arc)).collect();
new_rows
.into_iter()
.map(|row| match index.get(&key(&row)) {
Some(old) if ***old == row => Arc::clone(old),
_ => Arc::new(row),
})
.collect()
}
+12 -10
View File
@@ -1,6 +1,5 @@
//! RX event loop and packet dispatch.
use crate::control::queries;
use crate::control::{ControlSocket, commands};
use crate::node::wire::{
COMMON_PREFIX_SIZE, CommonPrefix, FMP_VERSION, PHASE_ESTABLISHED, PHASE_MSG1, PHASE_MSG2,
@@ -236,15 +235,18 @@ impl Node {
self.register_identity(identity.node_addr, identity.pubkey);
}
Some((request, response_tx)) = control_rx.recv() => {
let response = if request.command.starts_with("show_") {
queries::dispatch(self, &request.command, request.params.as_ref())
} else {
commands::dispatch(
self,
&request.command,
request.params.as_ref(),
).await
};
// Only mutating COMMAND requests (`connect` / `disconnect`)
// reach the rx_loop now. Every pure-read `show_*` query is
// served off-loop from the read handle in the control accept
// task (`snapshot_dispatch`), so it never round-trips here —
// the data-plane dispatch path carries no `show_*` arm. A
// `show_*` that somehow arrives (none does) falls through to
// `commands::dispatch`, which returns "unknown command".
let response = commands::dispatch(
self,
&request.command,
request.params.as_ref(),
).await;
let _ = response_tx.send(response);
}
_ = tick.tick() => {
+590 -1
View File
@@ -4,7 +4,7 @@
//! holds all state required for mesh routing: identity, tree state,
//! Bloom filters, coordinate caches, transports, links, and peers.
mod acl;
pub(crate) mod acl;
mod bloom;
pub(crate) mod context;
#[cfg(unix)]
@@ -396,6 +396,21 @@ pub struct Node {
/// live mutable `stats_history` above stays on the tick.
stats_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::StatsSnapshot>>,
/// Read-side snapshot of the Category-D derived/routing/cache subsystems
/// (tree / bloom / coord cache / identity cache + F-queue scalars) that the
/// `show_tree` / `show_bloom` / `show_cache` / `show_routing` /
/// `show_identity_cache` queries render off the rx_loop. Published from the
/// tick (see [`Self::publish_routing_snapshot`] for the Q1 rationale).
routing_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::RoutingSnapshot>>,
/// Read-side snapshot of the Category-E per-entity tables (peers / sessions
/// / links / connections / transports + mmp) that the `show_peers` /
/// `show_sessions` / `show_links` / `show_connections` / `show_transports`
/// / `show_mmp` queries render off the rx_loop. Published from the tick with
/// `Vec<Arc<Row>>` structural sharing (unchanged rows reused by pointer);
/// see [`Self::publish_entities_snapshot`] for the Q1 rationale.
entities_snapshot: std::sync::Arc<arc_swap::ArcSwap<crate::control::snapshot::EntitySnapshot>>,
// === TUN Interface ===
/// TUN device state.
tun_state: TunState,
@@ -663,6 +678,12 @@ impl Node {
stats_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::StatsSnapshot::empty(),
)),
routing_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::RoutingSnapshot::empty(),
)),
entities_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::EntitySnapshot::empty(),
)),
tun_state,
tun_name: None,
tun_tx: None,
@@ -818,6 +839,12 @@ impl Node {
stats_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::StatsSnapshot::empty(),
)),
routing_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::RoutingSnapshot::empty(),
)),
entities_snapshot: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
crate::control::snapshot::EntitySnapshot::empty(),
)),
tun_state,
tun_name: None,
tun_tx: None,
@@ -1395,6 +1422,8 @@ impl Node {
self.context.clone(),
self.metrics.clone(),
self.stats_snapshot.clone(),
self.routing_snapshot.clone(),
self.entities_snapshot.clone(),
)
}
@@ -1468,6 +1497,30 @@ impl Node {
// it is published only here, not in a monolithic per-tick rebuild of
// every query (Q1-c). It also is not gated behind any slow I/O on the
// tick the way the abandoned 2edc8a1 republish was.
// Per-stats-history-peer metadata (R5). `show_stats_peers` /
// `show_stats_history_all_peers` need each tracked peer's live
// membership (`is_active`), resolved npub, and display name — all
// cross-subsystem reads against the live peer table and host map,
// available only here with `&self`. The lifecycle timestamps and
// metric rings the renderers also read live in `history` (the dual-ring
// read copy above), so this map carries only the resolved fields.
let peer_meta: HashMap<NodeAddr, crate::control::snapshot::StatsPeerMeta> = self
.stats_history
.peer_addrs()
.copied()
.map(|addr| {
let live = self.peers.get(&addr);
let meta = crate::control::snapshot::StatsPeerMeta {
is_active: live.is_some(),
npub: live
.map(|p| p.npub())
.unwrap_or_else(|| hex::encode(addr.as_bytes())),
display_name: self.peer_display_name(&addr),
};
(addr, meta)
})
.collect();
let snapshot = crate::control::snapshot::StatsSnapshot {
history: std::sync::Arc::new(self.stats_history.clone()),
estimated_mesh_size: self.estimated_mesh_size,
@@ -1481,8 +1534,512 @@ impl Node {
transport_count: self.transports.len(),
session_count: self.sessions.len(),
peer_aliases: std::sync::Arc::new(self.peer_aliases.clone()),
acl_status: self.peer_acl_status(),
peer_meta: std::sync::Arc::new(peer_meta),
};
self.stats_snapshot.store(std::sync::Arc::new(snapshot));
// Publish the Category-D routing read view alongside the stats
// snapshot, from the same tick.
self.publish_routing_snapshot();
// Publish the Category-E per-entity read view from the same tick, with
// `Vec<Arc<Row>>` structural sharing against the previous snapshot.
self.publish_entities_snapshot();
}
/// Project the Category-D derived/routing/cache state into a
/// [`RoutingSnapshot`](crate::control::snapshot::RoutingSnapshot) and
/// publish it via `ArcSwap`, so `show_tree` / `show_bloom` / `show_cache`
/// / `show_routing` / `show_identity_cache` render off the rx_loop.
///
/// **Q1 publisher placement.** The four projected subsystems (tree / bloom
/// / coord cache / identity cache) mutate at dozens of scattered handler
/// sites, and every projected row carries a *display name* resolved against
/// the live peer/session tables and host map — state reachable only with
/// `&Node`. Per-mutator on-change publication (Q1-a) would therefore be
/// large, error-prone surgery, and each call would still need `&Node` to
/// resolve names across subsystem boundaries. So this projection is
/// published from the tick — the documented acceptable interim (the spec's
/// "publish from the tick" allowance, mirroring R2's stats publish). The
/// tick is the one site with coherent `&Node` access to resolve every
/// display name together. A single combined cell is the natural shape
/// because there is exactly one publisher, so the multi-mutator
/// whole-snapshot-rebuild hazard Q1-c warns against does not arise.
///
/// The snapshot holds typed rows + scalars (Q1-d data, not rendered
/// responses); the counter-family `stats` blocks the queries also emit are
/// served from the `MetricsRegistry` (already `Arc`-shared) at render time.
fn publish_routing_snapshot(&self) {
use crate::control::snapshot as snap;
let now = Self::now_ms();
// --- tree (show_tree) ---
let tree = self.tree_state();
let my_coords = tree.my_coords();
let tree_peers: Vec<snap::TreePeerRow> = tree
.peer_ids()
.map(|peer_id| {
let coords = tree
.peer_coords(peer_id)
.map(|coords| snap::TreePeerCoords {
depth: coords.depth(),
root: *coords.root_id(),
coord_path: coords.entries().iter().map(|e| e.node_addr).collect(),
distance_to_us: my_coords.distance_to(coords),
});
snap::TreePeerRow {
node_addr: *peer_id,
display_name: self.peer_display_name(peer_id),
coords,
}
})
.collect();
let parent_addr = my_coords.parent_id();
let tree_view = snap::TreeView {
my_node_addr: *tree.my_node_addr(),
root: *tree.root(),
is_root: tree.is_root(),
depth: my_coords.depth(),
my_coords: my_coords.entries().iter().map(|e| e.node_addr).collect(),
parent: *parent_addr,
parent_display_name: self.peer_display_name(parent_addr),
declaration_sequence: tree.my_declaration().sequence(),
declaration_signed: tree.my_declaration().is_signed(),
peer_tree_count: tree.peer_count(),
peers: tree_peers,
};
// --- bloom (show_bloom) ---
let bloom = self.bloom_state();
let max_inbound_fpr = self.config().node.bloom.max_inbound_fpr;
let bloom_peers: Vec<snap::BloomPeerRow> = self
.peers()
.map(|peer| {
let addr = *peer.node_addr();
let filter = peer.inbound_filter().map(|f| snap::BloomPeerFilter {
estimated_count: f.estimated_count(max_inbound_fpr),
set_bits: f.count_ones(),
fill_ratio: f.fill_ratio(),
});
snap::BloomPeerRow {
peer: addr,
display_name: self.peer_display_name(&addr),
has_filter: peer.filter_sequence() > 0,
filter_sequence: peer.filter_sequence(),
filter,
}
})
.collect();
let bloom_view = snap::BloomView {
own_node_addr: *self.node_addr(),
is_leaf_only: self.is_leaf_only(),
sequence: bloom.sequence(),
leaf_dependents: bloom.leaf_dependents().iter().copied().collect(),
peer_filters: bloom_peers,
};
// --- coord cache (show_cache, show_routing) ---
let cache = self.coord_cache();
let cache_stats = cache.stats(now);
let cache_entries: Vec<snap::CacheEntryRow> = cache
.iter(now)
.map(|(addr, entry)| snap::CacheEntryRow {
node_addr: *addr,
display_name: self.peer_display_name(addr),
depth: entry.coords().depth(),
coord_path: entry
.coords()
.entries()
.iter()
.map(|e| e.node_addr)
.collect(),
created_at: entry.created_at(),
last_used_ms: entry.last_used(),
path_mtu: entry.path_mtu(),
})
.collect();
let cache_view = snap::CacheView {
count: cache_stats.entries,
max_entries: cache_stats.max_entries,
fill_ratio: cache_stats.fill_ratio(),
default_ttl_ms: cache.default_ttl_ms(),
expired: cache_stats.expired,
avg_age_ms: cache_stats.avg_age_ms,
entries: cache_entries,
};
// --- F-queue / discovery routing scalars (show_routing) ---
let pending_lookups: Vec<snap::PendingLookupRow> = self
.pending_lookups_iter()
.map(|(addr, lookup)| snap::PendingLookupRow {
target: *addr,
display_name: self.peer_display_name(addr),
initiated_ms: lookup.initiated_ms,
last_sent_ms: lookup.last_sent_ms,
attempt: lookup.attempt,
})
.collect();
let retries: Vec<snap::RetryRow> = self
.retry_state_iter()
.map(|(addr, state)| snap::RetryRow {
node_addr: *addr,
display_name: self.peer_display_name(addr),
retry_count: state.retry_count,
retry_after_ms: state.retry_after_ms,
auto_reconnect: state.reconnect,
})
.collect();
let routing_view = snap::RoutingView {
pending_lookups,
pending_tun_destinations: self.pending_tun_destinations(),
pending_tun_packets: self.pending_tun_total_packets(),
recent_requests: self.recent_request_count(),
retries,
};
// --- identity cache (show_identity_cache, show_routing) ---
let identity_entries: Vec<snap::IdentityRow> = self
.identity_cache_iter()
.map(|(node_addr, pubkey, last_seen_ms)| {
let (xonly, _parity) = pubkey.x_only_public_key();
let fips_addr = crate::identity::FipsAddress::from_node_addr(node_addr);
snap::IdentityRow {
node_addr: *node_addr,
npub: crate::identity::encode_npub(&xonly),
display_name: self.peer_display_name(node_addr),
ipv6_addr: format!("{}", fips_addr),
last_seen_ms,
}
})
.collect();
let identity_view = snap::IdentityView {
entries: identity_entries,
max_entries: self.identity_cache_max(),
};
let snapshot = snap::RoutingSnapshot {
tree: tree_view,
bloom: bloom_view,
cache: cache_view,
routing: routing_view,
identity: identity_view,
};
self.routing_snapshot.store(std::sync::Arc::new(snapshot));
}
/// Project the Category-E per-entity tables (peers / sessions / links /
/// connections / transports + mmp) into an
/// [`EntitySnapshot`](crate::control::snapshot::EntitySnapshot) and publish
/// it via `ArcSwap`, so `show_peers` / `show_sessions` / `show_links` /
/// `show_connections` / `show_transports` / `show_mmp` render off the
/// rx_loop.
///
/// **Q1 publisher placement (tick, like R3).** Every projected row needs a
/// display name resolved against the live peer/session tables and host map
/// (`&Node`); `show_peers` additionally needs the live tree state to derive
/// `is_parent` / `is_child` plus the Nostr-discovery failure-state map —
/// cross-subsystem reads available only with `&Node`. And most fields
/// (link/session traffic counters, MMP metrics, `last_seen`, noise counters)
/// mutate continuously on the data plane, not at the discrete entity
/// lifecycle mutators, so per-lifecycle-mutator publication (Q1-a) would not
/// capture their freshness anyway. The tick is the natural cadence with
/// coherent `&Node` access.
///
/// **Structural sharing (the R4 umbrella mandate).** Each table is a
/// `Vec<Arc<Row>>`. The freshly-projected rows are reconciled against the
/// previously published snapshot via
/// [`reconcile_rows`](crate::control::snapshot::reconcile_rows): a row's
/// `Arc` is reused (kept by pointer) whenever it matches the prior row by
/// identity and compares equal by value, so a tick in which only one
/// peer/session changed re-allocates only that one row, not the whole table.
/// This is what keeps the publish cost off the hot path at scale (the exact
/// thing the umbrella warns a naive per-tick rebuild would violate).
fn publish_entities_snapshot(&self) {
use crate::control::snapshot as snap;
let prev = self.entities_snapshot.load();
// --- peers (show_peers) ---
let tree = self.tree_state();
let my_addr = *tree.my_node_addr();
let parent_id = *tree.my_declaration().parent_id();
let is_root = tree.is_root();
// Per-npub Nostr-traversal failure-state, indexed by npub for O(1)
// per-peer lookup (empty when Nostr discovery is disabled).
let nostr_state: std::collections::HashMap<String, _> = self
.nostr_discovery_handle()
.map(|d| {
d.failure_state_snapshot()
.into_iter()
.map(|view| (view.npub.clone(), view))
.collect()
})
.unwrap_or_default();
let peer_rows: Vec<snap::PeerRow> = self
.peers()
.map(|peer| {
let node_addr = *peer.node_addr();
let is_parent = !is_root && node_addr == parent_id;
let is_child = tree
.peer_declaration(&node_addr)
.is_some_and(|decl| *decl.parent_id() == my_addr);
let link_info = self.get_link(&peer.link_id()).map(|link| {
let transport_type = self
.get_transport(&link.transport_id())
.map(|h| h.transport_type().name.to_string());
snap::PeerLinkInfo {
direction: format!("{}", link.direction()),
transport_type,
}
});
let stats = peer.link_stats();
let nostr = nostr_state.get(&peer.npub());
let nostr_traversal = snap::PeerNostrState {
consecutive_failures: nostr.map(|s| s.consecutive_failures).unwrap_or(0),
cooldown_until_ms: nostr.and_then(|s| s.cooldown_until_ms),
last_observed_skew_ms: nostr.and_then(|s| s.last_observed_skew_ms),
};
let noise = peer.noise_session().map(|session| snap::PeerNoiseCounters {
send_counter: session.current_send_counter(),
highest_recv_counter: session.highest_received_counter(),
});
let mmp = peer
.mmp()
.map(|mmp| project_entity_mmp(&mmp.metrics, format!("{}", mmp.mode()), None));
snap::PeerRow {
node_addr,
npub: peer.npub(),
display_name: self.peer_display_name(&node_addr),
ipv6_addr: format!("{}", peer.address()),
connectivity: format!("{}", peer.connectivity()),
link_id: peer.link_id().as_u64(),
authenticated_at_ms: peer.authenticated_at(),
last_seen_ms: peer.last_seen(),
has_tree_position: peer.has_tree_position(),
has_bloom_filter: peer.filter_sequence() > 0,
filter_sequence: peer.filter_sequence(),
is_parent,
is_child,
transport_addr: peer.current_addr().map(|a| format!("{}", a)),
link_info,
tree_depth: peer.coords().map(|c| c.depth()),
stats: snap::PeerLinkStats {
packets_sent: stats.packets_sent,
packets_recv: stats.packets_recv,
bytes_sent: stats.bytes_sent,
bytes_recv: stats.bytes_recv,
},
replay_suppressed: peer.replay_suppressed_count(),
consecutive_decrypt_failures: peer.consecutive_decrypt_failures(),
nostr_traversal,
noise,
our_session_index: peer.our_index().map(|idx| idx.as_u32()),
rekey_in_progress: peer.rekey_in_progress(),
rekey_draining: peer.is_draining(),
current_k_bit: peer.current_k_bit(),
mmp,
}
})
.collect();
// --- sessions (show_sessions) ---
let session_rows: Vec<snap::SessionRow> = self
.session_entries()
.map(|(addr, entry)| {
let state = if entry.is_established() {
"established"
} else if entry.is_initiating() {
"initiating"
} else if entry.is_awaiting_msg3() {
"awaiting_msg3"
} else {
"unknown"
};
let (xonly, _parity) = entry.remote_pubkey().x_only_public_key();
let (pkts_tx, pkts_rx, bytes_tx, bytes_rx) = entry.traffic_counters();
let resend_count = (!entry.is_established()).then(|| entry.resend_count());
let established = entry.is_established().then(|| snap::SessionEstablished {
session_start_ms: entry.session_start_ms(),
current_k_bit: entry.current_k_bit(),
coords_warmup_remaining: entry.coords_warmup_remaining(),
is_draining: entry.is_draining(),
});
let mmp = entry.mmp().map(|mmp| {
project_entity_mmp(
&mmp.metrics,
format!("{}", mmp.mode()),
Some(mmp.path_mtu.current_mtu()),
)
});
snap::SessionRow {
remote_addr: *addr,
display_name: self.peer_display_name(addr),
state,
is_initiator: entry.is_initiator(),
last_activity_ms: entry.last_activity(),
npub: crate::identity::encode_npub(&xonly),
stats: snap::SessionStats {
packets_sent: pkts_tx,
packets_recv: pkts_rx,
bytes_sent: bytes_tx,
bytes_recv: bytes_rx,
},
resend_count,
established,
mmp,
}
})
.collect();
// --- links (show_links) ---
let link_rows: Vec<snap::LinkRow> = self
.links()
.map(|link| {
let stats = link.stats();
snap::LinkRow {
link_id: link.link_id().as_u64(),
transport_id: link.transport_id().as_u32(),
remote_addr: format!("{}", link.remote_addr()),
direction: format!("{}", link.direction()),
state: format!("{}", link.state()),
created_at_ms: link.created_at(),
stats: snap::LinkStats {
packets_sent: stats.packets_sent,
packets_recv: stats.packets_recv,
bytes_sent: stats.bytes_sent,
bytes_recv: stats.bytes_recv,
last_recv_ms: stats.last_recv_ms,
},
}
})
.collect();
// --- connections (show_connections) ---
let connection_rows: Vec<snap::ConnectionRow> = self
.connections()
.map(|conn| snap::ConnectionRow {
link_id: conn.link_id().as_u64(),
direction: format!("{}", conn.direction()),
handshake_state: format!("{}", conn.handshake_state()),
started_at_ms: conn.started_at(),
last_activity_ms: conn.last_activity(),
resend_count: conn.resend_count(),
expected_peer: conn.expected_identity().map(|id| id.npub()),
})
.collect();
// --- transports (show_transports) ---
let transport_rows: Vec<snap::TransportRow> = self
.transport_ids()
.map(|id| {
let handle = self.get_transport(id).unwrap();
snap::TransportRow {
transport_id: id.as_u32(),
transport_type: handle.transport_type().name.to_string(),
state: format!("{}", handle.state()),
mtu: handle.mtu(),
name: handle.name().map(|s| s.to_string()),
local_addr: handle.local_addr().map(|a| format!("{}", a)),
tor_mode: handle.tor_mode().map(|s| s.to_string()),
onion_address: handle.onion_address().map(|s| s.to_string()),
tor_monitoring: handle
.tor_monitoring()
.map(|m| serde_json::to_value(&m).unwrap_or_default()),
stats: handle.transport_stats(),
}
})
.collect();
// --- mmp peers (show_mmp link-layer) ---
let mmp_peer_rows: Vec<snap::MmpPeerRow> = self
.peers()
.filter_map(|peer| {
let mmp = peer.mmp()?;
let addr = *peer.node_addr();
let metrics = &mmp.metrics;
let srtt_ms = metrics.srtt_ms();
let smoothed_etx = metrics.smoothed_etx();
let lqi = match (srtt_ms, smoothed_etx) {
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
_ => None,
};
let trend = |dual: &crate::mmp::algorithms::DualEwma| {
dual.initialized()
.then(|| crate::control::queries::trend_label(dual.short(), dual.long()))
};
Some(snap::MmpPeerRow {
peer: addr,
display_name: self.peer_display_name(&addr),
mode: format!("{}", mmp.mode()),
loss_rate: metrics.loss_rate(),
etx: metrics.etx,
goodput_bps: metrics.goodput_bps,
spin_bit_initiator: mmp.spin_bit.is_initiator(),
smoothed_loss: metrics.smoothed_loss(),
smoothed_etx,
srtt_ms,
lqi,
trends: snap::MmpTrends {
rtt_trend: trend(&metrics.rtt_trend),
loss_trend: trend(&metrics.loss_trend),
goodput_trend: trend(&metrics.goodput_trend),
jitter_trend: trend(&metrics.jitter_trend),
},
delivery_ratio_forward: metrics.delivery_ratio_forward,
delivery_ratio_reverse: metrics.delivery_ratio_reverse,
ecn_ce_count: metrics.last_ecn_ce_count(),
})
})
.collect();
// --- mmp sessions (show_mmp session-layer) ---
let mmp_session_rows: Vec<snap::MmpSessionRow> = self
.session_entries()
.filter_map(|(addr, entry)| {
let mmp = entry.mmp()?;
let metrics = &mmp.metrics;
let srtt_ms = metrics.srtt_ms();
let smoothed_etx = metrics.smoothed_etx();
let sqi = match (srtt_ms, smoothed_etx) {
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
_ => None,
};
Some(snap::MmpSessionRow {
remote: *addr,
display_name: self.peer_display_name(addr),
mode: format!("{}", mmp.mode()),
loss_rate: metrics.loss_rate(),
etx: metrics.etx,
path_mtu: mmp.path_mtu.current_mtu(),
smoothed_loss: metrics.smoothed_loss(),
smoothed_etx,
srtt_ms,
sqi,
})
})
.collect();
let snapshot = snap::EntitySnapshot {
peers: snap::reconcile_rows(&prev.peers, peer_rows, |r| r.node_addr),
sessions: snap::reconcile_rows(&prev.sessions, session_rows, |r| r.remote_addr),
links: snap::reconcile_rows(&prev.links, link_rows, |r| r.link_id),
connections: snap::reconcile_rows(&prev.connections, connection_rows, |r| r.link_id),
transports: snap::reconcile_rows(&prev.transports, transport_rows, |r| r.transport_id),
mmp_peers: snap::reconcile_rows(&prev.mmp_peers, mmp_peer_rows, |r| r.peer),
mmp_sessions: snap::reconcile_rows(&prev.mmp_sessions, mmp_session_rows, |r| r.remote),
};
self.entities_snapshot.store(std::sync::Arc::new(snapshot));
}
// === TUN Interface ===
@@ -2297,6 +2854,38 @@ impl Node {
}
}
/// Project an MMP metrics block into the snapshot
/// [`EntityMmp`](crate::control::snapshot::EntityMmp) shared by `show_peers`
/// (link-layer, `path_mtu = None`) and `show_sessions` (session-layer,
/// `path_mtu = Some`). `quality_index` (`lqi` for peers / `sqi` for sessions)
/// is precomputed here exactly as the on-loop queries do, so the render is a
/// plain field emit.
fn project_entity_mmp(
metrics: &crate::mmp::metrics::MmpMetrics,
mode: String,
path_mtu: Option<u16>,
) -> crate::control::snapshot::EntityMmp {
let srtt_ms = metrics.srtt_ms();
let smoothed_etx = metrics.smoothed_etx();
let quality_index = match (srtt_ms, smoothed_etx) {
(Some(srtt), Some(setx)) => Some(setx * (1.0 + srtt / 100.0)),
_ => None,
};
crate::control::snapshot::EntityMmp {
mode,
srtt_ms,
loss_rate: metrics.loss_rate(),
etx: metrics.etx,
goodput_bps: metrics.goodput_bps,
delivery_ratio_forward: metrics.delivery_ratio_forward,
delivery_ratio_reverse: metrics.delivery_ratio_reverse,
smoothed_loss: metrics.smoothed_loss(),
smoothed_etx,
quality_index,
path_mtu,
}
}
impl fmt::Debug for Node {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Node")