diff --git a/src/node/bloom.rs b/src/node/bloom.rs index 00853ac..989057a 100644 --- a/src/node/bloom.rs +++ b/src/node/bloom.rs @@ -179,7 +179,7 @@ impl Node { /// Non-routing nodes do not send filters (they receive only). pub(super) async fn send_pending_filter_announces(&mut self) { // Non-routing and leaf nodes don't send bloom filters - if self.node_profile != crate::protocol::NodeProfile::Full { + if self.node_profile() != crate::protocol::NodeProfile::Full { return; } @@ -402,7 +402,7 @@ impl Node { /// and marks all peers for update. fn check_adaptive_sizing(&mut self) { // Only Full nodes participate in filter sizing - if self.node_profile != crate::protocol::NodeProfile::Full { + if self.node_profile() != crate::protocol::NodeProfile::Full { return; } diff --git a/src/node/context.rs b/src/node/context.rs index 6a450bf..e78c61b 100644 --- a/src/node/context.rs +++ b/src/node/context.rs @@ -1,16 +1,17 @@ //! Shared immutable context bundle. //! //! [`NodeContext`] groups the [`Node`](super::Node)'s effectively-immutable -//! fields behind a single `Arc` so that handlers can eventually borrow a -//! cheap `&NodeContext` clone instead of `&self`. +//! fields behind a single `Arc` so that handlers can borrow a cheap +//! `&NodeContext` clone instead of `&self`. //! -//! During the migration it is a *parallel, authoritative* copy of the -//! corresponding `Node` fields: both are kept in lockstep at the only three -//! mutation points — the constructor, [`update_peers`](super::Node::update_peers), -//! and the test-only `set_max_*` setters — via -//! [`Node::rebuild_context`](super::Node::rebuild_context). Readers migrate -//! onto the bundle incrementally; the duplicated `Node` fields are removed -//! once the last reader has moved over. +//! It is the **sole store** of these fields: the `Node` no longer keeps +//! duplicate copies. The bundle itself is immutable; the rare mutation of a +//! bundled field (the constructors, [`leaf_only`](super::Node::leaf_only), +//! and [`update_peers`](super::Node::update_peers)) is done by building a fresh +//! `NodeContext` and swapping the whole `Arc` via +//! [`Node::replace_context`](super::Node::replace_context). Readers reach the +//! fields through the `Node` accessors (`config()`, `identity()`, +//! `startup_epoch()`, `is_leaf_only()`, `max_*()`, `uptime()`). use std::sync::Arc; @@ -28,8 +29,6 @@ pub(crate) struct NodeContext { pub identity: Identity, /// Random epoch generated at startup for peer restart detection. - // Consumed by readers migrating in a later sub-PR. - #[allow(dead_code)] pub startup_epoch: [u8; 8], /// Instant when the node was created, for uptime reporting. @@ -42,18 +41,12 @@ pub(crate) struct NodeContext { pub node_profile: NodeProfile, /// Maximum connections (0 = unlimited). - // Consumed by readers migrating in a later sub-PR. - #[allow(dead_code)] pub max_connections: usize, /// Maximum peers (0 = unlimited). - // Consumed by readers migrating in a later sub-PR. - #[allow(dead_code)] pub max_peers: usize, /// Maximum links (0 = unlimited). - // Consumed by readers migrating in a later sub-PR. - #[allow(dead_code)] pub max_links: usize, } diff --git a/src/node/handlers/discovery.rs b/src/node/handlers/discovery.rs index baa0116..76c77cd 100644 --- a/src/node/handlers/discovery.rs +++ b/src/node/handlers/discovery.rs @@ -366,7 +366,7 @@ impl Node { } // Leaf nodes don't forward discovery requests - if self.node_profile == crate::protocol::NodeProfile::Leaf { + if self.node_profile() == crate::protocol::NodeProfile::Leaf { return; } diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index e5d24f3..b7448dc 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -179,13 +179,13 @@ impl Node { ); // Create FMP negotiation payload for msg2 (includes profile, MMP bits, bloom TLV) - let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode(); + let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile()).encode(); let our_keypair = self.identity().keypair(); let noise_msg1 = &packet.data[header.noise_msg1_offset..]; let msg2_response = match conn.receive_handshake_init( our_keypair, - self.startup_epoch, + self.startup_epoch(), noise_msg1, Some(&neg_payload), packet.timestamp_ms, @@ -492,6 +492,7 @@ impl Node { return; } + let our_profile = self.node_profile(); let (peer_identity, msg3_bytes, our_index) = { let Some(conn) = self.connections.get_mut(&link_id) else { warn!(link_id = %link_id, "Connection removed during msg2 processing"); @@ -502,7 +503,7 @@ impl Node { }; // Create FMP negotiation payload for msg3 (includes profile, MMP bits, bloom TLV) - let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode(); + let neg_payload = NegotiationPayload::fmp(1, 1, our_profile).encode(); // Process Noise msg2 and generate msg3 let noise_msg2 = &packet.data[header.noise_msg2_offset..]; @@ -527,10 +528,10 @@ impl Node { // Process peer's FMP negotiation payload from msg2 if let Some(neg_bytes) = &received_negotiation { - match process_fmp_negotiation(self.node_profile, conn, neg_bytes) { + match process_fmp_negotiation(our_profile, conn, neg_bytes) { Ok(()) => {} Err(e) => { - warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed"); + warn!(link_id = %link_id, our_profile = %our_profile, error = %e, "FMP negotiation failed"); conn.mark_failed(); self.stats_mut() .record_reject(RejectReason::Handshake(HandshakeReject::BadState)); @@ -878,6 +879,7 @@ impl Node { } }; + let our_profile = self.node_profile(); let (peer_identity, our_index, remote_epoch) = { // Get the pending connection let conn = match self.connections.get_mut(&link_id) { @@ -922,10 +924,10 @@ impl Node { // Process peer's FMP negotiation payload from msg3 if let Some(neg_bytes) = &received_negotiation { - match process_fmp_negotiation(self.node_profile, conn, neg_bytes) { + match process_fmp_negotiation(our_profile, conn, neg_bytes) { Ok(()) => {} Err(e) => { - warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed"); + warn!(link_id = %link_id, our_profile = %our_profile, error = %e, "FMP negotiation failed"); self.connections.remove(&link_id); self.remove_link(&link_id); self.stats_mut() @@ -1025,7 +1027,7 @@ impl Node { // admitting them doesn't grow peers.len(). The late cap check // inside promote_connection() is intentionally left in place // as defense-in-depth. - if self.max_peers > 0 && self.peers.len() >= self.max_peers { + if self.max_peers() > 0 && self.peers.len() >= self.max_peers() { let is_known_active = self.peers.contains_key(&peer_node_addr); let is_pending_outbound = self.connections.iter().any(|(_, conn)| { conn.expected_identity() @@ -1035,7 +1037,7 @@ impl Node { if !is_known_active && !is_pending_outbound { debug!( peer = %self.peer_display_name(&peer_node_addr), - max = self.max_peers, + max = self.max_peers(), "Silent-dropping Msg3 at max_peers cap (early gate; no promotion)" ); // Capture our_index before removing the connection so @@ -1497,7 +1499,7 @@ impl Node { ) -> Result { // Leaf nodes: reject if we already have a peer (single-peer enforcement) let peer_node_addr_check = *verified_identity.node_addr(); - if self.node_profile == crate::protocol::NodeProfile::Leaf + if self.node_profile() == crate::protocol::NodeProfile::Leaf && !self.peers.is_empty() && !self.peers.contains_key(&peer_node_addr_check) { @@ -1639,7 +1641,7 @@ impl Node { is_outbound, &self.config().node.mmp, remote_epoch, - self.node_profile, + self.node_profile(), peer_profile, ); new_peer.set_tree_announce_min_interval_ms( @@ -1720,10 +1722,10 @@ impl Node { } // Normal promotion - if self.max_peers > 0 && self.peers.len() >= self.max_peers { + if self.max_peers() > 0 && self.peers.len() >= self.max_peers() { let _ = self.index_allocator.free(our_index); return Err(NodeError::MaxPeersExceeded { - max: self.max_peers, + max: self.max_peers(), }); } @@ -1750,7 +1752,7 @@ impl Node { is_outbound, &self.config().node.mmp, remote_epoch, - self.node_profile, + self.node_profile(), peer_profile, ); new_peer.set_tree_announce_min_interval_ms( diff --git a/src/node/handlers/rekey.rs b/src/node/handlers/rekey.rs index aa7cb78..d8b02bc 100644 --- a/src/node/handlers/rekey.rs +++ b/src/node/handlers/rekey.rs @@ -204,7 +204,7 @@ impl Node { // Create XX initiator handshake directly (no PeerConnection) let our_keypair = self.identity().keypair(); let mut hs = HandshakeState::new_initiator(our_keypair); - hs.set_local_epoch(self.startup_epoch); + hs.set_local_epoch(self.startup_epoch()); let noise_msg1 = match hs.write_message_1() { Ok(msg) => msg, @@ -543,7 +543,7 @@ impl Node { // Create Noise XX initiator handshake (rekey: no negotiation payload) let our_keypair = self.identity().keypair(); let mut handshake = HandshakeState::new_initiator(our_keypair); - handshake.set_local_epoch(self.startup_epoch); + handshake.set_local_epoch(self.startup_epoch()); let msg1 = match handshake.write_message_1() { Ok(m) => m, diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index 93c33db..76c550d 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -507,7 +507,7 @@ impl Node { } let our_keypair = self.identity().keypair(); let mut handshake = HandshakeState::new_responder(our_keypair); - handshake.set_local_epoch(self.startup_epoch); + handshake.set_local_epoch(self.startup_epoch()); if let Err(e) = handshake.read_message_1(&setup.handshake_payload) { debug!(error = %e, "Failed to process rekey XX msg1"); @@ -558,7 +558,7 @@ impl Node { // Create XX responder handshake and process msg1 let our_keypair = self.identity().keypair(); let mut handshake = HandshakeState::new_responder(our_keypair); - handshake.set_local_epoch(self.startup_epoch); + handshake.set_local_epoch(self.startup_epoch()); if let Err(e) = handshake.read_message_1(&setup.handshake_payload) { debug!(error = %e, "Failed to process Noise XX msg1 in SessionSetup"); @@ -1419,7 +1419,7 @@ impl Node { // Create Noise XX initiator handshake let our_keypair = self.identity().keypair(); let mut handshake = HandshakeState::new_initiator(our_keypair); - handshake.set_local_epoch(self.startup_epoch); + handshake.set_local_epoch(self.startup_epoch()); let msg1 = handshake .write_message_1() .map_err(|e| NodeError::SendFailed { diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index ccb67bc..5d52417 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -55,11 +55,14 @@ impl Node { new_by_addr.insert(*identity.node_addr(), peer); } - // Read the current peer set directly from the field: update_peers is the - // config-source mutation owner (it writes self.config.peers below and then - // rebuilds the context), so it manages config.peers directly rather than - // through the context accessor — same rationale as the write at line ~124. + // Read the current peer set from the context *before* the swap below: + // update_peers is the config-source mutation owner. It reads the current + // (pre-update) peer set here, builds a fresh Config + context, then swaps + // the whole Arc. Reading the live context Arc before the swap yields the + // pre-update set the diff needs (the `update_peers_races_*` canary depends + // on this ordering). let current_by_addr: HashMap = self + .context .config .peers() .iter() @@ -124,8 +127,9 @@ impl Node { .map(|node_addr| new_by_addr[node_addr].clone()) .collect(); - self.config.peers = new_by_addr.into_values().collect(); - self.rebuild_context(); + let mut new_config = (*self.context.config).clone(); + new_config.peers = new_by_addr.into_values().collect(); + self.replace_context(|ctx| ctx.config = std::sync::Arc::new(new_config)); for peer_config in added_configs { outcome.added += 1; @@ -502,7 +506,7 @@ impl Node { // Start the Noise handshake and get message 1 let our_keypair = self.identity().keypair(); let noise_msg1 = - match connection.start_handshake(our_keypair, self.startup_epoch, current_time_ms) { + match connection.start_handshake(our_keypair, self.startup_epoch(), current_time_ms) { Ok(msg) => msg, Err(e) => { // Clean up the index and link @@ -755,7 +759,7 @@ impl Node { debug!( peer_npub = %traversal.peer_npub, peers = self.peers.len(), - max_peers = self.max_peers, + max_peers = self.max_peers(), "Dropping established NAT traversal: at capacity" ); continue; @@ -2128,16 +2132,16 @@ impl Node { .connections .len() .saturating_add(self.pending_connects.len()); - let connection_slots = if self.max_connections == 0 { + let connection_slots = if self.max_connections() == 0 { usize::MAX } else { - self.max_connections.saturating_sub(connection_used) + self.max_connections().saturating_sub(connection_used) }; - let peer_slots = if self.max_peers == 0 { + let peer_slots = if self.max_peers() == 0 { usize::MAX } else { - self.max_peers.saturating_sub(self.peers.len()) + self.max_peers().saturating_sub(self.peers.len()) }; connection_slots.min(peer_slots) @@ -2148,25 +2152,25 @@ impl Node { .connections .len() .saturating_add(self.pending_connects.len()); - if self.max_connections == 0 { + if self.max_connections() == 0 { usize::MAX } else { - self.max_connections.saturating_sub(used) + self.max_connections().saturating_sub(used) } } fn outbound_link_slots(&self) -> usize { - if self.max_links == 0 { + if self.max_links() == 0 { usize::MAX } else { - self.max_links.saturating_sub(self.links.len()) + self.max_links().saturating_sub(self.links.len()) } } fn path_candidate_attempt_budget(&self, peer_node_addr: &NodeAddr) -> usize { if !self.peers.contains_key(peer_node_addr) - && self.max_peers > 0 - && self.peers.len() >= self.max_peers + && self.max_peers() > 0 + && self.peers.len() >= self.max_peers() { return 0; } diff --git a/src/node/mod.rs b/src/node/mod.rs index 42c0e58..e62b1fc 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -306,38 +306,18 @@ struct PendingConnect { /// connection before authentication completes. // Discovery lookup constants moved to config: node.discovery.attempt_timeouts_secs, node.discovery.ttl pub struct Node { - // === Identity === - /// This node's cryptographic identity. - identity: Identity, - - /// Random epoch generated at startup for peer restart detection. - /// Exchanged inside Noise handshake messages so peers can detect restarts. - startup_epoch: [u8; 8], - - /// Instant when the node was created, for uptime reporting. - started_at: std::time::Instant, - - // === Configuration === - /// Loaded configuration. - config: Config, - - /// Shared immutable context bundle. A parallel, authoritative copy of the - /// effectively-immutable fields (config/identity/startup_epoch/started_at/ - /// is_leaf_only/max_*), kept in lockstep with the `Node` fields via - /// `rebuild_context`. Readers migrate onto it in later sub-PRs; the - /// duplicated `Node` fields are removed once the last reader has moved. + // === Immutable Context === + /// Shared immutable context bundle: the single source of truth for the + /// node's effectively-immutable state (config/identity/startup_epoch/ + /// started_at/is_leaf_only/max_*). Mutated only by whole-`Arc` replacement + /// via `replace_context` at the constructors, `leaf_only`, and + /// `update_peers`; readers reach it through the accessors. context: Arc, // === State === /// Node operational state. state: NodeState, - /// Whether this is a leaf-only node. - is_leaf_only: bool, - - /// Node profile derived from config (Full, NonRouting, Leaf). - node_profile: NodeProfile, - // === Spanning Tree === /// Local spanning tree state. tree_state: TreeState, @@ -404,14 +384,6 @@ pub struct Node { /// initiation timestamp (Unix ms). Prevents duplicate flood queries. pending_lookups: HashMap, - // === Resource Limits === - /// Maximum connections (0 = unlimited). - max_connections: usize, - /// Maximum peers (0 = unlimited). - max_peers: usize, - /// Maximum links (0 = unlimited). - max_links: usize, - // === Counters === /// Next link ID to allocate. next_link_id: u64, @@ -676,14 +648,8 @@ impl Node { )); Ok(Self { - identity, - startup_epoch, - started_at, - config, context, state: NodeState::Created, - is_leaf_only, - node_profile, tree_state, bloom_state, coord_cache, @@ -700,9 +666,6 @@ impl Node { identity_cache: HashMap::new(), pending_tun_packets: HashMap::new(), pending_lookups: HashMap::new(), - max_connections, - max_peers, - max_links, next_link_id: 1, next_transport_id: 1, stats: stats::NodeStats::new(), @@ -839,14 +802,8 @@ impl Node { )); Ok(Self { - identity, - startup_epoch, - started_at, - config, context, state: NodeState::Created, - is_leaf_only: false, - node_profile: NodeProfile::Full, tree_state, bloom_state, coord_cache, @@ -863,9 +820,6 @@ impl Node { identity_cache: HashMap::new(), pending_tun_packets: HashMap::new(), pending_lookups: HashMap::new(), - max_connections, - max_peers, - max_links, next_link_id: 1, next_transport_id: 1, stats: stats::NodeStats::new(), @@ -926,10 +880,11 @@ impl Node { /// Create a leaf-only node (simplified state). pub fn leaf_only(config: Config) -> Result { let mut node = Self::new(config)?; - node.is_leaf_only = true; - node.node_profile = NodeProfile::Leaf; - node.bloom_state = BloomState::leaf_only(*node.identity.node_addr()); - node.rebuild_context(); + node.bloom_state = BloomState::leaf_only(*node.node_addr()); + node.replace_context(|ctx| { + ctx.is_leaf_only = true; + ctx.node_profile = NodeProfile::Leaf; + }); Ok(node) } @@ -941,7 +896,7 @@ impl Node { // Collect UDP configs with optional names to avoid borrow conflicts let udp_instances: Vec<_> = self - .config + .config() .transports .udp .iter() @@ -959,13 +914,12 @@ impl Node { #[cfg(unix)] { let eth_instances: Vec<_> = self - .config + .config() .transports .ethernet .iter() .map(|(name, config)| (name.map(|s| s.to_string()), config.clone())) .collect(); - for (name, eth_config) in eth_instances { let transport_id = self.allocate_transport_id(); let eth = EthernetTransport::new(transport_id, name, eth_config, packet_tx.clone()); @@ -975,7 +929,7 @@ impl Node { // Create TCP transport instances let tcp_instances: Vec<_> = self - .config + .config() .transports .tcp .iter() @@ -990,7 +944,7 @@ impl Node { // Create Tor transport instances let tor_instances: Vec<_> = self - .config + .config() .transports .tor .iter() @@ -1007,7 +961,7 @@ impl Node { #[cfg(bluer_available)] { let ble_instances: Vec<_> = self - .config + .config() .transports .ble .iter() @@ -1149,12 +1103,17 @@ impl Node { /// Get this node's NodeAddr. pub fn node_addr(&self) -> &NodeAddr { - self.identity.node_addr() + self.context.identity.node_addr() } /// Get this node's npub. pub fn npub(&self) -> String { - self.identity.npub() + self.context.identity.npub() + } + + /// Get this node's startup epoch (random per-boot tag for restart detection). + pub fn startup_epoch(&self) -> [u8; 8] { + self.context.startup_epoch } /// Reload the host map if the backing `/etc/fips/hosts` file changed. @@ -1197,23 +1156,16 @@ impl Node { self.context.config.as_ref() } - /// Rebuild the shared [`context::NodeContext`] from the current `Node` - /// fields. Called after any mutation of a bundled field (`update_peers`, - /// the `set_max_*` setters) so `self.context` stays equal to the `Node` - /// fields it mirrors. Cheap — the only deep copy is the (rare) `Config` - /// clone. - fn rebuild_context(&mut self) { - self.context = Arc::new(context::NodeContext::new( - Arc::new(self.config.clone()), - self.identity.clone(), - self.startup_epoch, - self.started_at, - self.is_leaf_only, - self.node_profile, - self.max_connections, - self.max_peers, - self.max_links, - )); + /// Mutate the shared immutable context by building a fresh + /// [`context::NodeContext`] and swapping the whole `Arc`. The per-instance + /// context is never interior-mutated; this clone-edit-swap is the sole + /// runtime mutation path for the bundle (the constructors, + /// [`leaf_only`](Self::leaf_only), and [`update_peers`](Self::update_peers)). + /// Cheap — the only deep copy is the (rare) `Config` clone behind its `Arc`. + fn replace_context(&mut self, f: impl FnOnce(&mut context::NodeContext)) { + let mut ctx = (*self.context).clone(); + f(&mut ctx); + self.context = Arc::new(ctx); } /// Calculate the effective IPv6 MTU that can be sent over FIPS. @@ -1252,7 +1204,7 @@ impl Node { return mtu; } // Fallback to config: try UDP first, then Ethernet - if let Some((_, cfg)) = self.config.transports.udp.iter().next() { + if let Some((_, cfg)) = self.config().transports.udp.iter().next() { return cfg.mtu(); } 1280 @@ -1337,7 +1289,7 @@ impl Node { let parent_id = *self.tree_state.my_declaration().parent_id(); let is_root = self.tree_state.is_root(); - let max_fpr = self.config.node.bloom.max_inbound_fpr; + let max_fpr = self.config().node.bloom.max_inbound_fpr; let mut total: f64 = 1.0; // count self let mut child_count: u32 = 0; let mut has_data = false; @@ -1401,7 +1353,7 @@ impl Node { None => true, Some(last) => { now.duration_since(last) - >= std::time::Duration::from_secs(self.config.node.mmp.log_interval_secs) + >= std::time::Duration::from_secs(self.config().node.mmp.log_interval_secs) } }; if should_log { @@ -1522,16 +1474,19 @@ impl Node { // === Resource Limits === - /// Set the maximum number of connections (handshake phase). - pub fn set_max_connections(&mut self, max: usize) { - self.max_connections = max; - self.rebuild_context(); + /// Maximum connections (handshake phase); 0 = unlimited. + pub fn max_connections(&self) -> usize { + self.context.max_connections } - /// Set the maximum number of peers (authenticated). - pub fn set_max_peers(&mut self, max: usize) { - self.max_peers = max; - self.rebuild_context(); + /// Maximum authenticated peers; 0 = unlimited. + pub fn max_peers(&self) -> usize { + self.context.max_peers + } + + /// Maximum links; 0 = unlimited. + pub fn max_links(&self) -> usize { + self.context.max_links } /// Returns false when we are at or above the configured `max_peers` @@ -1543,13 +1498,8 @@ impl Node { /// Nostr-mediated NAT-traversal punch) from doing pointless work /// when saturated. pub(crate) fn outbound_admission_check(&self) -> bool { - self.max_peers == 0 || self.peers.len() < self.max_peers - } - - /// Set the maximum number of links. - pub fn set_max_links(&mut self, max: usize) { - self.max_links = max; - self.rebuild_context(); + let max_peers = self.context.max_peers; + max_peers == 0 || self.peers.len() < max_peers } // === Counts === @@ -1614,9 +1564,9 @@ impl Node { /// Add a link. pub fn add_link(&mut self, link: Link) -> Result<(), NodeError> { - if self.max_links > 0 && self.links.len() >= self.max_links { + if self.max_links() > 0 && self.links.len() >= self.max_links() { return Err(NodeError::MaxLinksExceeded { - max: self.max_links, + max: self.max_links(), }); } let link_id = link.link_id(); @@ -1720,9 +1670,9 @@ impl Node { return Err(NodeError::ConnectionAlreadyExists(link_id)); } - if self.max_connections > 0 && self.connections.len() >= self.max_connections { + if self.max_connections() > 0 && self.connections.len() >= self.max_connections() { return Err(NodeError::MaxConnectionsExceeded { - max: self.max_connections, + max: self.max_connections(), }); } @@ -1857,7 +1807,7 @@ impl Node { self.identity_cache .insert(prefix, (node_addr, pubkey, Self::now_ms())); // LRU eviction - let max = self.config.node.cache.identity_size; + let max = self.config().node.cache.identity_size; if self.identity_cache.len() > max && let Some(oldest_key) = self .identity_cache @@ -1908,7 +1858,7 @@ impl Node { /// Configured maximum identity cache size. pub fn identity_cache_max(&self) -> usize { - self.config.node.cache.identity_size + self.config().node.cache.identity_size } /// Number of pending discovery lookups. @@ -2389,7 +2339,7 @@ impl fmt::Debug for Node { f.debug_struct("Node") .field("node_addr", self.node_addr()) .field("state", &self.state) - .field("is_leaf_only", &self.is_leaf_only) + .field("is_leaf_only", &self.is_leaf_only()) .field("connections", &self.connection_count()) .field("peers", &self.peer_count()) .field("links", &self.link_count()) diff --git a/src/node/retry.rs b/src/node/retry.rs index db5bf0d..720bed5 100644 --- a/src/node/retry.rs +++ b/src/node/retry.rs @@ -235,7 +235,7 @@ impl Node { if !self.outbound_admission_check() { debug!( peers = self.peers.len(), - max_peers = self.max_peers, + max_peers = self.max_peers(), retry_pending = self.retry_pending.len(), "Suppressing auto-reconnect retries: at capacity" ); diff --git a/src/node/tests/acl.rs b/src/node/tests/acl.rs index 9b64841..8a019b3 100644 --- a/src/node/tests/acl.rs +++ b/src/node/tests/acl.rs @@ -53,13 +53,13 @@ async fn test_outbound_msg2_denied_after_acl_reload() { let node_b = make_node(); let transport_id = TransportId::new(1); let remote_addr = TransportAddr::from_string("127.0.0.1:5001"); - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let link_id_a = node_a.allocate_link_id(); let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); let noise_msg1 = conn_a - .start_handshake(node_a.identity.keypair(), node_a.startup_epoch, 1000) + .start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000) .unwrap(); conn_a.set_our_index(our_index_a); conn_a.set_transport_id(transport_id); @@ -85,7 +85,7 @@ async fn test_outbound_msg2_denied_after_acl_reload() { let responder_epoch = [0x11; 8]; let noise_msg2 = conn_b .receive_handshake_init( - node_b.identity.keypair(), + node_b.identity().keypair(), responder_epoch, &noise_msg1, None, @@ -173,12 +173,12 @@ async fn test_inbound_msg3_denied_triggers_disconnect() { // === A initiates the handshake === - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let link_id_a = node_a.allocate_link_id(); let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); let noise_msg1 = conn_a - .start_handshake(node_a.identity.keypair(), node_a.startup_epoch, 1000) + .start_handshake(node_a.identity().keypair(), node_a.startup_epoch(), 1000) .unwrap(); conn_a.set_our_index(our_index_a); conn_a.set_transport_id(transport_id_a); diff --git a/src/node/tests/bootstrap.rs b/src/node/tests/bootstrap.rs index 17cfe05..04fba2f 100644 --- a/src/node/tests/bootstrap.rs +++ b/src/node/tests/bootstrap.rs @@ -78,9 +78,9 @@ async fn test_adopted_udp_traversal_completes_handshake() { node_b.handle_msg3(pkt_at_b).await; let peer_a_node_addr = - *PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()).node_addr(); + *PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr(); let peer_b_node_addr = - *PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()).node_addr(); + *PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()).node_addr(); assert_eq!( node_a.peer_count(), @@ -248,7 +248,7 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() { assert_eq!(pkt_at_a.data[0] & 0x0f, PHASE_MSG3); node_a.handle_msg3(pkt_at_a).await; - let node_a_addr = *PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()).node_addr(); + let node_a_addr = *PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr(); assert!( node_b.get_peer(&node_a_addr).is_some(), "node_b should first be connected to node_a via adopted transport" @@ -262,7 +262,7 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() { .transports .insert(transport_id_c, TransportHandle::Udp(transport_c)); - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let adopted_addr = TransportAddr::from_string(&handoff_result.local_addr.to_string()); node_c .initiate_connection(transport_id_c, adopted_addr, Some(peer_b_identity)) @@ -314,7 +314,7 @@ async fn test_third_peer_can_handshake_via_adopted_transport_socket() { }; node_b.handle_msg3(pkt_at_b).await; - let node_c_addr = *PeerIdentity::from_pubkey_full(node_c.identity.pubkey_full()).node_addr(); + let node_c_addr = *PeerIdentity::from_pubkey_full(node_c.identity().pubkey_full()).node_addr(); assert!( node_b.get_peer(&node_c_addr).is_some(), "node_b should promote node_c when node_c handshakes via adopted socket" diff --git a/src/node/tests/discovery.rs b/src/node/tests/discovery.rs index 6fde3e5..61659b7 100644 --- a/src/node/tests/discovery.rs +++ b/src/node/tests/discovery.rs @@ -1170,7 +1170,7 @@ async fn test_check_pending_lookups_default_sequence_unreachable() { // Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test // cannot silently drift if the default changes. assert_eq!( - node.config.node.discovery.attempt_timeouts_secs, + node.config().node.discovery.attempt_timeouts_secs, vec![1, 2, 4, 8], "test pins the [1,2,4,8] default; update the test if the default changes" ); diff --git a/src/node/tests/handshake.rs b/src/node/tests/handshake.rs index 1c6507f..c0c1cab 100644 --- a/src/node/tests/handshake.rs +++ b/src/node/tests/handshake.rs @@ -1,6 +1,8 @@ //! Integration tests for end-to-end Noise XX handshake scenarios. -use super::spanning_tree::{cleanup_nodes, drain_all_packets, initiate_handshake, make_test_node}; +use super::spanning_tree::{ + cleanup_nodes, drain_all_packets, initiate_handshake, make_test_node_with_profile, +}; use super::*; #[tokio::test] @@ -50,7 +52,7 @@ async fn test_two_node_handshake_udp() { // === Phase 1: Node A initiates handshake to Node B === // Create peer identity for B (must use full key for ECDH parity) - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let peer_b_node_addr = *peer_b_identity.node_addr(); let link_id_a = node_a.allocate_link_id(); @@ -60,9 +62,9 @@ async fn test_two_node_handshake_udp() { let our_index_a = node_a.index_allocator.allocate().unwrap(); // Start handshake (generates Noise XX msg1) - let our_keypair_a = node_a.identity.keypair(); + let our_keypair_a = node_a.identity().keypair(); let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch, 1000) + .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) .unwrap(); conn_a.set_our_index(our_index_a); conn_a.set_transport_id(transport_id_a); @@ -101,7 +103,7 @@ async fn test_two_node_handshake_udp() { node_b.handle_msg1(packet_b).await; let peer_a_node_addr = - *PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()).node_addr(); + *PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()).node_addr(); // XX: B has NOT promoted yet (needs msg3) assert_eq!( @@ -313,16 +315,16 @@ async fn test_run_rx_loop_handshake() { // === Phase 1: Node A initiates handshake to Node B === - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let peer_b_node_addr = *peer_b_identity.node_addr(); let link_id_a = node_a.allocate_link_id(); let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity.keypair(); + let our_keypair_a = node_a.identity().keypair(); let noise_msg1 = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch, 1000) + .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) .unwrap(); conn_a.set_our_index(our_index_a); conn_a.set_transport_id(transport_id_a); @@ -494,9 +496,9 @@ async fn test_cross_connection_both_initiate() { .insert(transport_id_b, TransportHandle::Udp(transport_b)); // Peer identities (must use full key for ECDH parity) - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let peer_b_node_addr = *peer_b_identity.node_addr(); - let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity.pubkey_full()); + let peer_a_identity = PeerIdentity::from_pubkey_full(node_a.identity().pubkey_full()); let peer_a_node_addr = *peer_a_identity.node_addr(); // === Phase 1: Both nodes initiate handshakes (simulate auto_connect) === @@ -505,9 +507,9 @@ async fn test_cross_connection_both_initiate() { let link_id_a_out = node_a.allocate_link_id(); let mut conn_a = PeerConnection::outbound(link_id_a_out, peer_b_identity, 1000); let our_index_a = node_a.index_allocator.allocate().unwrap(); - let our_keypair_a = node_a.identity.keypair(); + let our_keypair_a = node_a.identity().keypair(); let noise_msg1_a = conn_a - .start_handshake(our_keypair_a, node_a.startup_epoch, 1000) + .start_handshake(our_keypair_a, node_a.startup_epoch(), 1000) .unwrap(); conn_a.set_our_index(our_index_a); conn_a.set_transport_id(transport_id_a); @@ -535,9 +537,9 @@ async fn test_cross_connection_both_initiate() { let link_id_b_out = node_b.allocate_link_id(); let mut conn_b = PeerConnection::outbound(link_id_b_out, peer_a_identity, 1000); let our_index_b = node_b.index_allocator.allocate().unwrap(); - let our_keypair_b = node_b.identity.keypair(); + let our_keypair_b = node_b.identity().keypair(); let noise_msg1_b = conn_b - .start_handshake(our_keypair_b, node_b.startup_epoch, 1000) + .start_handshake(our_keypair_b, node_b.startup_epoch(), 1000) .unwrap(); conn_b.set_our_index(our_index_b); conn_b.set_transport_id(transport_id_b); @@ -705,9 +707,9 @@ async fn test_stale_connection_cleanup() { // Allocate session index and set transport info let our_index = node.index_allocator.allocate().unwrap(); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let _noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch, past_time_ms) + .start_handshake(our_keypair, node.startup_epoch(), past_time_ms) .unwrap(); conn.set_our_index(our_index); conn.set_transport_id(transport_id); @@ -783,9 +785,9 @@ async fn test_failed_connection_cleanup() { let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let _noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch, now_ms) + .start_handshake(our_keypair, node.startup_epoch(), now_ms) .unwrap(); conn.set_our_index(our_index); conn.set_transport_id(transport_id); @@ -843,9 +845,9 @@ async fn test_msg1_stored_for_resend() { let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch, now_ms) + .start_handshake(our_keypair, node.startup_epoch(), now_ms) .unwrap(); conn.set_our_index(our_index); conn.set_transport_id(transport_id); @@ -853,7 +855,7 @@ async fn test_msg1_stored_for_resend() { // Build wire msg1 and store it (as initiate_peer_connection does) let wire_msg1 = build_msg1(our_index, &noise_msg1); - let resend_interval = node.config.node.rate_limit.handshake_resend_interval_ms; + let resend_interval = node.config().node.rate_limit.handshake_resend_interval_ms; conn.set_handshake_msg1(wire_msg1.clone(), now_ms + resend_interval); // Verify stored msg1 matches what was built @@ -876,9 +878,9 @@ async fn test_resend_scheduling() { let mut conn = PeerConnection::outbound(link_id, peer_identity, now_ms); let our_index = node.index_allocator.allocate().unwrap(); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let noise_msg1 = conn - .start_handshake(our_keypair, node.startup_epoch, now_ms) + .start_handshake(our_keypair, node.startup_epoch(), now_ms) .unwrap(); conn.set_our_index(our_index); conn.set_transport_id(transport_id); @@ -999,9 +1001,10 @@ async fn attempt_profile_handshake( profile_a: crate::protocol::NodeProfile, profile_b: crate::protocol::NodeProfile, ) -> (usize, usize) { - let mut nodes = vec![make_test_node().await, make_test_node().await]; - nodes[0].node.node_profile = profile_a; - nodes[1].node.node_profile = profile_b; + let mut nodes = vec![ + make_test_node_with_profile(profile_a).await, + make_test_node_with_profile(profile_b).await, + ]; initiate_handshake(&mut nodes, 0, 1).await; drain_all_packets(&mut nodes, false).await; diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index e89fa1c..57394b1 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -28,14 +28,29 @@ pub(super) fn make_node() -> Node { make_node_with(Config::new()) } -/// Build a test node from an explicit `Config`. Prefer this over poking -/// `node.config.*` after construction: immutable fields are mirrored into the -/// shared `NodeContext` at build time, so a post-construction field poke is -/// invisible to any reader that has migrated onto the `config()` accessor. +/// Build a test node from an explicit `Config`. Immutable state lives solely in +/// the shared `NodeContext`, built once at construction — there is no +/// post-construction field to poke, so set limits/config on the `Config` here. pub(super) fn make_node_with(config: Config) -> Node { Node::new(config).unwrap() } +/// Build a test node with an explicit `max_peers` limit (replaces the removed +/// `set_max_peers` setter; resource limits are immutable post-construction). +pub(super) fn make_node_with_max_peers(max_peers: usize) -> Node { + let mut config = Config::new(); + config.node.limits.max_peers = max_peers; + make_node_with(config) +} + +/// Build a test node with an explicit `max_links` limit (replaces the removed +/// `set_max_links` setter; resource limits are immutable post-construction). +pub(super) fn make_node_with_max_links(max_links: usize) -> Node { + let mut config = Config::new(); + config.node.limits.max_links = max_links; + make_node_with(config) +} + #[allow(dead_code)] pub(super) fn make_node_addr(val: u8) -> NodeAddr { let mut bytes = [0u8; 16]; @@ -66,9 +81,9 @@ pub(super) fn make_completed_connection( let mut conn = PeerConnection::outbound(link_id, peer_identity, current_time_ms); // Run initiator side of handshake - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let msg1 = conn - .start_handshake(our_keypair, node.startup_epoch, current_time_ms) + .start_handshake(our_keypair, node.startup_epoch(), current_time_ms) .unwrap(); // Run responder side to generate msg2 diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 53a6d0f..e1d0668 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -1628,8 +1628,9 @@ fn test_coords_warmup_config_default() { #[test] fn test_identity_cache_lru_eviction() { - let mut node = make_node(); - node.config.node.cache.identity_size = 2; + let mut config = crate::Config::new(); + config.node.cache.identity_size = 2; + let mut node = make_node_with(config); let id1 = Identity::generate(); let id2 = Identity::generate(); @@ -1791,7 +1792,7 @@ async fn test_session_handshake_timeout() { let mut node = make_node(); let identity_b = Identity::generate(); - let handshake = HandshakeState::new_initiator(node.identity.keypair()); + let handshake = HandshakeState::new_initiator(node.identity().keypair()); let dest_addr = *identity_b.node_addr(); @@ -1808,7 +1809,7 @@ async fn test_session_handshake_timeout() { assert!(node.sessions.contains_key(&dest_addr)); // Before timeout: session should remain - let timeout_secs = node.config.node.rate_limit.handshake_timeout_secs; + let timeout_secs = node.config().node.rate_limit.handshake_timeout_secs; let before_timeout = 1000 + timeout_secs * 1000 - 1; node.resend_pending_session_handshakes(before_timeout).await; assert!( @@ -1852,7 +1853,7 @@ async fn test_session_awaiting_msg3_timeout() { assert!(node.sessions.contains_key(&src_addr)); // After timeout: session should be removed - let timeout_secs = node.config.node.rate_limit.handshake_timeout_secs; + let timeout_secs = node.config().node.rate_limit.handshake_timeout_secs; let after_timeout = 1000 + timeout_secs * 1000 + 1; node.resend_pending_session_handshakes(after_timeout).await; assert!( @@ -2298,7 +2299,7 @@ fn install_established_session_with_mmp(node: &mut Node, remote: &Identity) { 1000, true, ); - entry.init_mmp(&node.config.node.session_mmp); + entry.init_mmp(&node.config().node.session_mmp); node.sessions.insert(remote_addr, entry); } diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 785185f..2b96696 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -30,10 +30,29 @@ pub(super) async fn make_test_node() -> TestNode { /// Create a test node with a specific transport MTU. pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode { + make_test_node_inner(Config::new(), mtu).await +} + +/// Create a test node with a specific routing profile. Profile is immutable +/// (lives in the shared context), so it is set via the `Config` flags that +/// `Config::node_profile()` reads rather than poked post-construction. +pub(super) async fn make_test_node_with_profile(profile: crate::protocol::NodeProfile) -> TestNode { + use crate::protocol::NodeProfile; + let mut config = Config::new(); + match profile { + NodeProfile::Leaf => config.node.leaf_only = true, + NodeProfile::NonRouting => config.node.disable_routing = true, + NodeProfile::Full => {} + } + make_test_node_inner(config, 1280).await +} + +/// Shared builder: a test node from an explicit `Config` and transport MTU. +async fn make_test_node_inner(config: Config, mtu: u16) -> TestNode { use crate::config::UdpConfig; use crate::transport::udp::UdpTransport; - let mut node = make_node(); + let mut node = make_node_with(config); let transport_id = TransportId::new(1); // recv_buf_size and packet_channel are sized for large-network harness @@ -90,7 +109,7 @@ pub(super) async fn initiate_handshake(nodes: &mut [TestNode], i: usize, j: usiz let our_index = initiator.node.index_allocator.allocate().unwrap(); let our_keypair = initiator.node.identity().keypair(); let noise_msg1 = conn - .start_handshake(our_keypair, initiator.node.startup_epoch, 1000) + .start_handshake(our_keypair, initiator.node.startup_epoch(), 1000) .unwrap(); conn.set_our_index(our_index); conn.set_transport_id(transport_id); diff --git a/src/node/tests/unit.rs b/src/node/tests/unit.rs index c513021..0f92873 100644 --- a/src/node/tests/unit.rs +++ b/src/node/tests/unit.rs @@ -250,8 +250,7 @@ fn test_node_link_management() { #[test] fn test_node_link_limit() { - let mut node = make_node(); - node.set_max_links(2); + let mut node = make_node_with_max_links(2); for i in 0..2 { let link_id = node.allocate_link_id(); @@ -383,9 +382,8 @@ fn test_node_cross_connection_resolution() { #[test] fn test_node_peer_limit() { - let mut node = make_node(); + let mut node = make_node_with_max_peers(2); let transport_id = TransportId::new(1); - node.set_max_peers(2); // Add two peers via promotion for i in 0..2 { @@ -601,9 +599,9 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { let mut pending_conn = PeerConnection::outbound(pending_link_id, peer_b_identity, pending_time_ms); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let _msg1 = pending_conn - .start_handshake(our_keypair, node.startup_epoch, pending_time_ms) + .start_handshake(our_keypair, node.startup_epoch(), pending_time_ms) .unwrap(); let pending_index = node.index_allocator.allocate().unwrap(); @@ -640,9 +638,9 @@ fn test_promote_cleans_up_pending_outbound_to_same_peer() { let mut completing_conn = PeerConnection::outbound(completing_link_id, peer_b_identity, completing_time_ms); - let our_keypair = node.identity.keypair(); + let our_keypair = node.identity().keypair(); let msg1 = completing_conn - .start_handshake(our_keypair, node.startup_epoch, completing_time_ms) + .start_handshake(our_keypair, node.startup_epoch(), completing_time_ms) .unwrap(); // B responds @@ -989,7 +987,7 @@ fn active_peer_same_path_discovery_refreshes_stale_peer() { let transport_id = TransportId::new(1); let current_addr = TransportAddr::from_string("127.0.0.1:9"); let stale_at = Node::now_ms().saturating_sub( - node.config + node.config() .node .heartbeat_interval_secs .saturating_add(1) @@ -1040,7 +1038,20 @@ async fn node_context_mirrors_config_and_immutable_facades() { #[tokio::test] async fn update_peers_races_new_alternative_without_dropping_active_peer() { - let mut node = make_node(); + // The node's *current* (pre-update) peer set must contain `old_peer`, so it + // is baked into the Config at construction (immutable context = sole store). + let peer_full = Identity::generate(); + let old_peer = crate::config::PeerConfig { + npub: peer_full.npub(), + alias: None, + addresses: vec![crate::config::PeerAddress::new("udp", "127.0.0.1:9")], + connect_policy: crate::config::ConnectPolicy::AutoConnect, + auto_reconnect: true, + via_nostr: false, + }; + let mut config = Config::new(); + config.peers = vec![old_peer.clone()]; + let mut node = make_node_with(config); let (packet_tx, packet_rx) = packet_channel(64); node.packet_tx = Some(packet_tx.clone()); node.packet_rx = Some(packet_rx); @@ -1059,7 +1070,6 @@ async fn update_peers_races_new_alternative_without_dropping_active_peer() { node.transports .insert(transport_id, TransportHandle::Udp(udp)); - let peer_full = Identity::generate(); let peer_identity = PeerIdentity::from_pubkey_full(peer_full.pubkey_full()); let peer_node_addr = *peer_identity.node_addr(); let current_addr = TransportAddr::from_string("127.0.0.1:9"); @@ -1079,14 +1089,6 @@ async fn update_peers_races_new_alternative_without_dropping_active_peer() { ), ); - let old_peer = crate::config::PeerConfig { - npub: peer_full.npub(), - alias: None, - addresses: vec![crate::config::PeerAddress::new("udp", "127.0.0.1:9")], - connect_policy: crate::config::ConnectPolicy::AutoConnect, - auto_reconnect: true, - via_nostr: false, - }; let new_peer = crate::config::PeerConfig { addresses: vec![ crate::config::PeerAddress::new("udp", "127.0.0.1:9"), @@ -1094,7 +1096,6 @@ async fn update_peers_races_new_alternative_without_dropping_active_peer() { ], ..old_peer.clone() }; - node.config.peers = vec![old_peer]; let outcome = node.update_peers(vec![new_peer]).await.unwrap(); @@ -1260,8 +1261,8 @@ fn test_schedule_reconnect_preserves_backoff() { ); // With count=3, backoff should be 5s * 2^3 = 40s. - let base_ms = node.config.node.retry.base_interval_secs * 1000; - let max_ms = node.config.node.retry.max_backoff_secs * 1000; + let base_ms = node.config().node.retry.base_interval_secs * 1000; + let max_ms = node.config().node.retry.max_backoff_secs * 1000; let expected_delay = state.backoff_ms(base_ms, max_ms); assert_eq!( state.retry_after_ms, @@ -1296,8 +1297,8 @@ fn test_schedule_reconnect_fresh_state() { "Fresh reconnect should start at count=0" ); // Base delay: 5s * 2^0 = 5s - let base_ms = node.config.node.retry.base_interval_secs * 1000; - let max_ms = node.config.node.retry.max_backoff_secs * 1000; + let base_ms = node.config().node.retry.base_interval_secs * 1000; + let max_ms = node.config().node.retry.max_backoff_secs * 1000; let expected_delay = state.backoff_ms(base_ms, max_ms); assert_eq!(state.retry_after_ms, 1_000 + expected_delay); } @@ -1628,8 +1629,7 @@ fn inject_dummy_peers(node: &mut Node, count: usize) { #[test] fn outbound_admission_check_direct() { // max_peers cap honored: above-cap returns false, below-cap returns true. - let mut node = make_node(); - node.set_max_peers(3); + let mut node = make_node_with_max_peers(3); assert!(node.outbound_admission_check(), "0/3 should be admissible"); inject_dummy_peers(&mut node, 2); @@ -1646,8 +1646,7 @@ fn outbound_admission_check_direct() { ); // No-cap sentinel: max_peers == 0 admits unconditionally. - let mut uncapped = make_node(); - uncapped.set_max_peers(0); + let mut uncapped = make_node_with_max_peers(0); assert!(uncapped.outbound_admission_check()); inject_dummy_peers(&mut uncapped, 50); assert!( @@ -1658,8 +1657,7 @@ fn outbound_admission_check_direct() { #[tokio::test] async fn process_pending_retries_gated_at_capacity() { - let mut node = make_node(); - node.set_max_peers(2); + let mut node = make_node_with_max_peers(2); inject_dummy_peers(&mut node, 2); // Queue a retry that would otherwise be due. @@ -1717,8 +1715,7 @@ async fn poll_nostr_discovery_established_gated_at_capacity() { use crate::discovery::EstablishedTraversal; use std::net::UdpSocket; - let mut node = make_node(); - node.set_max_peers(2); + let mut node = make_node_with_max_peers(2); inject_dummy_peers(&mut node, 2); let bootstrap = Arc::new(NostrDiscovery::new_for_test()); @@ -1797,7 +1794,7 @@ async fn craft_and_send_msg1( use crate::node::wire::build_msg1; use crate::utils::index::SessionIndex; - let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full()); + let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity().pubkey_full()); let sender_pubkey_id = PeerIdentity::from_pubkey_full(sender_identity.pubkey_full()); let sender_node_addr = *sender_pubkey_id.node_addr(); @@ -1859,8 +1856,7 @@ async fn handle_msg1_silent_drops_at_cap_for_new_peer() { use crate::config::UdpConfig; use tokio::time::{Duration, timeout}; - let mut node = make_node(); - node.set_max_peers(2); + let mut node = make_node_with_max_peers(2); inject_dummy_peers(&mut node, 2); assert_eq!(node.peer_count(), 2, "precondition: at cap"); @@ -1941,8 +1937,7 @@ async fn handle_msg1_silent_drops_at_cap_for_new_peer() { async fn handle_msg1_admits_existing_peer_at_cap() { use crate::config::UdpConfig; - let mut node = make_node(); - node.set_max_peers(2); + let mut node = make_node_with_max_peers(2); inject_dummy_peers(&mut node, 1); diff --git a/src/node/tree.rs b/src/node/tree.rs index 7aa284c..6cf862c 100644 --- a/src/node/tree.rs +++ b/src/node/tree.rs @@ -37,7 +37,7 @@ impl Node { &mut self, peer_addr: &NodeAddr, ) -> Result<(), NodeError> { - if self.node_profile == crate::protocol::NodeProfile::Leaf { + if self.node_profile() == crate::protocol::NodeProfile::Leaf { return Ok(()); } let now_ms = std::time::SystemTime::now() diff --git a/testing/ci-local.sh b/testing/ci-local.sh index 9263a88..6e57486 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -233,6 +233,21 @@ run_build() { record "clippy" 1 return 1 fi + + # Guard: the effectively-immutable state lives solely in NodeContext. The + # Node struct must not re-declare a bundled field (config/identity/ + # startup_epoch/started_at/is_leaf_only/node_profile/max_*) — a shadow field + # would silently reopen dual-store divergence between the struct and the + # context. Checks the struct *declaration*, so it is wrap-insensitive. + info "node-context single-store guard" + if awk '/^pub struct Node \{/,/^\}/' src/node/mod.rs \ + | grep -qE '^[[:space:]]+(config|identity|startup_epoch|started_at|is_leaf_only|node_profile|max_connections|max_peers|max_links):'; then + fail "Node struct re-declares a bundled immutable field; it must live solely in NodeContext" + record "node-context-guard" 1 + return 1 + else + record "node-context-guard" 0 + fi } # ── Stage 2: Unit Tests ───────────────────────────────────────────────────