bacnet_network/router/mod.rs
1//! BACnet half-router — forwards APDUs between BACnet networks.
2//!
3//! A BACnet router (ASHRAE 135-2020 Clause 6.6) sits on several BACnet
4//! networks at once and relays messages among them, rewriting the NPDU
5//! source/destination fields and decrementing the hop count as it goes.
6//!
7//! This implementation supports:
8//! - Forwarding APDUs between directly-connected networks
9//! - Who-Is-Router-To-Network / I-Am-Router-To-Network messages
10//! - Reject-Message-To-Network for unknown routes
11//! - Learned routes from I-Am-Router-To-Network announcements
12//!
13//! Local application delivery follows the shared
14//! [receive-queue contract](crate::layer#receive-queue-admission), independently
15//! of forwarding and inline network-message handling.
16//!
17//! # Routing-claim trust model (RB-06)
18//!
19//! Classic BACnet routing claims are unauthenticated: an ingress port, next-hop
20//! MAC or advertised network is not proof that a peer is authorized to control
21//! that route. These local mitigations do not prevent route poisoning or
22//! authenticate a claim (Clauses 6.4 and 6.6.3):
23//! - Direct routes cannot be overwritten by learning, changed by rejects, or
24//! changed by Initialize-Routing-Table management updates.
25//! - Standard convergence applies 6.6.3.2 last-wins immediately: each new
26//! I-Am-Router / Init-ACK advertisement updates routing information, with
27//! flap warnings retained. Same-port refreshes and absent learning stay
28//! immediate; I-Could-Be-Router remains absent-only.
29//! - Hardened convergence (`RouterTable::new_hardened`, explicit opt-in only)
30//! holds cross-port learned moves for a second same-(network, port) claim
31//! within 60s inclusive. Repeats in separate messages suffice; duplicates in
32//! one message cannot corroborate. The old route forwards while pending.
33//! Alternation resets the slot; single-shot advertisers need the 60s reaper
34//! plus a fresh claim. Corroboration is dampening, not identity assurance.
35//! - Initialize-Routing-Table updates are management writes (6.4.7/6.6.3.8):
36//! nonzero Port ID replaces/appends the learned entry, Port ID 0 purges it,
37//! immediately in wire order. They never create/alter direct routes. Wire
38//! controls pass [`control_policy::ControlGate`] (RB-09); direct immunity
39//! stays safety scoping, not auth. LOCAL table calls bypass policy.
40//! - A query (Number of Ports 0) never mutates and answers with the complete
41//! table in ascending-DNET bounded portions (6.6.3.9); wire Port IDs are
42//! `port_index + 1` (0 stays the purge trigger).
43//! - Hardened pending holds one challenger per learned network (bounded by live
44//! learned routes). Slots expire lazily on the next claim plus the 60s aging
45//! reaper; apply/refresh/removal/aging/direct/manual edits clear the slot.
46//! - Freshness is split: `last_seen` (control confirmation) drives 300s expiry;
47//! `last_used` (forwarding use) never extends it. Busy/Available touch
48//! neither. Traffic no longer pins a dead route past the rescue.
49//! - Unknown DNETs solicit once per 5s per DNET (coalesced, max 256 pending,
50//! 30s entry age, reserved never solicited) then fail the caller with
51//! NOT_DIRECTLY_CONNECTED (6.6.3.1/6.5 retryable); packets are never buffered.
52//! `stop` cancels pending discovery.
53//! - Disconnect never removes routes (PTP unimplemented). Reject transitions
54//! are dampened per (ingress port, network) with a 30s hold-down; no-op
55//! rejects never extend busy deadlines. [`RouterTable::claim_snapshot`]
56//! stays count-only saturating and never affects decisions.
57
58mod local_delivery;
59
60use std::collections::HashMap;
61use std::sync::atomic::{AtomicU64, Ordering};
62use std::sync::Arc;
63use std::time::{Duration, Instant};
64
65use bacnet_encoding::npdu::{encode_npdu, Npdu, NpduAddress};
66use bacnet_transport::port::{DataAttribute, TransportPort};
67use bacnet_types::enums::NetworkMessageType;
68use bacnet_types::error::Error;
69use bacnet_types::MacAddr;
70use bytes::{BufMut, Bytes, BytesMut};
71use tokio::sync::{mpsc, Mutex};
72use tokio::task::JoinHandle;
73use tracing::{debug, warn};
74
75use crate::layer::{AdmissionReceiver, QueueAdmissionCounters, ReceivedApdu};
76use crate::layer::{AdmissionSender, ReceivedNetworkControl};
77use crate::router_table::RouterTable;
78use bacnet_transport::port::TransportProvenance;
79
80mod control_messages;
81pub mod control_policy;
82mod dispatch;
83mod forwarding;
84mod local_control;
85mod options;
86mod reject;
87
88pub use control_policy::{ControlAuthContext, ControlAuthorizer, ControlClass};
89pub use control_policy::{ControlDecisionCounters, ControlGate, ControlPolicy};
90pub use control_policy::{ControlServiceCounters, ControlTrust};
91use dispatch::PortDispatch;
92use local_control::{LocalControl, OwnAddresses};
93pub use options::{LocalApduReceiver, NetworkControlReceiver, RouterOptions, StartedRouter};
94
95/// A send request to be forwarded on a port.
96#[derive(Debug)]
97enum SendRequest {
98 Unicast {
99 npdu: Bytes,
100 mac: MacAddr,
101 data_attributes: Vec<DataAttribute>,
102 },
103 Broadcast {
104 npdu: Bytes,
105 data_attributes: Vec<DataAttribute>,
106 },
107}
108
109impl SendRequest {
110 fn broadcast(npdu: Bytes) -> Self {
111 Self::broadcast_with_attributes(npdu, &[])
112 }
113
114 /// Ingress-triggered unicast: carries the ingress data attributes instead
115 /// of silently dropping them (RB-03). Locally-originated messages with no
116 /// ingress attributes pass an empty slice.
117 fn unicast_with_attributes(
118 npdu: Bytes,
119 mac: MacAddr,
120 data_attributes: &[DataAttribute],
121 ) -> Self {
122 Self::Unicast {
123 npdu,
124 mac,
125 data_attributes: data_attributes.to_vec(),
126 }
127 }
128
129 /// Ingress-triggered broadcast: carries the ingress data attributes
130 /// instead of silently dropping them (RB-03). Locally-originated
131 /// messages with no ingress attributes use [`Self::broadcast`].
132 fn broadcast_with_attributes(npdu: Bytes, data_attributes: &[DataAttribute]) -> Self {
133 Self::Broadcast {
134 npdu,
135 data_attributes: data_attributes.to_vec(),
136 }
137 }
138}
139
140/// Bounded unknown-destination discovery (RB-06, Clauses 6.5/6.6.3.1).
141///
142/// Per-DNET single inflight Who-Is solicitation: simultaneous unknowns coalesce
143/// to one broadcast, rate-limited to one per DNET per 5s, entries expire after
144/// 30s via the aging reaper, at most 256 pending DNETs, reserved never
145/// solicited. The caller still fails with NOT_DIRECTLY_CONNECTED (honest
146/// retryable); packets are never buffered and no per-packet retries occur.
147/// `cancel` (via [`BACnetRouter::stop`]) drops all pending discovery.
148const DISCOVERY_COOLDOWN: Duration = Duration::from_secs(5);
149const DISCOVERY_MAX_AGE: Duration = Duration::from_secs(30);
150const DISCOVERY_MAX_PENDING: usize = 256;
151
152#[derive(Debug, Default)]
153pub(crate) struct DiscoveryTracker {
154 last_solicited: HashMap<u16, Instant>,
155 cancelled: bool,
156}
157
158impl DiscoveryTracker {
159 /// Decide whether an unknown `dnet` should solicit now; records it if so.
160 pub(crate) fn should_solicit_at(&mut self, dnet: u16, now: Instant) -> bool {
161 if self.cancelled || dnet == 0 || dnet == 0xFFFF {
162 return false;
163 }
164 if let Some(last) = self.last_solicited.get(&dnet) {
165 if now.duration_since(*last) < DISCOVERY_COOLDOWN {
166 return false;
167 }
168 } else if self.last_solicited.len() >= DISCOVERY_MAX_PENDING {
169 return false;
170 }
171 self.last_solicited.insert(dnet, now);
172 true
173 }
174
175 /// Wall-clock wrapper for dispatch paths.
176 pub(crate) fn should_solicit(&mut self, dnet: u16) -> bool {
177 self.should_solicit_at(dnet, Instant::now())
178 }
179
180 /// Reap entries older than 30s; returns the reaped DNETs ascending.
181 pub(crate) fn expire_at(&mut self, now: Instant) -> Vec<u16> {
182 let mut out: Vec<u16> = self
183 .last_solicited
184 .iter()
185 .filter(|(_, last)| now.duration_since(**last) > DISCOVERY_MAX_AGE)
186 .map(|(net, _)| *net)
187 .collect();
188 out.sort_unstable();
189 for net in &out {
190 self.last_solicited.remove(net);
191 }
192 out
193 }
194
195 /// Cancel all pending discovery (router stop).
196 pub(crate) fn cancel(&mut self) {
197 self.cancelled = true;
198 self.last_solicited.clear();
199 }
200
201 #[cfg(test)]
202 pub(crate) fn len(&self) -> usize {
203 self.last_solicited.len()
204 }
205
206 #[cfg(test)]
207 pub(crate) fn is_cancelled(&self) -> bool {
208 self.cancelled
209 }
210}
211
212/// Immutable ingress facts carried to the network-message admission point
213/// ([`dispatch::dispatch_network_message`] / `handle_network_message`).
214///
215/// This is a plain record of what arrived, free of trust assertions: ingress port
216/// identity, the immediate link peer (`source_mac`), link-layer group flag,
217/// data-link attributes, and the decoded NPDU envelope (source/destination,
218/// hop count, message type, vendor ID). It asserts no provenance or trust —
219/// a claimed SNET/SADR is carried, never verified here.
220///
221/// RB-07 threads [`TransportProvenance`] here; RB-09 [`control_policy`]
222/// consumes it at the same admission point for protected-control decisions.
223#[derive(Debug, Clone)]
224pub(super) struct IngressContext {
225 /// Dispatch index of the ingress port.
226 pub port_idx: usize,
227 /// BACnet network number assigned to the ingress port.
228 pub port_network: u16,
229 /// Immediate link peer that sent the frame (SA), not the routed origin.
230 pub source_mac: MacAddr,
231 /// Whether the frame arrived via a data-link multicast/broadcast.
232 pub link_layer_group: bool,
233 /// Data-link attributes supplied with the frame, if any.
234 pub data_attributes: Vec<DataAttribute>,
235 /// Honest transport + origin provenance, immutable by value (RB-07).
236 /// Threaded, never decided on here.
237 pub provenance: TransportProvenance,
238 /// Decoded NPDU, including envelope (source/destination, hop count,
239 /// message type, vendor ID) and control payload.
240 pub npdu: Npdu,
241}
242
243#[cfg(test)]
244impl IngressContext {
245 /// In-memory test ingress: link-broadcast delivery with no attributes.
246 pub(super) fn test_local(
247 port_idx: usize,
248 port_network: u16,
249 source_mac: &[u8],
250 npdu: Npdu,
251 ) -> Self {
252 Self {
253 port_idx,
254 port_network,
255 source_mac: MacAddr::from_slice(source_mac),
256 link_layer_group: true,
257 data_attributes: Vec::new(),
258 provenance: TransportProvenance::unverified(),
259 npdu,
260 }
261 }
262
263 /// In-memory test ingress with explicit provenance (RB-07).
264 #[cfg(test)]
265 pub(super) fn test_local_with_provenance(
266 port_idx: usize,
267 port_network: u16,
268 source_mac: &[u8],
269 npdu: Npdu,
270 provenance: TransportProvenance,
271 ) -> Self {
272 Self {
273 port_idx,
274 port_network,
275 source_mac: MacAddr::from_slice(source_mac),
276 link_layer_group: true,
277 data_attributes: Vec::new(),
278 provenance,
279 npdu,
280 }
281 }
282}
283
284/// A router port: a transport bound to a specific BACnet network number.
285pub struct RouterPort<T: TransportPort> {
286 /// The transport for this port.
287 pub transport: T,
288 /// The network number assigned to this port.
289 pub network_number: u16,
290}
291
292/// BACnet router connecting multiple networks.
293///
294/// The router holds multiple ports, each bound to a different BACnet network.
295/// When an NPDU arrives on one port with a destination network that maps to
296/// another port, the router forwards the message.
297/// See the [receive-queue contract](crate::layer#receive-queue-admission) for
298/// raw/tracked local receivers, admission limits, counters and lifecycle.
299pub struct BACnetRouter {
300 /// Shared routing table.
301 table: Arc<Mutex<RouterTable>>,
302 /// Bounded unknown-destination discovery (per-DNET coalescing).
303 discovery: Arc<Mutex<DiscoveryTracker>>,
304 /// RB-09 wire-control gate (permissive default, hardened opt-in).
305 control: Arc<control_policy::ControlGate>,
306 /// Dispatch tasks (one per port).
307 dispatch_tasks: Vec<JoinHandle<()>>,
308 /// Sender tasks (one per port, owns the transport for outgoing messages).
309 sender_tasks: Vec<JoinHandle<()>>,
310 /// Background task that purges stale learned routes.
311 aging_task: Option<JoinHandle<()>>,
312 /// NPDUs refused for a link-layer source MAC, DLEN or SLEN past
313 /// `NpduAddress::MAX_MAC_LEN`.
314 address_length_drops: Arc<AtomicU64>,
315 /// NPDUs dropped for a DADR beside DNET 0xFFFF (#1379).
316 global_broadcast_dadr_drops: Arc<AtomicU64>,
317 /// Broadcast NPDUs dropped for an APDU other than an
318 /// Unconfirmed-Request (#1491).
319 broadcast_pdu_type_drops: Arc<AtomicU64>,
320 /// Last sequence given to a control for the router's own consumer.
321 network_control_ingress_sequence: Arc<AtomicU64>,
322}
323
324impl BACnetRouter {
325 /// Create and start a router from a list of ports, set up as `options`
326 /// asks (#1220).
327 ///
328 /// Returns the router with its local APDU receiver, for messages without
329 /// a remote destination or for which this router is the final hop, plus
330 /// the network-control receiver when the options ask for it.
331 /// [`RouterOptions::new`] is the plain router; see [`RouterOptions`] for
332 /// the tracked APDU receiver, the wire-control policy and the
333 /// network-control receiver, which combine freely.
334 ///
335 /// Fails if two ports share a network number or a transport fails to
336 /// start.
337 pub async fn start<T, A, C>(
338 ports: Vec<RouterPort<T>>,
339 options: RouterOptions<A, C>,
340 ) -> Result<StartedRouter<A, C>, Error>
341 where
342 T: TransportPort + 'static,
343 A: LocalApduReceiver,
344 C: NetworkControlReceiver,
345 {
346 let (control_tx, network_control) = if options.wants_network_control() {
347 let (tx, rx, counters) = AdmissionReceiver::channel(C::TRACKED);
348 (Some(tx), Some(C::from_queue(rx, counters)))
349 } else {
350 (None, None)
351 };
352 let gate = Arc::new(options.control_gate());
353 let (router, rx, counters) =
354 Self::start_dispatch(ports, A::TRACKED, gate, control_tx).await?;
355 Ok(StartedRouter {
356 router,
357 apdus: A::from_queue(rx, counters),
358 network_control,
359 })
360 }
361
362 /// Count-only RB-09 decision totals (allow/deny/policy-deny per class).
363 pub fn control_snapshot(&self) -> control_policy::ControlDecisionCounters {
364 self.control.snapshot()
365 }
366
367 /// NPDUs refused on any port since start because an address in them was
368 /// past [`NpduAddress::MAX_MAC_LEN`](bacnet_encoding::npdu::NpduAddress::MAX_MAC_LEN):
369 /// their DLEN or SLEN (#1141), or the link-layer source MAC the port's
370 /// transport reported (#1198). Saturates at `u64::MAX`.
371 ///
372 /// Such an NPDU is neither forwarded nor delivered locally, and a network
373 /// message in it changes no route. When its DLEN or SLEN is the long one
374 /// and it names a specific DNET, the router also answers the sender with
375 /// Reject-Message-To-Network reason 6, `ADDRESSING_ERROR` (Clause 6.4.4),
376 /// the way it rejects a DNET it cannot reach. A global broadcast, or an
377 /// NPDU without a DNET, is only dropped. So is a frame from an over-long
378 /// link-layer source MAC: that is a fault in the transport, not in the
379 /// NPDU, and the router sends nothing back to such a MAC. No built-in
380 /// transport reports one.
381 pub fn address_length_drops(&self) -> u64 {
382 self.address_length_drops.load(Ordering::Relaxed)
383 }
384
385 /// NPDUs dropped on any port since start because their DNET was 0xFFFF
386 /// and they also carried a DADR (#1379). Saturates at `u64::MAX`.
387 ///
388 /// DNET 0xFFFF already names every device on every network (Clauses
389 /// 6.2.2 and 6.3.2), so a DADR beside it contradicts it. The router does
390 /// not pass such an NPDU on to its other ports, deliver it locally,
391 /// act on a network message in it, or answer it with a reject: a
392 /// global broadcast never draws one. The address lengths are within
393 /// bounds, so the drop is counted here and not in
394 /// [`Self::address_length_drops`].
395 pub fn global_broadcast_dadr_drops(&self) -> u64 {
396 self.global_broadcast_dadr_drops.load(Ordering::Relaxed)
397 }
398
399 /// NPDUs dropped on any port since start because they were addressed to
400 /// a broadcast, global (DNET 0xFFFF) or remote (a DNET with DLEN 0), and
401 /// carried an APDU other than an Unconfirmed-Request (#1491). Saturates
402 /// at `u64::MAX`.
403 ///
404 /// Only an Unconfirmed-Request may go to a broadcast network address
405 /// (Clause 6.3). Anything else belongs to one peer's transaction, so the
406 /// router neither passes it on to any network nor delivers it locally,
407 /// and answers it with no reject. The PDU type comes from the first APDU
408 /// octet, with no decode; network messages are not affected.
409 pub fn broadcast_pdu_type_drops(&self) -> u64 {
410 self.broadcast_pdu_type_drops.load(Ordering::Relaxed)
411 }
412
413 async fn start_dispatch<T: TransportPort + 'static>(
414 mut ports: Vec<RouterPort<T>>,
415 track_depth: bool,
416 control: Arc<control_policy::ControlGate>,
417 network_control: Option<AdmissionSender<ReceivedNetworkControl>>,
418 ) -> Result<(Self, mpsc::Receiver<ReceivedApdu>, QueueAdmissionCounters), Error> {
419 let mut table = RouterTable::new();
420
421 // Reject duplicate network numbers
422 {
423 let mut seen = std::collections::HashSet::new();
424 for port in &ports {
425 if !seen.insert(port.network_number) {
426 return Err(Error::Encoding(format!(
427 "Duplicate network number {} in router ports",
428 port.network_number
429 )));
430 }
431 }
432 }
433
434 // Register directly-connected networks
435 for (idx, port) in ports.iter().enumerate() {
436 table.add_direct(port.network_number, idx);
437 }
438
439 let table = Arc::new(Mutex::new(table));
440 let discovery = Arc::new(Mutex::new(DiscoveryTracker::default()));
441 let (local_tx, local_rx, counters) = AdmissionReceiver::channel(track_depth);
442
443 // Start each transport, set up send channels
444 let mut port_receivers = Vec::new();
445 let mut send_txs: Vec<mpsc::Sender<SendRequest>> = Vec::new();
446 let mut sender_tasks = Vec::new();
447 let mut port_networks = Vec::new();
448 let mut port_local_macs = Vec::new();
449
450 for port in &mut ports {
451 let rx = port.transport.start().await?;
452 port_receivers.push(rx);
453 port_networks.push(port.network_number);
454 port_local_macs.push(MacAddr::from_slice(port.transport.local_mac()));
455 }
456
457 // Move transports into sender tasks
458 for port in ports {
459 let (send_tx, mut send_rx) = mpsc::channel::<SendRequest>(256);
460 send_txs.push(send_tx);
461
462 let transport = port.transport;
463 let task = tokio::spawn(async move {
464 while let Some(req) = send_rx.recv().await {
465 match req {
466 SendRequest::Unicast {
467 npdu,
468 mac,
469 data_attributes,
470 } => {
471 if let Err(e) = transport
472 .send_unicast_with_data_attributes(&npdu, &mac, &data_attributes)
473 .await
474 {
475 warn!(error = %e, "Router send_unicast failed");
476 }
477 }
478 SendRequest::Broadcast {
479 npdu,
480 data_attributes,
481 } => {
482 if let Err(e) = transport
483 .send_broadcast_with_data_attributes(&npdu, &data_attributes)
484 .await
485 {
486 warn!(error = %e, "Router send_broadcast failed");
487 }
488 }
489 }
490 }
491 });
492 sender_tasks.push(task);
493 }
494
495 let send_txs = Arc::new(send_txs);
496
497 // Announce I-Am-Router-To-Network on each port listing networks reachable via other ports.
498 for (port_idx, tx) in send_txs.iter().enumerate() {
499 let other_networks: Vec<u16> = port_networks
500 .iter()
501 .enumerate()
502 .filter(|(idx, _)| *idx != port_idx)
503 .map(|(_, net)| *net)
504 .collect();
505
506 if other_networks.is_empty() {
507 continue;
508 }
509
510 let mut payload = BytesMut::with_capacity(other_networks.len() * 2);
511 for net in &other_networks {
512 payload.put_u16(*net);
513 }
514
515 let payload_len = payload.len();
516 let response = Npdu {
517 is_network_message: true,
518 message_type: Some(NetworkMessageType::I_AM_ROUTER_TO_NETWORK.to_raw()),
519 payload: payload.freeze(),
520 ..Npdu::default()
521 };
522
523 let mut buf = BytesMut::with_capacity(8 + payload_len);
524 if let Err(e) = encode_npdu(&mut buf, &response) {
525 warn!("Failed to encode I-Am-Router NPDU: {e}");
526 continue;
527 }
528
529 if let Err(e) = tx.try_send(SendRequest::broadcast(buf.freeze())) {
530 warn!(%e, "Router dropped I-Am-Router announcement: output channel full");
531 }
532 }
533
534 let mut dispatch_tasks = Vec::new();
535 let address_length_drops = Arc::new(AtomicU64::new(0));
536 let global_broadcast_dadr_drops = Arc::new(AtomicU64::new(0));
537 let broadcast_pdu_type_drops = Arc::new(AtomicU64::new(0));
538 let network_control_ingress_sequence = Arc::new(AtomicU64::new(0));
539 let local_control = Arc::new(LocalControl::new(
540 OwnAddresses::new(
541 port_networks
542 .iter()
543 .zip(&port_local_macs)
544 .map(|(&network, mac)| NpduAddress {
545 network,
546 mac_address: mac.clone(),
547 })
548 .collect(),
549 ),
550 network_control,
551 Arc::clone(&network_control_ingress_sequence),
552 ));
553
554 for (port_idx, rx) in port_receivers.into_iter().enumerate() {
555 let port = PortDispatch {
556 table: Arc::clone(&table),
557 discovery: Arc::clone(&discovery),
558 control: Arc::clone(&control),
559 local_control: Arc::clone(&local_control),
560 address_length_drops: Arc::clone(&address_length_drops),
561 global_broadcast_dadr_drops: Arc::clone(&global_broadcast_dadr_drops),
562 broadcast_pdu_type_drops: Arc::clone(&broadcast_pdu_type_drops),
563 local_tx: local_tx.clone(),
564 send_txs: Arc::clone(&send_txs),
565 port_idx,
566 port_network: port_networks[port_idx],
567 local_mac: port_local_macs[port_idx].clone(),
568 };
569 dispatch_tasks.push(tokio::spawn(port.run(rx)));
570 }
571
572 // Periodically purge stale learned routes, hardened pending slots and
573 // discovery entries. `last_used` never extends `last_seen` expiry.
574 let aging_table = Arc::clone(&table);
575 let aging_discovery = Arc::clone(&discovery);
576 let aging_task = tokio::spawn(async move {
577 let mut interval = tokio::time::interval(Duration::from_secs(60));
578 let max_age = Duration::from_secs(300); // 5 minutes
579 loop {
580 interval.tick().await;
581 let now = Instant::now();
582 let mut tbl = aging_table.lock().await;
583 let purged = tbl.purge_stale_at(now, max_age);
584 tbl.clear_expired_busy();
585 let expired_pending = tbl.expire_pending_at(now);
586 drop(tbl);
587 let mut disc = aging_discovery.lock().await;
588 let expired_discovery = disc.expire_at(now);
589 drop(disc);
590 for net in purged {
591 debug!(network = net, "Purged stale route");
592 }
593 for net in expired_pending {
594 debug!(network = net, "Expired hardened pending challenger");
595 }
596 for net in expired_discovery {
597 debug!(network = net, "Expired discovery solicitation");
598 }
599 }
600 });
601
602 Ok((
603 Self {
604 table,
605 discovery,
606 control,
607 dispatch_tasks,
608 sender_tasks,
609 aging_task: Some(aging_task),
610 address_length_drops,
611 global_broadcast_dadr_drops,
612 broadcast_pdu_type_drops,
613 network_control_ingress_sequence,
614 },
615 local_rx,
616 counters,
617 ))
618 }
619
620 /// Get a reference to the routing table.
621 pub fn table(&self) -> &Arc<Mutex<RouterTable>> {
622 &self.table
623 }
624
625 /// Stop the router.
626 ///
627 /// Aborts and awaits dispatch, sender and aging tasks without waiting for
628 /// the local application consumer, even with a full queue. Queued local
629 /// APDUs remain drainable, retaining their reply senders until consumed or
630 /// the receiver is dropped. Stop is not an admission drop and does not clear
631 /// depth/high-water/drop totals. See the
632 /// [receive-queue contract](crate::layer#receive-queue-admission).
633 pub async fn stop(&mut self) {
634 for task in self.dispatch_tasks.drain(..) {
635 task.abort();
636 let _ = task.await;
637 }
638 for task in self.sender_tasks.drain(..) {
639 task.abort();
640 let _ = task.await;
641 }
642 if let Some(task) = self.aging_task.take() {
643 task.abort();
644 let _ = task.await;
645 }
646 // Cancel pending unknown-destination discovery so no further
647 // solicitation is emitted and coalesced waiters observe shutdown.
648 self.discovery.lock().await.cancel();
649 }
650}
651
652#[cfg(test)]
653mod admission_tests;
654#[cfg(test)]
655mod busy_scope_tests;
656#[cfg(test)]
657mod claim_tests;
658#[cfg(test)]
659mod envelope_control_tests;
660#[cfg(test)]
661mod envelope_discovery_tests;
662#[cfg(test)]
663mod envelope_harness;
664#[cfg(test)]
665mod ingress_harness;
666#[cfg(test)]
667mod init_routing_table_tests;
668#[cfg(test)]
669mod rb06_convergence_tests;
670#[cfg(test)]
671mod rb09_control_policy_tests;
672#[cfg(test)]
673mod tests;