Skip to main content

bacnet_router_core/
table.rs

1//! The routing table (Clause 6.6.1), in caller-sized storage.
2//!
3//! [`Table`] maps each network the router can reach to a [`Route`]: the
4//! port it is reached through, the next router's MAC (none for a directly
5//! attached network), and a [`Reachability`] that keeps a lasting failure
6//! apart from congestion. Up to `R` routes are kept sorted by network
7//! number, so a lookup is a binary search and every listing comes out in
8//! ascending order with no sort.
9//!
10//! The rules are those of the hosted router's table:
11//!
12//! - **Direct routes are fixed.** No learning claim, reject, flow-control
13//!   mark or management write changes one.
14//! - **Learning** ([`Table::apply_claim`]): under [`Convergence::Standard`]
15//!   each claim applies at once, so the last one wins (Clause 6.6.3.2), and a
16//!   route that changes port three times within 60 s is reported as
17//!   flapping. [`Convergence::Hardened`], an opt-in, holds a move to another
18//!   port until a second claim for the same network and port arrives within
19//!   the corroboration window (60 s, inclusive). A new network, or a refresh
20//!   on the same port, applies at once in both modes.
21//! - **Freshness is split.** `last_seen` is set by learning and management
22//!   only, and aging keys off it; forwarding sets `last_used`, which never
23//!   extends a route's life. Busy and Available marks touch neither.
24//! - **Rejects** ([`Table::apply_reject`]): reason 1 marks a learned route
25//!   unreachable and reason 2 busy for 30 s; any other reason leaves it as
26//!   it is, and is counted apart (Clause 6.6.3.5).
27//!   Each change starts a 30 s hold-down for its network and arrival port,
28//!   during which further reject-driven changes from that port are ignored.
29//!   Hold-down records live in their own pool of `H`; a full pool evicts the
30//!   oldest and counts it ([`ClaimCounters::hold_evicted`]).
31//! - **Busy marks** lapse at their deadline, which
32//!   [`Table::effective_reachability`] reads inline, so the sweep's cadence
33//!   never changes a forwarding decision. Only Router-Available or fresh
34//!   learning lifts Unreachable.
35//!
36//! [`ClaimCounters`] counts the outcomes. The counts saturate and never
37//! change a decision.
38//!
39//! The table stores no timers: the calls that need the busy period, the
40//! hold-down or the corroboration window take a [`Timing`], which the
41//! router's options own. So [`Table::new`] is all zero bits, and a table in
42//! a `static` costs firmware no flash for an initial image.
43
44mod claims;
45mod holds;
46
47use core::fmt;
48
49use crate::{Duration, Instant, Mac, PortId, swap_apart};
50
51pub use claims::{LearnRefusal, Learned};
52pub use holds::RejectEffect;
53
54/// How far a route can be used.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Reachability {
57    /// Usable.
58    Reachable,
59    /// Congested until the route's busy deadline (Router-Busy, or reject
60    /// reason 2).
61    Busy,
62    /// Failed until Router-Available or fresh learning (reject reason 1).
63    Unreachable,
64}
65
66/// How a learning claim that moves a route to another port is taken.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum Convergence {
69    /// At once: the last claim wins (Clause 6.6.3.2).
70    #[default]
71    Standard,
72    /// After a second claim for the same network and port within the
73    /// corroboration window. It authenticates nothing, and it slows
74    /// legitimate moves: alternating claims never converge, and a single
75    /// claim waits for the window to lapse and a fresh one.
76    Hardened,
77}
78
79/// The way to one network: a copy of the table's row, which
80/// [`Table::lookup`] and [`Table::routes`] hand out.
81///
82/// Its fields are the table's bookkeeping, so they are read through its
83/// getters, and only the table's own calls change the row: a direct route
84/// stays direct, and the flap count and timestamps stay the table's. Build
85/// one with [`Route::direct`] or [`Route::learned`], to compare with what a
86/// lookup returns. It is `#[non_exhaustive]`.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct Route {
90    port: PortId,
91    direct: bool,
92    next_hop: Mac,
93    last_seen: Option<Instant>,
94    last_used: Option<Instant>,
95    reachability: Reachability,
96    busy_until: Option<Instant>,
97    flap_count: u8,
98    last_port_change: Option<Instant>,
99}
100
101impl Route {
102    /// All zero bits: what an unused slot holds.
103    const ZERO: Self = Self {
104        port: PortId(0),
105        direct: false,
106        next_hop: Mac::EMPTY,
107        last_seen: None,
108        last_used: None,
109        reachability: Reachability::Reachable,
110        busy_until: None,
111        flap_count: 0,
112        last_port_change: None,
113    };
114
115    /// A directly attached network on `port`.
116    #[must_use]
117    pub const fn direct(port: PortId) -> Self {
118        let mut route = Self::ZERO;
119        route.port = port;
120        route.direct = true;
121        route
122    }
123
124    /// A network reached through `next_hop` on `port`, confirmed `now`.
125    #[must_use]
126    pub const fn learned(port: PortId, next_hop: Mac, now: Instant) -> Self {
127        let mut route = Self::ZERO;
128        route.port = port;
129        route.next_hop = next_hop;
130        route.last_seen = Some(now);
131        route
132    }
133
134    /// The port the network is reached through.
135    #[must_use]
136    pub const fn port(&self) -> PortId {
137        self.port
138    }
139
140    /// Whether the network is attached to that port, rather than reached
141    /// through another router.
142    #[must_use]
143    pub const fn is_direct(&self) -> bool {
144        self.direct
145    }
146
147    /// The next router's MAC on that port; empty for a direct route.
148    #[must_use]
149    pub const fn next_hop(&self) -> &Mac {
150        &self.next_hop
151    }
152
153    /// When learning or management last confirmed the route; `None` for a
154    /// direct route. Aging keys off it.
155    #[must_use]
156    pub const fn last_seen(&self) -> Option<Instant> {
157        self.last_seen
158    }
159
160    /// When the route last carried traffic; `None` until it does, and again
161    /// after a move to another port. It never extends the route's life.
162    #[must_use]
163    pub const fn last_used(&self) -> Option<Instant> {
164        self.last_used
165    }
166
167    /// The stored state. A lapsed busy mark still reads `Busy` here until
168    /// the sweep; [`Table::effective_reachability`] reads it at a time.
169    #[must_use]
170    pub const fn reachability(&self) -> Reachability {
171        self.reachability
172    }
173
174    /// When a busy mark lapses.
175    #[must_use]
176    pub const fn busy_until(&self) -> Option<Instant> {
177        self.busy_until
178    }
179
180    /// Moves to another port within 60 s of the one before.
181    #[must_use]
182    pub const fn flap_count(&self) -> u8 {
183        self.flap_count
184    }
185
186    /// When the route last moved to another port.
187    #[must_use]
188    pub const fn last_port_change(&self) -> Option<Instant> {
189        self.last_port_change
190    }
191
192    /// Whether flow control from the router at `peer` on `port` covers it:
193    /// learned, through that port, with that next router.
194    fn served_via(&self, port: PortId, peer: &[u8]) -> bool {
195        !self.direct && self.port == port && self.next_hop == *peer
196    }
197}
198
199/// A hardened-mode claim waiting for its second (see [`Table::pending`]).
200///
201/// The table builds it. It is `#[non_exhaustive]`, so a new field isn't a
202/// breaking change: a pattern needs `..`.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204#[non_exhaustive]
205pub struct Pending {
206    /// The port the claim would move the route to.
207    pub port: PortId,
208    /// When the first claim came.
209    pub first_seen: Instant,
210}
211
212/// Counts of how routing claims fared. They saturate, never affect a
213/// decision, and name no peer.
214///
215/// Learning counts cover I-Am-Router and Initialize-Routing-Table-Ack
216/// entries the router inspects, not management writes or calls on the
217/// table itself. The router records some of them itself.
218///
219/// It is `#[non_exhaustive]`, so a new count isn't a breaking change.
220/// Outside this crate, build one from `ClaimCounters::default()` and set
221/// its fields.
222#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
223#[non_exhaustive]
224pub struct ClaimCounters {
225    /// Rejects that changed a learned route's state.
226    pub reject_applied: u64,
227    /// Rejects ignored, for either reason below; each counts once here.
228    pub reject_dampened: u64,
229    /// Rejects that would change nothing: no route, a direct one, or the
230    /// state already in effect. A repeat Busy never extends its deadline.
231    pub reject_dampened_same_state: u64,
232    /// State-changing rejects ignored within the hold-down.
233    pub reject_dampened_hold_down: u64,
234    /// Rejects for a learned route with a reason other than 1 or 2, which
235    /// leave it as it is (Clause 6.6.3.5).
236    pub reject_kept: u64,
237    /// Learning claims that added or refreshed a learned route.
238    pub learned_ok: u64,
239    /// Learning claims that met the router's route cap and ended the
240    /// message's learning, and I-Could-Be-Router offers the cap turned
241    /// away.
242    pub learned_cap_ignored: u64,
243    /// Moves that reached the flap warning.
244    pub flap_warned: u64,
245    /// Hardened-mode claims held pending, new or restarted after expiry.
246    pub pending_started: u64,
247    /// Moves applied by a second claim within the window.
248    pub corroborated_applied: u64,
249    /// Pending claims found past the window and dropped, by the next claim
250    /// or by the sweep. Other cleanup doesn't count.
251    pub pending_expired: u64,
252    /// Disconnect-Connection removals ignored, since there are no PTP
253    /// connections.
254    pub disconnect_removal_ignored: u64,
255    /// Hold-down records evicted, oldest first, from a full pool.
256    pub hold_evicted: u64,
257}
258
259/// The table's timers, which the router's options own and pass to each
260/// call that needs one.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub struct Timing {
263    /// How long reject reason 2 marks a route busy.
264    pub busy_for: Duration,
265    /// How long a reject-driven change holds off the next one for its
266    /// network and arrival port.
267    pub hold_down: Duration,
268    /// How long a hardened-mode claim waits for its second.
269    pub corroboration: Duration,
270}
271
272impl Timing {
273    /// 30 s busy, 30 s hold-down, 60 s corroboration: the hosted router's.
274    pub const DEFAULT: Self = Self {
275        busy_for: Duration::from_secs(30),
276        hold_down: Duration::from_secs(30),
277        corroboration: Duration::from_secs(60),
278    };
279}
280
281impl Default for Timing {
282    fn default() -> Self {
283        Self::DEFAULT
284    }
285}
286
287/// One stored route and its pending claim.
288#[derive(Debug, Clone, Copy)]
289struct Slot {
290    network: u16,
291    route: Route,
292    pending: Option<Pending>,
293}
294
295/// An unused slot: all zero bits.
296const VACANT: Slot = Slot {
297    network: 0,
298    route: Route::ZERO,
299    pending: None,
300};
301
302/// Whether `network` names no single network (0, or the global 0xFFFF), so
303/// no route can lead to it. A router never learns one, and a non-routing
304/// node reading a route announcement drops it the same way.
305#[must_use]
306pub const fn is_reserved(network: u16) -> bool {
307    network == 0 || network == 0xFFFF
308}
309
310/// A routing table of up to `R` routes and `H` hold-down records.
311///
312/// A full table refuses a new network: [`add_direct`](Self::add_direct)
313/// and [`add_learned`](Self::add_learned) return `false`, and
314/// [`learn`](Self::learn) and [`apply_claim`](Self::apply_claim) return
315/// [`LearnRefusal::Full`]. Routes it holds can still be refreshed, moved and
316/// removed. A router keeps its route cap at or below `R`, so the cap, which
317/// it counts, is met first.
318///
319/// A [`Route`] is a copy of a row, so a route changes only through the
320/// table's calls, each of which keeps its rules: [`add_direct`](Self::add_direct)
321/// and [`add_learned`](Self::add_learned) install one;
322/// [`learn`](Self::learn) and [`apply_claim`](Self::apply_claim) learn one,
323/// with flap detection and convergence; [`mark_busy`](Self::mark_busy),
324/// [`mark_available`](Self::mark_available) and
325/// [`mark_unreachable`](Self::mark_unreachable) set its state;
326/// [`touch`](Self::touch) notes its use; and [`remove`](Self::remove) and
327/// [`purge_stale`](Self::purge_stale) take it out. None of them makes a
328/// learned route direct, or a direct one learned, except by replacing it.
329///
330/// [`new`](Self::new) is a `const fn` whose result is all zero bits, so a
331/// table can live in a zero-initialized static (`.bss`).
332#[derive(Clone)]
333pub struct Table<const R: usize, const H: usize> {
334    slots: [Slot; R],
335    len: usize,
336    holds: holds::Holds<H>,
337    counters: ClaimCounters,
338    convergence: Convergence,
339}
340
341/// The routes and holds stored, not the unused storage.
342impl<const R: usize, const H: usize> fmt::Debug for Table<R, H> {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("Table")
345            .field("routes", &self.stored())
346            .field("holds", &self.holds)
347            .field("counters", &self.counters)
348            .field("convergence", &self.convergence)
349            .finish()
350    }
351}
352
353impl<const R: usize, const H: usize> Default for Table<R, H> {
354    fn default() -> Self {
355        Self::new()
356    }
357}
358
359impl<const R: usize, const H: usize> Table<R, H> {
360    /// How many routes it can hold.
361    pub const CAPACITY: usize = R;
362
363    /// An empty table with standard convergence: all zero bits.
364    #[must_use]
365    pub const fn new() -> Self {
366        Self {
367            slots: [VACANT; R],
368            len: 0,
369            holds: holds::Holds::new(),
370            counters: ClaimCounters {
371                reject_applied: 0,
372                reject_dampened: 0,
373                reject_dampened_same_state: 0,
374                reject_dampened_hold_down: 0,
375                reject_kept: 0,
376                learned_ok: 0,
377                learned_cap_ignored: 0,
378                flap_warned: 0,
379                pending_started: 0,
380                corroborated_applied: 0,
381                pending_expired: 0,
382                disconnect_removal_ignored: 0,
383                hold_evicted: 0,
384            },
385            convergence: Convergence::Standard,
386        }
387    }
388
389    /// Forget every route, hold-down record and count, keeping the
390    /// convergence mode: what [`new`](Self::new) gives, without building a
391    /// second table.
392    pub(crate) fn clear(&mut self) {
393        self.slots[..self.len].fill(VACANT);
394        self.len = 0;
395        self.holds.clear();
396        self.counters = ClaimCounters::default();
397    }
398
399    /// Choose the convergence mode for later claims. Routes stay; a pending
400    /// claim waits for its next claim or the sweep, as before. It is a
401    /// `const fn`, so a table in a `static` can start hardened.
402    pub const fn set_convergence(&mut self, convergence: Convergence) {
403        self.convergence = convergence;
404    }
405
406    /// The convergence mode.
407    #[must_use]
408    pub const fn convergence(&self) -> Convergence {
409        self.convergence
410    }
411
412    /// The claim counts so far.
413    #[must_use]
414    pub const fn claim_counters(&self) -> ClaimCounters {
415        self.counters
416    }
417
418    /// How many routes it holds.
419    #[must_use]
420    pub const fn len(&self) -> usize {
421        self.len
422    }
423
424    /// Whether it holds none.
425    #[must_use]
426    pub const fn is_empty(&self) -> bool {
427        self.len == 0
428    }
429
430    /// Whether a new network has no room.
431    #[must_use]
432    pub const fn is_full(&self) -> bool {
433        self.len == R
434    }
435
436    /// The stored slots, sorted by network.
437    fn stored(&self) -> &[Slot] {
438        &self.slots[..self.len]
439    }
440
441    /// The index of `network`'s slot, or where it would go.
442    fn find(&self, network: u16) -> Result<usize, usize> {
443        self.stored()
444            .binary_search_by_key(&network, |slot| slot.network)
445    }
446
447    /// The slot for `network`, if it is stored.
448    fn slot_mut(&mut self, network: u16) -> Option<&mut Slot> {
449        let index = self.find(network).ok()?;
450        Some(&mut self.slots[index])
451    }
452
453    /// Store `route` for `network`, replacing any route and pending claim it
454    /// had. `false` when the network is new and there is no room.
455    fn put(&mut self, network: u16, route: Route) -> bool {
456        let slot = Slot {
457            network,
458            route,
459            pending: None,
460        };
461        match self.find(network) {
462            Ok(index) => self.slots[index] = slot,
463            Err(_) if self.len == R => return false,
464            Err(index) => {
465                // Into the free slot at the end, then back to its place one
466                // swap at a time, with no memmove (`swap_apart`).
467                self.slots[self.len] = slot;
468                for at in (index..self.len).rev() {
469                    swap_apart(&mut self.slots, at, at + 1);
470                }
471                self.len += 1;
472            }
473        }
474        true
475    }
476
477    /// The route to `network`.
478    #[must_use]
479    pub fn lookup(&self, network: u16) -> Option<Route> {
480        let index = self.find(network).ok()?;
481        Some(self.slots[index].route)
482    }
483
484    /// Remove the route to `network`, with its pending claim and its
485    /// hold-down records.
486    pub fn remove(&mut self, network: u16) -> Option<Route> {
487        let index = self.find(network).ok()?;
488        let route = self.slots[index].route;
489        // The emptied slot moves to the end one swap at a time, with no
490        // memmove (`swap_apart`).
491        for at in index + 1..self.len {
492            swap_apart(&mut self.slots, at - 1, at);
493        }
494        self.len -= 1;
495        self.slots[self.len] = VACANT;
496        self.holds.forget(network);
497        Some(route)
498    }
499
500    /// Every route, in ascending network order. This and the other listings
501    /// can be cloned, so a writer can count before it writes.
502    pub fn routes(&self) -> impl Iterator<Item = (u16, &Route)> + Clone + '_ {
503        self.stored().iter().map(|slot| (slot.network, &slot.route))
504    }
505
506    /// Every network, in ascending order.
507    pub fn networks(&self) -> impl Iterator<Item = u16> + Clone + '_ {
508        self.stored().iter().map(|slot| slot.network)
509    }
510
511    /// The networks reached through `port`, in ascending order.
512    pub fn networks_on_port(&self, port: PortId) -> impl Iterator<Item = u16> + Clone + '_ {
513        self.routes()
514            .filter(move |(_, route)| route.port == port)
515            .map(|(network, _)| network)
516    }
517
518    /// The networks reached through any port but `port`, in ascending order.
519    pub fn networks_not_on_port(&self, port: PortId) -> impl Iterator<Item = u16> + Clone + '_ {
520        self.routes()
521            .filter(move |(_, route)| route.port != port)
522            .map(|(network, _)| network)
523    }
524
525    /// Install a directly attached network on `port`, replacing whatever
526    /// route it had and clearing its records. Network 0 and 0xFFFF are
527    /// ignored. `false` when nothing was installed.
528    pub fn add_direct(&mut self, network: u16, port: PortId) -> bool {
529        if is_reserved(network) {
530            return false;
531        }
532        self.holds.forget(network);
533        self.put(network, Route::direct(port))
534    }
535
536    /// Install or refresh a learned route at once, without corroboration or
537    /// flap detection: a management write, or a call on the table itself.
538    ///
539    /// It sets `last_seen`, keeps `last_used` on a refresh through the same
540    /// port and clears it otherwise, clears any mark, and re-arms the
541    /// hold-down. A direct route, network 0 and 0xFFFF are left alone.
542    /// `false` when nothing was installed.
543    pub fn add_learned(&mut self, network: u16, port: PortId, next_hop: Mac, now: Instant) -> bool {
544        if is_reserved(network) {
545            return false;
546        }
547        let last_used = match self.lookup(network) {
548            Some(existing) if existing.direct => return false,
549            Some(existing) if existing.port == port => existing.last_used,
550            _ => None,
551        };
552        self.holds.forget(network);
553        let mut route = Route::learned(port, next_hop, now);
554        route.last_used = last_used;
555        self.put(network, route)
556    }
557
558    /// Mark a learned route busy until `deadline`, unless it is unreachable:
559    /// congestion never revives a failed route. Direct routes are never
560    /// marked, and neither timestamp moves.
561    pub fn mark_busy(&mut self, network: u16, deadline: Instant) {
562        if let Some(slot) = self.slot_mut(network) {
563            busy(&mut slot.route, deadline);
564        }
565    }
566
567    /// Make a learned route reachable, lifting a busy or unreachable mark.
568    /// Direct routes and timestamps are untouched.
569    pub fn mark_available(&mut self, network: u16) {
570        if let Some(slot) = self.slot_mut(network) {
571            available(&mut slot.route);
572        }
573    }
574
575    /// [`mark_busy`](Self::mark_busy) every route served via the router at
576    /// `peer` on `port`, as a Router-Busy from it that lists no network asks,
577    /// and return how many were marked. Unreachable routes stay unreachable.
578    pub fn mark_busy_via_peer(&mut self, port: PortId, peer: &[u8], deadline: Instant) -> usize {
579        let mut marked = 0;
580        for slot in &mut self.slots[..self.len] {
581            if slot.route.served_via(port, peer) && busy(&mut slot.route, deadline) {
582                marked += 1;
583            }
584        }
585        marked
586    }
587
588    /// [`mark_available`](Self::mark_available) every route served via the
589    /// router at `peer` on `port`, as a Router-Available from it that lists
590    /// no network asks, and return how many there were.
591    pub fn mark_available_via_peer(&mut self, port: PortId, peer: &[u8]) -> usize {
592        let mut marked = 0;
593        for slot in &mut self.slots[..self.len] {
594            if slot.route.served_via(port, peer) {
595                available(&mut slot.route);
596                marked += 1;
597            }
598        }
599        marked
600    }
601
602    /// Mark a learned route unreachable, keeping it in the table. Direct
603    /// routes are untouched.
604    pub fn mark_unreachable(&mut self, network: u16) {
605        if let Some(slot) = self.slot_mut(network) {
606            let route = &mut slot.route;
607            if !route.direct {
608                route.reachability = Reachability::Unreachable;
609                route.busy_until = None;
610            }
611        }
612    }
613
614    /// Whether `network` is served by the router at `peer` on `port`: a
615    /// learned route through that port to that next router. This is the
616    /// scope of a flow-control message from `peer`, whose own list, when it
617    /// has one, is intersected with it.
618    #[must_use]
619    pub fn is_served_via_peer(&self, network: u16, port: PortId, peer: &[u8]) -> bool {
620        self.lookup(network)
621            .is_some_and(|route| route.served_via(port, peer))
622    }
623
624    /// The networks [`is_served_via_peer`](Self::is_served_via_peer) holds
625    /// for, in ascending order: the scope of a flow-control message from
626    /// `peer` that lists none.
627    pub fn routes_served_via_peer<'t>(
628        &'t self,
629        port: PortId,
630        peer: &'t [u8],
631    ) -> impl Iterator<Item = u16> + Clone + 't {
632        self.routes()
633            .filter(move |(_, route)| route.served_via(port, peer))
634            .map(|(network, _)| network)
635    }
636
637    /// Clear every busy mark whose deadline has come, returning how many.
638    pub fn clear_expired_busy(&mut self, now: Instant) -> usize {
639        let mut cleared = 0;
640        for slot in &mut self.slots[..self.len] {
641            let route = &mut slot.route;
642            if route.busy_until.is_some_and(|deadline| now >= deadline) {
643                route.reachability = Reachability::Reachable;
644                route.busy_until = None;
645                cleared += 1;
646            }
647        }
648        cleared
649    }
650
651    /// The reachability of `network` at `now`: a busy mark reads
652    /// `Reachable` from its deadline on, whether or not the sweep has run.
653    #[must_use]
654    pub fn effective_reachability(&self, network: u16, now: Instant) -> Option<Reachability> {
655        let route = self.lookup(network)?;
656        Some(match route.busy_until {
657            Some(deadline) if route.reachability == Reachability::Busy && now >= deadline => {
658                Reachability::Reachable
659            }
660            _ => route.reachability,
661        })
662    }
663
664    /// Note that a learned route carried traffic `now`. Only `last_used`
665    /// moves, so traffic never keeps a route alive.
666    pub fn touch(&mut self, network: u16, now: Instant) {
667        if let Some(slot) = self.slot_mut(network)
668            && !slot.route.direct
669        {
670            slot.route.last_used = Some(now);
671        }
672    }
673
674    /// Remove every learned route last confirmed more than `max_age` before
675    /// `now`, calling `purged` with each network in ascending order, and
676    /// return how many. Direct routes never age.
677    ///
678    /// Every call comes before anything is removed, so a `purged` that
679    /// panics leaves the table as it was.
680    pub fn purge_stale(
681        &mut self,
682        now: Instant,
683        max_age: Duration,
684        mut purged: impl FnMut(u16),
685    ) -> usize {
686        let stale = |slot: &Slot| {
687            !slot.route.direct
688                && slot
689                    .route
690                    .last_seen
691                    .is_some_and(|seen| now.saturating_duration_since(seen) > max_age)
692        };
693        for slot in self.stored().iter().filter(|slot| stale(slot)) {
694            purged(slot.network);
695        }
696        // Each kept route is swapped forward, never copied over another,
697        // with no memmove (`swap_apart`).
698        let mut kept = 0;
699        for index in 0..self.len {
700            if stale(&self.slots[index]) {
701                self.holds.forget(self.slots[index].network);
702            } else {
703                swap_apart(&mut self.slots, kept, index);
704                kept += 1;
705            }
706        }
707        let removed = self.len - kept;
708        self.slots[kept..self.len].fill(VACANT);
709        self.len = kept;
710        removed
711    }
712}
713
714/// Mark `route` busy until `deadline` unless it is direct or unreachable;
715/// `true` when it was marked.
716fn busy(route: &mut Route, deadline: Instant) -> bool {
717    let marks = !route.direct && route.reachability != Reachability::Unreachable;
718    if marks {
719        route.reachability = Reachability::Busy;
720        route.busy_until = Some(deadline);
721    }
722    marks
723}
724
725/// Make `route` reachable unless it is direct.
726fn available(route: &mut Route) {
727    if !route.direct {
728        route.reachability = Reachability::Reachable;
729        route.busy_until = None;
730    }
731}
732
733#[cfg(test)]
734mod tests;