Skip to main content

bacnet_client/client/
routed_path_limits.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard};
3use std::time::{Duration, Instant as StdInstant};
4
5use bacnet_encoding::npdu::decode_reject_message_to_network;
6use bacnet_network::layer::ReceivedNetworkControl;
7use bacnet_types::enums::{NetworkMessageType, RejectMessageReason};
8use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
9use tokio::time::Instant as TokioInstant;
10
11use super::*;
12use crate::tsm::CompletionOutcome;
13
14/// Smallest routed NPDU envelope required across the standard data links.
15const CONSERVATIVE_ROUTED_PATH_MAX_NPDU: u16 = 228;
16/// Hard bound covering gates, configured evidence, and learned evidence.
17pub(super) const MAX_ROUTED_PATH_ENTRIES: usize = 256;
18
19#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
20struct RoutedPathKey {
21    immediate_router_mac: MacAddr,
22    dnet: u16,
23}
24
25impl RoutedPathKey {
26    fn new(immediate_router_mac: &[u8], dnet: u16) -> Self {
27        Self {
28            immediate_router_mac: MacAddr::from_slice(immediate_router_mac),
29            dnet,
30        }
31    }
32}
33
34#[derive(Clone, Copy, Debug)]
35enum ConfiguredLimitProvenance {
36    ExplicitApi,
37}
38
39struct ConfiguredPathLimit {
40    max_npdu: u16,
41    provenance: ConfiguredLimitProvenance,
42    observed_at: StdInstant,
43}
44
45struct LearnedPathLimit {
46    exclusive_max_npdu: u16,
47    observed_at: StdInstant,
48}
49
50struct ActiveRoutedSend {
51    path: RoutedPathKey,
52    tsm_mac: MacAddr,
53    invoke_id: u8,
54    owner: TransactionOwner,
55    forwarded_npci_len: u16,
56    outgoing_apdu_len: Option<u16>,
57    ingress_floor: u64,
58}
59
60struct RoutedPathEntry {
61    gate: Arc<AsyncMutex<()>>,
62    configured: Option<ConfiguredPathLimit>,
63    learned: Option<LearnedPathLimit>,
64    active: Option<ActiveRoutedSend>,
65    last_used: u64,
66    quarantine_started: Option<TokioInstant>,
67    generation_attempts: u32,
68    generation_terminal_observed: bool,
69}
70
71impl RoutedPathEntry {
72    fn new(last_used: u64) -> Self {
73        Self {
74            gate: Arc::new(AsyncMutex::new(())),
75            configured: None,
76            learned: None,
77            active: None,
78            last_used,
79            quarantine_started: None,
80            generation_attempts: 0,
81            generation_terminal_observed: false,
82        }
83    }
84}
85
86struct RoutedPathState {
87    entries: HashMap<RoutedPathKey, RoutedPathEntry>,
88    next_use: u64,
89    capacity: usize,
90}
91
92pub(super) struct RoutedPathLimits {
93    state: StdMutex<RoutedPathState>,
94    quarantine_horizon: Duration,
95}
96
97impl RoutedPathLimits {
98    pub(super) fn new(quarantine_horizon: Duration) -> Self {
99        Self::with_capacity(MAX_ROUTED_PATH_ENTRIES, quarantine_horizon)
100    }
101
102    fn with_capacity(capacity: usize, quarantine_horizon: Duration) -> Self {
103        assert!(capacity > 0, "routed path capacity must be nonzero");
104        Self {
105            state: StdMutex::new(RoutedPathState {
106                entries: HashMap::new(),
107                next_use: 0,
108                capacity,
109            }),
110            quarantine_horizon,
111        }
112    }
113
114    fn state(&self) -> StdMutexGuard<'_, RoutedPathState> {
115        self.state
116            .lock()
117            .unwrap_or_else(std::sync::PoisonError::into_inner)
118    }
119
120    pub(super) async fn acquire(
121        self: &Arc<Self>,
122        router_mac: &[u8],
123        dnet: u16,
124    ) -> Result<RoutedPathLease, Error> {
125        let key = RoutedPathKey::new(router_mac, dnet);
126        let gate = self.reserve_gate(&key)?;
127        let gate = gate.lock_owned().await;
128        self.wait_for_quarantine(&key).await;
129        Ok(RoutedPathLease {
130            limits: Arc::clone(self),
131            key,
132            _gate: gate,
133        })
134    }
135
136    fn reserve_gate(&self, key: &RoutedPathKey) -> Result<Arc<AsyncMutex<()>>, Error> {
137        let mut state = self.state();
138        let last_used = bump_use(&mut state);
139        if let Some(entry) = state.entries.get_mut(key) {
140            entry.last_used = last_used;
141            return Ok(Arc::clone(&entry.gate));
142        }
143
144        if state.entries.len() >= state.capacity {
145            let reclaim = state
146                .entries
147                .iter()
148                .filter(|(_, entry)| self.is_safely_reclaimable(entry))
149                .min_by(|(key_a, entry_a), (key_b, entry_b)| {
150                    entry_a
151                        .last_used
152                        .cmp(&entry_b.last_used)
153                        .then_with(|| key_a.cmp(key_b))
154                })
155                .map(|(key, _)| key.clone());
156            if let Some(reclaim) = reclaim {
157                state.entries.remove(&reclaim);
158            }
159        }
160        if state.entries.len() >= state.capacity {
161            return Err(Error::RoutedPathCapacityExceeded {
162                capacity: state.capacity,
163            });
164        }
165
166        let entry = RoutedPathEntry::new(last_used);
167        let gate = Arc::clone(&entry.gate);
168        state.entries.insert(key.clone(), entry);
169        Ok(gate)
170    }
171
172    fn is_safely_reclaimable(&self, entry: &RoutedPathEntry) -> bool {
173        entry.configured.is_none()
174            && entry.learned.is_none()
175            && entry.active.is_none()
176            && Arc::strong_count(&entry.gate) == 1
177            && entry
178                .quarantine_started
179                .is_none_or(|started| started.elapsed() >= self.quarantine_horizon)
180    }
181
182    async fn wait_for_quarantine(&self, key: &RoutedPathKey) {
183        loop {
184            let remaining = {
185                let mut state = self.state();
186                let last_used = bump_use(&mut state);
187                let entry = state
188                    .entries
189                    .get_mut(key)
190                    .expect("a held or waiting gate keeps its path entry resident");
191                entry.last_used = last_used;
192                match entry.quarantine_started {
193                    Some(started) => {
194                        let remaining = self.quarantine_horizon.saturating_sub(started.elapsed());
195                        if remaining.is_zero() {
196                            entry.quarantine_started = None;
197                            entry.generation_attempts = 0;
198                            entry.generation_terminal_observed = false;
199                            None
200                        } else {
201                            Some(remaining)
202                        }
203                    }
204                    None => {
205                        entry.generation_attempts = 0;
206                        entry.generation_terminal_observed = false;
207                        None
208                    }
209                }
210            };
211            let Some(remaining) = remaining else {
212                return;
213            };
214            tokio::time::sleep(remaining).await;
215        }
216    }
217
218    pub(super) fn install_active(
219        &self,
220        target: ConfirmedTarget<'_>,
221        tsm_mac: MacAddr,
222        invoke_id: u8,
223        owner: TransactionOwner,
224        forwarded_npci_len: u16,
225        ingress_floor: u64,
226    ) {
227        let ConfirmedTarget::Routed {
228            router_mac,
229            dest_network,
230            ..
231        } = target
232        else {
233            return;
234        };
235        let key = RoutedPathKey::new(router_mac, dest_network);
236        let mut state = self.state();
237        let entry = state
238            .entries
239            .get_mut(&key)
240            .expect("the routed path lease keeps its entry resident");
241        debug_assert!(
242            entry.active.is_none(),
243            "the per-path gate permits only one active routed request"
244        );
245        entry.active = Some(ActiveRoutedSend {
246            path: key,
247            tsm_mac,
248            invoke_id,
249            owner,
250            forwarded_npci_len,
251            outgoing_apdu_len: None,
252            ingress_floor,
253        });
254    }
255
256    pub(super) fn authorize_attempt(
257        &self,
258        target: ConfirmedTarget<'_>,
259        tsm_mac: &MacAddr,
260        invoke_id: u8,
261        owner: &TransactionOwner,
262        outgoing_apdu_len: usize,
263    ) -> bool {
264        let ConfirmedTarget::Routed {
265            router_mac,
266            dest_network,
267            ..
268        } = target
269        else {
270            return true;
271        };
272        let Ok(outgoing_apdu_len) = u16::try_from(outgoing_apdu_len) else {
273            return false;
274        };
275        let key = RoutedPathKey::new(router_mac, dest_network);
276        let mut state = self.state();
277        let Some(entry) = state.entries.get_mut(&key) else {
278            return false;
279        };
280        let Some(active) = entry.active.as_mut() else {
281            return false;
282        };
283        if active.tsm_mac != *tsm_mac
284            || active.invoke_id != invoke_id
285            || !active.owner.same_as(owner)
286        {
287            return false;
288        }
289        active.outgoing_apdu_len = Some(outgoing_apdu_len);
290        entry.generation_attempts = entry.generation_attempts.saturating_add(1);
291        true
292    }
293
294    pub(super) async fn handle_network_control(
295        &self,
296        tsm: &Arc<Mutex<Tsm>>,
297        control: ReceivedNetworkControl,
298    ) {
299        if control.npdu.message_type != Some(NetworkMessageType::REJECT_MESSAGE_TO_NETWORK.to_raw())
300        {
301            return;
302        }
303        let Ok(reject) = decode_reject_message_to_network(&control.npdu.payload) else {
304            return;
305        };
306        if reject.reason != RejectMessageReason::MESSAGE_TOO_LONG {
307            return;
308        }
309
310        let Some(active) =
311            self.claim_active(&control.source_mac, reject.dnet, control.ingress_sequence)
312        else {
313            return;
314        };
315        let outcome = tsm.lock().await.complete_network_path_too_long_for_owner(
316            &active.tsm_mac,
317            active.invoke_id,
318            &active.owner,
319            reject.dnet,
320        );
321        if outcome == CompletionOutcome::Delivered {
322            self.commit_learned_limit(active);
323        }
324    }
325
326    fn claim_active(
327        &self,
328        router_mac: &[u8],
329        dnet: u16,
330        ingress_sequence: u64,
331    ) -> Option<ActiveRoutedSend> {
332        let key = RoutedPathKey::new(router_mac, dnet);
333        let mut state = self.state();
334        let entry = state.entries.get_mut(&key)?;
335        if entry.active.as_ref().is_none_or(|active| {
336            active.outgoing_apdu_len.is_none()
337                || active.path != key
338                || ingress_sequence <= active.ingress_floor
339        }) {
340            return None;
341        }
342        entry.active.take()
343    }
344
345    fn commit_learned_limit(&self, active: ActiveRoutedSend) {
346        let Some(attempted_npdu) = active
347            .outgoing_apdu_len
348            .and_then(|apdu| active.forwarded_npci_len.checked_add(apdu))
349        else {
350            return;
351        };
352        let mut state = self.state();
353        let entry = state
354            .entries
355            .get_mut(&active.path)
356            .expect("the reason-4 claimant still holds its routed path gate");
357        let exclusive_max_npdu = entry.learned.as_ref().map_or(attempted_npdu, |learned| {
358            learned.exclusive_max_npdu.min(attempted_npdu)
359        });
360        entry.learned = Some(LearnedPathLimit {
361            exclusive_max_npdu,
362            observed_at: StdInstant::now(),
363        });
364    }
365}
366
367pub(super) struct RoutedPathLease {
368    limits: Arc<RoutedPathLimits>,
369    key: RoutedPathKey,
370    _gate: OwnedMutexGuard<()>,
371}
372
373impl RoutedPathLease {
374    pub(super) fn max_apdu(
375        &self,
376        dadr_len: usize,
377        local_source_mac_len: usize,
378    ) -> Result<u16, Error> {
379        let forwarded_npci_len = forwarded_npci_len(dadr_len, local_source_mac_len)?;
380        let state = self.limits.state();
381        let entry = state
382            .entries
383            .get(&self.key)
384            .expect("path entry remains present while its gate is held");
385        let configured_or_conservative =
386            entry
387                .configured
388                .as_ref()
389                .map_or(CONSERVATIVE_ROUTED_PATH_MAX_NPDU, |configured| {
390                    tracing::trace!(
391                        max_npdu = configured.max_npdu,
392                        provenance = ?configured.provenance,
393                        age = ?configured.observed_at.elapsed(),
394                        "Using configured routed-path NPDU limit"
395                    );
396                    configured.max_npdu
397                });
398        let effective_npdu = entry
399            .learned
400            .as_ref()
401            .map_or(configured_or_conservative, |learned| {
402                tracing::trace!(
403                    exclusive_max_npdu = learned.exclusive_max_npdu,
404                    age = ?learned.observed_at.elapsed(),
405                    "Applying learned routed-path upper bound"
406                );
407                configured_or_conservative
408                    .min(learned.exclusive_max_npdu.checked_sub(1).unwrap_or(0))
409            });
410        Ok(effective_npdu.checked_sub(forwarded_npci_len).unwrap_or(0))
411    }
412
413    pub(super) fn forwarded_npci_len(
414        &self,
415        dadr_len: usize,
416        local_source_mac_len: usize,
417    ) -> Result<u16, Error> {
418        forwarded_npci_len(dadr_len, local_source_mac_len)
419    }
420
421    /// Mark a source-correlated terminal response for the current generation.
422    /// One attempted frame plus its terminal response proves there cannot be a
423    /// second reason-4 still in flight for that generation. Multi-frame or
424    /// retried generations remain ambiguous and are quarantined on drop.
425    pub(super) fn mark_terminal_observed(&self) {
426        let mut state = self.limits.state();
427        let entry = state
428            .entries
429            .get_mut(&self.key)
430            .expect("path entry remains present while its gate is held");
431        entry.generation_terminal_observed = entry.generation_attempts == 1;
432    }
433
434    fn configure(&self, max_npdu: u16) {
435        let mut state = self.limits.state();
436        let entry = state
437            .entries
438            .get_mut(&self.key)
439            .expect("path entry remains present while its gate is held");
440        entry.configured = Some(ConfiguredPathLimit {
441            max_npdu,
442            provenance: ConfiguredLimitProvenance::ExplicitApi,
443            observed_at: StdInstant::now(),
444        });
445        entry.learned = None;
446    }
447
448    fn clear(&self) {
449        let mut state = self.limits.state();
450        let entry = state
451            .entries
452            .get_mut(&self.key)
453            .expect("path entry remains present while its gate is held");
454        entry.configured = None;
455        entry.learned = None;
456    }
457}
458
459impl Drop for RoutedPathLease {
460    fn drop(&mut self) {
461        let mut state = self.limits.state();
462        if let Some(entry) = state.entries.get_mut(&self.key) {
463            entry.active = None;
464            if entry.generation_attempts > 0 && !entry.generation_terminal_observed {
465                entry.quarantine_started = Some(TokioInstant::now());
466            }
467            entry.generation_attempts = 0;
468            entry.generation_terminal_observed = false;
469        }
470    }
471}
472
473fn bump_use(state: &mut RoutedPathState) -> u64 {
474    state.next_use = state.next_use.saturating_add(1);
475    state.next_use
476}
477
478pub(super) fn routed_path_quarantine_horizon(config: &ClientConfig) -> Duration {
479    // Reuse the configured complete request/retry horizon instead of adding a
480    // separate arbitrary stale-control timer. A zero timeout still receives
481    // one millisecond, the existing configuration's millisecond granularity,
482    // so ambiguous generations never skip quarantine entirely.
483    let attempts = u64::from(config.apdu_retries).saturating_add(1);
484    Duration::from_millis(config.apdu_timeout_ms.saturating_mul(attempts).max(1))
485}
486
487fn forwarded_npci_len(dadr_len: usize, local_source_mac_len: usize) -> Result<u16, Error> {
488    if dadr_len > u8::MAX as usize {
489        return Err(Error::Encoding(
490            "routed destination MAC address exceeds 255 bytes".into(),
491        ));
492    }
493    if local_source_mac_len == 0 || local_source_mac_len > u8::MAX as usize {
494        return Err(Error::Encoding(
495            "local source MAC address for routed forwarding must contain 1..=255 bytes".into(),
496        ));
497    }
498    u16::try_from(9usize + dadr_len + local_source_mac_len)
499        .map_err(|_| Error::Encoding("forwarded routed NPCI length exceeds u16".into()))
500}
501
502impl<T: TransportPort + 'static> BACnetClient<T> {
503    /// Configure the maximum NPDU for one immediate-router/DNET path.
504    ///
505    /// The update waits for any request already active on the same path. It
506    /// replaces the previous configured value and deliberately clears learned
507    /// reason-4 evidence for that path.
508    pub async fn configure_routed_path_max_npdu(
509        &self,
510        router_mac: &[u8],
511        dnet: u16,
512        max_npdu: u16,
513    ) -> Result<(), Error> {
514        validate_public_path(router_mac, dnet)?;
515        let lease = self.routed_path_limits.acquire(router_mac, dnet).await?;
516        lease.configure(max_npdu);
517        Ok(())
518    }
519
520    /// Restore one immediate-router/DNET path to the conservative default.
521    ///
522    /// The update waits for an active request and clears both configured and
523    /// learned reason-4 evidence. Later requests therefore use the 228-octet
524    /// conservative routed NPDU envelope until new evidence is supplied.
525    pub async fn clear_routed_path_limit(&self, router_mac: &[u8], dnet: u16) -> Result<(), Error> {
526        validate_public_path(router_mac, dnet)?;
527        let lease = self.routed_path_limits.acquire(router_mac, dnet).await?;
528        lease.clear();
529        Ok(())
530    }
531}
532
533fn validate_public_path(router_mac: &[u8], dnet: u16) -> Result<(), Error> {
534    if router_mac.is_empty() || router_mac.len() > u8::MAX as usize {
535        return Err(Error::Encoding(
536            "immediate router MAC address must contain 1..=255 bytes".into(),
537        ));
538    }
539    if dnet == 0 || dnet == 0xffff {
540        return Err(Error::Encoding(
541            "routed path DNET must be in 1..=65534".into(),
542        ));
543    }
544    Ok(())
545}
546
547#[cfg(test)]
548#[path = "routed_path_limits_tests.rs"]
549mod tests;