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::{swap_apart, Duration, Instant, Mac, PortId};
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).
303const fn is_reserved(network: u16) -> bool {
304    network == 0 || network == 0xFFFF
305}
306
307/// A routing table of up to `R` routes and `H` hold-down records.
308///
309/// A full table refuses a new network: [`add_direct`](Self::add_direct)
310/// and [`add_learned`](Self::add_learned) return `false`, and
311/// [`learn`](Self::learn) and [`apply_claim`](Self::apply_claim) return
312/// [`LearnRefusal::Full`]. Routes it holds can still be refreshed, moved and
313/// removed. A router keeps its route cap at or below `R`, so the cap, which
314/// it counts, is met first.
315///
316/// A [`Route`] is a copy of a row, so a route changes only through the
317/// table's calls, each of which keeps its rules: [`add_direct`](Self::add_direct)
318/// and [`add_learned`](Self::add_learned) install one;
319/// [`learn`](Self::learn) and [`apply_claim`](Self::apply_claim) learn one,
320/// with flap detection and convergence; [`mark_busy`](Self::mark_busy),
321/// [`mark_available`](Self::mark_available) and
322/// [`mark_unreachable`](Self::mark_unreachable) set its state;
323/// [`touch`](Self::touch) notes its use; and [`remove`](Self::remove) and
324/// [`purge_stale`](Self::purge_stale) take it out. None of them makes a
325/// learned route direct, or a direct one learned, except by replacing it.
326///
327/// [`new`](Self::new) is a `const fn` whose result is all zero bits, so a
328/// table can live in a zero-initialized static (`.bss`).
329#[derive(Clone)]
330pub struct Table<const R: usize, const H: usize> {
331    slots: [Slot; R],
332    len: usize,
333    holds: holds::Holds<H>,
334    counters: ClaimCounters,
335    convergence: Convergence,
336}
337
338/// The routes and holds stored, not the unused storage.
339impl<const R: usize, const H: usize> fmt::Debug for Table<R, H> {
340    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341        f.debug_struct("Table")
342            .field("routes", &self.stored())
343            .field("holds", &self.holds)
344            .field("counters", &self.counters)
345            .field("convergence", &self.convergence)
346            .finish()
347    }
348}
349
350impl<const R: usize, const H: usize> Default for Table<R, H> {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356impl<const R: usize, const H: usize> Table<R, H> {
357    /// How many routes it can hold.
358    pub const CAPACITY: usize = R;
359
360    /// An empty table with standard convergence: all zero bits.
361    #[must_use]
362    pub const fn new() -> Self {
363        Self {
364            slots: [VACANT; R],
365            len: 0,
366            holds: holds::Holds::new(),
367            counters: ClaimCounters {
368                reject_applied: 0,
369                reject_dampened: 0,
370                reject_dampened_same_state: 0,
371                reject_dampened_hold_down: 0,
372                reject_kept: 0,
373                learned_ok: 0,
374                learned_cap_ignored: 0,
375                flap_warned: 0,
376                pending_started: 0,
377                corroborated_applied: 0,
378                pending_expired: 0,
379                disconnect_removal_ignored: 0,
380                hold_evicted: 0,
381            },
382            convergence: Convergence::Standard,
383        }
384    }
385
386    /// Forget every route, hold-down record and count, keeping the
387    /// convergence mode: what [`new`](Self::new) gives, without building a
388    /// second table.
389    pub(crate) fn clear(&mut self) {
390        self.slots[..self.len].fill(VACANT);
391        self.len = 0;
392        self.holds.clear();
393        self.counters = ClaimCounters::default();
394    }
395
396    /// Choose the convergence mode for later claims. Routes stay; a pending
397    /// claim waits for its next claim or the sweep, as before. It is a
398    /// `const fn`, so a table in a `static` can start hardened.
399    pub const fn set_convergence(&mut self, convergence: Convergence) {
400        self.convergence = convergence;
401    }
402
403    /// The convergence mode.
404    #[must_use]
405    pub const fn convergence(&self) -> Convergence {
406        self.convergence
407    }
408
409    /// The claim counts so far.
410    #[must_use]
411    pub const fn claim_counters(&self) -> ClaimCounters {
412        self.counters
413    }
414
415    /// How many routes it holds.
416    #[must_use]
417    pub const fn len(&self) -> usize {
418        self.len
419    }
420
421    /// Whether it holds none.
422    #[must_use]
423    pub const fn is_empty(&self) -> bool {
424        self.len == 0
425    }
426
427    /// Whether a new network has no room.
428    #[must_use]
429    pub const fn is_full(&self) -> bool {
430        self.len == R
431    }
432
433    /// The stored slots, sorted by network.
434    fn stored(&self) -> &[Slot] {
435        &self.slots[..self.len]
436    }
437
438    /// The index of `network`'s slot, or where it would go.
439    fn find(&self, network: u16) -> Result<usize, usize> {
440        self.stored()
441            .binary_search_by_key(&network, |slot| slot.network)
442    }
443
444    /// The slot for `network`, if it is stored.
445    fn slot_mut(&mut self, network: u16) -> Option<&mut Slot> {
446        let index = self.find(network).ok()?;
447        Some(&mut self.slots[index])
448    }
449
450    /// Store `route` for `network`, replacing any route and pending claim it
451    /// had. `false` when the network is new and there is no room.
452    fn put(&mut self, network: u16, route: Route) -> bool {
453        let slot = Slot {
454            network,
455            route,
456            pending: None,
457        };
458        match self.find(network) {
459            Ok(index) => self.slots[index] = slot,
460            Err(_) if self.len == R => return false,
461            Err(index) => {
462                // Into the free slot at the end, then back to its place one
463                // swap at a time, with no memmove (`swap_apart`).
464                self.slots[self.len] = slot;
465                for at in (index..self.len).rev() {
466                    swap_apart(&mut self.slots, at, at + 1);
467                }
468                self.len += 1;
469            }
470        }
471        true
472    }
473
474    /// The route to `network`.
475    #[must_use]
476    pub fn lookup(&self, network: u16) -> Option<Route> {
477        let index = self.find(network).ok()?;
478        Some(self.slots[index].route)
479    }
480
481    /// Remove the route to `network`, with its pending claim and its
482    /// hold-down records.
483    pub fn remove(&mut self, network: u16) -> Option<Route> {
484        let index = self.find(network).ok()?;
485        let route = self.slots[index].route;
486        // The emptied slot moves to the end one swap at a time, with no
487        // memmove (`swap_apart`).
488        for at in index + 1..self.len {
489            swap_apart(&mut self.slots, at - 1, at);
490        }
491        self.len -= 1;
492        self.slots[self.len] = VACANT;
493        self.holds.forget(network);
494        Some(route)
495    }
496
497    /// Every route, in ascending network order. This and the other listings
498    /// can be cloned, so a writer can count before it writes.
499    pub fn routes(&self) -> impl Iterator<Item = (u16, &Route)> + Clone + '_ {
500        self.stored().iter().map(|slot| (slot.network, &slot.route))
501    }
502
503    /// Every network, in ascending order.
504    pub fn networks(&self) -> impl Iterator<Item = u16> + Clone + '_ {
505        self.stored().iter().map(|slot| slot.network)
506    }
507
508    /// The networks reached through `port`, in ascending order.
509    pub fn networks_on_port(&self, port: PortId) -> impl Iterator<Item = u16> + Clone + '_ {
510        self.routes()
511            .filter(move |(_, route)| route.port == port)
512            .map(|(network, _)| network)
513    }
514
515    /// The networks reached through any port but `port`, in ascending order.
516    pub fn networks_not_on_port(&self, port: PortId) -> impl Iterator<Item = u16> + Clone + '_ {
517        self.routes()
518            .filter(move |(_, route)| route.port != port)
519            .map(|(network, _)| network)
520    }
521
522    /// Install a directly attached network on `port`, replacing whatever
523    /// route it had and clearing its records. Network 0 and 0xFFFF are
524    /// ignored. `false` when nothing was installed.
525    pub fn add_direct(&mut self, network: u16, port: PortId) -> bool {
526        if is_reserved(network) {
527            return false;
528        }
529        self.holds.forget(network);
530        self.put(network, Route::direct(port))
531    }
532
533    /// Install or refresh a learned route at once, without corroboration or
534    /// flap detection: a management write, or a call on the table itself.
535    ///
536    /// It sets `last_seen`, keeps `last_used` on a refresh through the same
537    /// port and clears it otherwise, clears any mark, and re-arms the
538    /// hold-down. A direct route, network 0 and 0xFFFF are left alone.
539    /// `false` when nothing was installed.
540    pub fn add_learned(&mut self, network: u16, port: PortId, next_hop: Mac, now: Instant) -> bool {
541        if is_reserved(network) {
542            return false;
543        }
544        let last_used = match self.lookup(network) {
545            Some(existing) if existing.direct => return false,
546            Some(existing) if existing.port == port => existing.last_used,
547            _ => None,
548        };
549        self.holds.forget(network);
550        let mut route = Route::learned(port, next_hop, now);
551        route.last_used = last_used;
552        self.put(network, route)
553    }
554
555    /// Mark a learned route busy until `deadline`, unless it is unreachable:
556    /// congestion never revives a failed route. Direct routes are never
557    /// marked, and neither timestamp moves.
558    pub fn mark_busy(&mut self, network: u16, deadline: Instant) {
559        if let Some(slot) = self.slot_mut(network) {
560            busy(&mut slot.route, deadline);
561        }
562    }
563
564    /// Make a learned route reachable, lifting a busy or unreachable mark.
565    /// Direct routes and timestamps are untouched.
566    pub fn mark_available(&mut self, network: u16) {
567        if let Some(slot) = self.slot_mut(network) {
568            available(&mut slot.route);
569        }
570    }
571
572    /// [`mark_busy`](Self::mark_busy) every route served via the router at
573    /// `peer` on `port`, as a Router-Busy from it that lists no network asks,
574    /// and return how many were marked. Unreachable routes stay unreachable.
575    pub fn mark_busy_via_peer(&mut self, port: PortId, peer: &[u8], deadline: Instant) -> usize {
576        let mut marked = 0;
577        for slot in &mut self.slots[..self.len] {
578            if slot.route.served_via(port, peer) && busy(&mut slot.route, deadline) {
579                marked += 1;
580            }
581        }
582        marked
583    }
584
585    /// [`mark_available`](Self::mark_available) every route served via the
586    /// router at `peer` on `port`, as a Router-Available from it that lists
587    /// no network asks, and return how many there were.
588    pub fn mark_available_via_peer(&mut self, port: PortId, peer: &[u8]) -> usize {
589        let mut marked = 0;
590        for slot in &mut self.slots[..self.len] {
591            if slot.route.served_via(port, peer) {
592                available(&mut slot.route);
593                marked += 1;
594            }
595        }
596        marked
597    }
598
599    /// Mark a learned route unreachable, keeping it in the table. Direct
600    /// routes are untouched.
601    pub fn mark_unreachable(&mut self, network: u16) {
602        if let Some(slot) = self.slot_mut(network) {
603            let route = &mut slot.route;
604            if !route.direct {
605                route.reachability = Reachability::Unreachable;
606                route.busy_until = None;
607            }
608        }
609    }
610
611    /// Whether `network` is served by the router at `peer` on `port`: a
612    /// learned route through that port to that next router. This is the
613    /// scope of a flow-control message from `peer`, whose own list, when it
614    /// has one, is intersected with it.
615    #[must_use]
616    pub fn is_served_via_peer(&self, network: u16, port: PortId, peer: &[u8]) -> bool {
617        self.lookup(network)
618            .is_some_and(|route| route.served_via(port, peer))
619    }
620
621    /// The networks [`is_served_via_peer`](Self::is_served_via_peer) holds
622    /// for, in ascending order: the scope of a flow-control message from
623    /// `peer` that lists none.
624    pub fn routes_served_via_peer<'t>(
625        &'t self,
626        port: PortId,
627        peer: &'t [u8],
628    ) -> impl Iterator<Item = u16> + Clone + 't {
629        self.routes()
630            .filter(move |(_, route)| route.served_via(port, peer))
631            .map(|(network, _)| network)
632    }
633
634    /// Clear every busy mark whose deadline has come, returning how many.
635    pub fn clear_expired_busy(&mut self, now: Instant) -> usize {
636        let mut cleared = 0;
637        for slot in &mut self.slots[..self.len] {
638            let route = &mut slot.route;
639            if route.busy_until.is_some_and(|deadline| now >= deadline) {
640                route.reachability = Reachability::Reachable;
641                route.busy_until = None;
642                cleared += 1;
643            }
644        }
645        cleared
646    }
647
648    /// The reachability of `network` at `now`: a busy mark reads
649    /// `Reachable` from its deadline on, whether or not the sweep has run.
650    #[must_use]
651    pub fn effective_reachability(&self, network: u16, now: Instant) -> Option<Reachability> {
652        let route = self.lookup(network)?;
653        Some(match route.busy_until {
654            Some(deadline) if route.reachability == Reachability::Busy && now >= deadline => {
655                Reachability::Reachable
656            }
657            _ => route.reachability,
658        })
659    }
660
661    /// Note that a learned route carried traffic `now`. Only `last_used`
662    /// moves, so traffic never keeps a route alive.
663    pub fn touch(&mut self, network: u16, now: Instant) {
664        if let Some(slot) = self.slot_mut(network) {
665            if !slot.route.direct {
666                slot.route.last_used = Some(now);
667            }
668        }
669    }
670
671    /// Remove every learned route last confirmed more than `max_age` before
672    /// `now`, calling `purged` with each network in ascending order, and
673    /// return how many. Direct routes never age.
674    ///
675    /// Every call comes before anything is removed, so a `purged` that
676    /// panics leaves the table as it was.
677    pub fn purge_stale(
678        &mut self,
679        now: Instant,
680        max_age: Duration,
681        mut purged: impl FnMut(u16),
682    ) -> usize {
683        let stale = |slot: &Slot| {
684            !slot.route.direct
685                && slot
686                    .route
687                    .last_seen
688                    .is_some_and(|seen| now.saturating_duration_since(seen) > max_age)
689        };
690        for slot in self.stored().iter().filter(|slot| stale(slot)) {
691            purged(slot.network);
692        }
693        // Each kept route is swapped forward, never copied over another,
694        // with no memmove (`swap_apart`).
695        let mut kept = 0;
696        for index in 0..self.len {
697            if stale(&self.slots[index]) {
698                self.holds.forget(self.slots[index].network);
699            } else {
700                swap_apart(&mut self.slots, kept, index);
701                kept += 1;
702            }
703        }
704        let removed = self.len - kept;
705        self.slots[kept..self.len].fill(VACANT);
706        self.len = kept;
707        removed
708    }
709}
710
711/// Mark `route` busy until `deadline` unless it is direct or unreachable;
712/// `true` when it was marked.
713fn busy(route: &mut Route, deadline: Instant) -> bool {
714    let marks = !route.direct && route.reachability != Reachability::Unreachable;
715    if marks {
716        route.reachability = Reachability::Busy;
717        route.busy_until = Some(deadline);
718    }
719    marks
720}
721
722/// Make `route` reachable unless it is direct.
723fn available(route: &mut Route) {
724    if !route.direct {
725        route.reachability = Reachability::Reachable;
726        route.busy_until = None;
727    }
728}
729
730#[cfg(test)]
731mod tests;