Skip to main content

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;