Files
fips/src/control/snapshot.rs
T
Johnathan Corgan 22372018bd Merge master into next
Carries the per-peer medium-change probe, the work written on top of it,
and the heartbeat-send accounting fix that came up from the maintenance
line. Every source file merged without a conflict, because this line had
not touched any of them.

The changelog was the only conflict, in two hunks, and one of them was
not the union it looked like. The first was: this line's own Fixed
entries and the other line's Node lifecycle subsection are both
additions, and both are kept. The second was not. The medium-change
detection entry already existed here, inherited when this line last took
a merge, and the other side was offering the same entry with an upgrade
note appended rather than a new one. Taking both would have described the
feature twice, once with the note and once without, so the older copy is
replaced in place and stays under Added where it belongs.

That note is the part with a consequence, and it now reaches this line
too: with detection on by default, a node that shortened its liveness
timeout to one or two seconds for fast failover is refused at startup
after the upgrade, citing a key its operator never set.
2026-09-09 19:38:53 +00:00

866 lines
33 KiB
Rust

//! 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 reference implementation of the canonical snapshot pattern: 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:
//! 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.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
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;
use secp256k1::XOnlyPublicKey;
/// Read-only snapshot of the stats-history rings plus the scalar gauges and
/// counts `show_status` reports. Published from the tick.
#[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,
/// Current spanning-tree root `NodeAddr` (rendered as hex by `show_status`).
pub root: NodeAddr,
/// Whether this node is the spanning-tree root.
pub is_root: bool,
/// Per-configured-transport-type count of peers whose active link rides that
/// transport type. Configured-but-idle types appear with a zero count.
/// Keyed by the transport type name (`"udp"`, `"tcp"`, `"tor"`, ...).
pub transport_peer_counts: std::collections::BTreeMap<String, 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>>,
/// 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.
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`).
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 {
/// 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,
root: zero_addr(),
is_root: false,
transport_peer_counts: std::collections::BTreeMap::new(),
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(),
stale: false,
}
}
// =====================================================================
// RoutingSnapshot (derived/routing/cache read view)
// =====================================================================
/// Read-only snapshot of the derived/routing/cache subsystems that
/// the pure-snapshot `show_tree` / `show_bloom` / `show_cache` / `show_routing`
/// / `show_identity_cache` queries render. Published via `ArcSwap`.
///
/// This is the single combined `ArcSwap<RoutingSnapshot>` cell for the routing
/// subsystems: one
/// cohesive routing view holding the four subsystems (tree / bloom / coord
/// cache / identity cache) plus the F-queue summary scalars.
///
/// **Publisher placement.** 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 — per-entity
/// 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**, the same placement the stats snapshot
/// above uses: 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
/// does not arise.
///
/// The snapshot holds *data* (typed rows + scalars), not rendered `Response`
/// envelopes; 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: if the derived/routing/cache subsystems are later
/// extracted 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,
/// Resolved npub of the root node, when discoverable (self when root, a live
/// peer's attested npub, or an identity-cache hit); `None` otherwise.
pub root_npub: Option<String>,
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(),
root_npub: None,
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>,
/// Fill ratio of the last filter actually sent uptree (to the tree parent).
/// `None` for a root node (nothing sent uptree) or before the first
/// announce has been sent.
pub uptree_fill_ratio: Option<f64>,
/// Estimated cardinality of the last filter sent uptree — this node's whole
/// subtree (self + tree-descendants, parent excluded), since bloom is
/// split-horizon. `None` for root, pre-first-announce, or when the estimate
/// is undefined for the saturation.
pub uptree_estimated_count: Option<f64>,
}
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(),
uptree_fill_ratio: None,
uptree_estimated_count: None,
}
}
}
/// 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,
}
// =====================================================================
// NativeSnapshot (native datagram API read view)
// =====================================================================
/// Read-only snapshot of the native datagram API registry that
/// `show_native_flows` renders. Published via `ArcSwap`.
///
/// **Publisher placement.** The registry lives inside `Node` and is touched
/// only by the rx_loop, which is precisely why the native receive path takes no
/// lock; a control task cannot read it at all, and putting a lock on it to allow
/// that would give back the property the design was built for. So the
/// projection is published from the tick beside the other snapshot cells.
///
/// **Cost.** A field read per flow. The peer's key is captured where its
/// session authenticated it and rides on the registry entry, so publishing a
/// row resolves nothing and consults no cache.
///
/// Time-relative fields (`age_ms`) are derived at render time from the captured
/// absolute timestamps, so a rendered age stays fresh relative to the read.
#[derive(Clone, Default)]
pub(crate) struct NativeSnapshot {
/// One row per flow, established and pending, ordered by identifier.
pub flows: Vec<NativeFlowRow>,
/// One row per listener, ordered by port.
pub listeners: Vec<NativeListenerRow>,
}
impl NativeSnapshot {
/// 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::default()
}
}
/// One native API flow in `show_native_flows`.
#[derive(Clone)]
pub(crate) struct NativeFlowRow {
/// The identifier the client names the flow by.
pub flow: u64,
/// The far end, by node address. Kept because this is an operator surface
/// and the node address is what `show_sessions` and `show_routing` key on,
/// which is what lets a reader correlate the three.
pub peer: NodeAddr,
/// The far end's address, as the x-only public key. Always present: a
/// connected flow decoded it from the npub its client named, and an
/// accepted one took it from the session that authenticated the peer.
pub peer_key: XOnlyPublicKey,
/// This node's port.
pub local_port: u16,
/// The far end's port.
pub remote_port: u16,
/// Whether a client has taken the flow, as opposed to still awaiting accept.
pub established: bool,
/// Datagrams the node is holding for it.
pub queued: usize,
/// Absolute open / accept / announce time (Unix ms); `age_ms` derived at
/// render time.
pub since_ms: u64,
}
/// One native API listener in `show_native_flows`.
#[derive(Clone)]
pub(crate) struct NativeListenerRow {
/// The local port it holds.
pub local_port: u16,
/// Flows announced on it and not yet answered.
pub backlog: usize,
}
// =====================================================================
// EntitySnapshot (per-entity table read views)
// =====================================================================
/// Read-only snapshot of the per-entity tables that the
/// pure-snapshot `show_peers` / `show_sessions` / `show_links` /
/// `show_connections` / `show_transports` / `show_mmp` queries render.
/// Published via `ArcSwap`.
///
/// This is the `entities` cell: peers / sessions / links / connections /
/// transports, published per-entity with `Vec<Arc<Row>>` structural sharing.
///
/// **Structural sharing.** 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.
///
/// **Publisher placement.** Like the routing snapshot, this is published from
/// the **tick**, not per-mutator. Two reasons, both stronger here than for the
/// routing snapshot:
///
/// 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
/// 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 (only
/// changed rows re-allocate) while keeping a single coherent `&Node`
/// publisher: there is no monolithic per-tick *re-allocation* of every row,
/// because unchanged rows are reused rather than rebuilt. This is the same
/// tick publish placement the routing snapshot uses, for the same reason.
///
/// The snapshot holds typed rows (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: if the session table is later extracted into a typed
/// `(transport_id, our_index)`-indexed type, these projections 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>,
/// The peer's current transport address as a numeric IP endpoint, when it
/// is one. Not rendered anywhere: this is the medium-change detector's
/// read of the peer table (see [`crate::node::netmon`]), carried here
/// because the detector is a detached task and this snapshot is the
/// node's existing lock-free read side.
///
/// `None` covers everything that is not a probeable IP destination — a
/// MAC on Ethernet or BLE, a `.onion` or Nym recipient, a peer still
/// carrying the hostname it was configured with, an IPv6 literal with a
/// scope suffix. Typed rather than re-parsed from `transport_addr` above
/// so a change to that string's rendering cannot silently leave the
/// detector with nothing to probe.
pub probe_target: Option<SocketAddr>,
/// Source address this peer's per-peer `connect()`-ed UDP socket was
/// pinned to by `connect(2)`, when it has one. Also not rendered, and read by the same detector:
/// it is what the send path is *actually* using, as against the
/// `probe_target` lookup's answer for what the kernel would choose now.
///
/// `None` where there is no such socket — every platform but Linux and
/// macOS, a peer on another transport, and a peer whose socket has not
/// been installed yet or was just released — and also where the kernel
/// declined to name a source, which is not an address and must not be
/// compared as one.
pub bound_source: Option<IpAddr>,
/// Address this peer's transport is bound to, when that bind is not the
/// wildcard. Read by the same detector, which has to put its probe the
/// same constrained question the send path answers.
///
/// `open_connected_fd` binds the transport's configured address verbatim
/// and only then connects, so a non-wildcard `bind_addr` pins the source
/// whatever the routing table says, while an unconstrained probe takes the
/// kernel's choice. Left unequal, those two answers differ permanently and
/// every first-seen peer reports a move that never happened.
///
/// `None` for the wildcard bind, which is the default and the case where
/// the kernel chooses on both sides.
pub probe_bind: Option<IpAddr>,
pub link_info: Option<PeerLinkInfo>,
pub tree_depth: Option<usize>,
/// `effective_depth = tree_depth + link_cost` — the same quantity
/// `evaluate_parent` ranks parent candidates on. `None` when the peer is
/// unmeasured (no SRTT while other peers have it) or has no coords,
/// mirroring the candidacy/cold-start rules. Pre-computed daemon-side so
/// fipstop never recomputes it.
pub effective_depth: Option<f64>,
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 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,
}
/// MMP trend labels for a session's session-layer block in `show_mmp` (each
/// present only when the corresponding trend is initialized). Mirrors the
/// link-layer [`MmpTrends`] arrow semantics on the session columns: srtt
/// (`rtt_trend`), loss (`loss_trend`), etx (`etx_trend`).
#[derive(Clone, PartialEq)]
pub(crate) struct MmpSessionTrends {
pub rtt_trend: Option<&'static str>,
pub loss_trend: Option<&'static str>,
pub etx_trend: Option<&'static str>,
}
/// 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>,
pub trends: MmpSessionTrends,
}
/// 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>>` structural-sharing discipline — 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()
}