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
14const CONSERVATIVE_ROUTED_PATH_MAX_NPDU: u16 = 228;
16pub(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 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 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 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 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;