Skip to main content

bacnet_client/client/
lifecycle.rs

1use super::dispatch_context::{DispatchContext, InboundApdu};
2use super::*;
3use std::sync::atomic::{AtomicU64, Ordering};
4
5const DEVICE_PURGE_INTERVAL: Duration = Duration::from_secs(300);
6#[cfg(not(test))]
7const DEVICE_MAX_AGE: Duration = Duration::from_secs(600);
8// Unit tests use Tokio's paused clock, while DeviceTable timestamps use
9// std::time::Instant. Use an immediate threshold so the purge timer is
10// observable without constructing an unrepresentable old Instant on Windows.
11#[cfg(test)]
12const DEVICE_MAX_AGE: Duration = Duration::ZERO;
13
14impl ClientConfig {
15    pub(super) fn validate_request_settings(&self) -> Result<(), Error> {
16        validate_max_apdu_length(self.max_apdu_length)?;
17        validate_max_segments(self.max_segments)?;
18        pacing::validate_interval_ms(self.min_request_interval_ms)
19    }
20
21    pub(super) fn validate_window(&self) -> Result<(), Error> {
22        if !(1..=127).contains(&self.proposed_window_size) {
23            return Err(Error::Encoding(format!(
24                "invalid proposed-window-size {}; expected 1..=127",
25                self.proposed_window_size
26            )));
27        }
28        Ok(())
29    }
30}
31
32impl<T: TransportPort> BACnetClient<T> {
33    fn abort_owned_tasks(&self) {
34        for task in [&self.dispatch_task, &self.network_number_task]
35            .into_iter()
36            .flatten()
37        {
38            task.abort();
39        }
40    }
41}
42
43async fn join_owned_task(slot: &mut Option<JoinHandle<()>>) {
44    // A canceled stop retains the join and its network Arc until it completes.
45    if let Some(task) = slot.as_mut() {
46        let _ = task.await;
47    }
48    *slot = None;
49}
50
51impl<T: TransportPort + 'static> BACnetClient<T> {
52    /// Start the client: bind transport, start network layer, spawn dispatch.
53    pub async fn start(config: ClientConfig, transport: T) -> Result<Self, Error> {
54        Self::start_with_options(config, transport, ClientOptions::default()).await
55    }
56
57    /// Start the client with additional startup options.
58    pub async fn start_with_options(
59        config: ClientConfig,
60        transport: T,
61        options: ClientOptions,
62    ) -> Result<Self, Error> {
63        config.validate_request_settings()?;
64        // `config.max_apdu_length` stays the configured maximum: each request
65        // takes the minimum with the transport's live limit, so it follows a
66        // limit that rises after start as well as one that falls (#2086).
67        // These checks only refuse a transport that leaves no APDU bucket.
68        let _bucket =
69            cap_max_apdu_to_transport(config.max_apdu_length, transport.egress_apdu_limit())?;
70        config.validate_window()?;
71        options.validate()?;
72
73        let mut network = NetworkLayer::new(transport);
74        let mut network_control_rx = network.enable_network_control_receiver()?;
75        let mut apdu_rx = network.start().await?;
76        let _bucket = cap_max_apdu_to_transport(
77            config.max_apdu_length,
78            network.transport().egress_apdu_limit(),
79        )?;
80        let local_mac = MacAddr::from_slice(network.local_mac());
81
82        let network = Arc::new(network);
83        let (number_tx, network_number_task) = if network
84            .transport()
85            .supports_local_nonrouter_number_controls()
86        {
87            let (tx, task) = network_number::spawn(&network);
88            (Some(tx), Some(task))
89        } else {
90            (None, None)
91        };
92
93        let coordinator = Arc::new(OutboundTransactionCoordinator::new());
94        let tsm = Arc::new(Mutex::new(new_coordinated_tsm(&config, coordinator)));
95        let tsm_dispatch = Arc::clone(&tsm);
96        let device_table = Arc::new(Mutex::new(DeviceTable::new()));
97        let device_table_dispatch = Arc::clone(&device_table);
98        let network_dispatch = Arc::clone(&network);
99        let (cov_tx, _) =
100            broadcast::channel::<ReceivedCOVNotification>(options.cov_channel_capacity);
101        let cov_tx_dispatch = cov_tx.clone();
102        let (event_tx, _) =
103            broadcast::channel::<ReceivedEventNotification>(options.event_channel_capacity);
104        let event_tx_dispatch = event_tx.clone();
105        let confirmed_cov_ack_policy = options.confirmed_cov_notification_ack_policy.clone();
106        let (device_tx, _) = broadcast::channel::<DeviceEvent>(DEVICE_EVENT_CHANNEL_CAPACITY);
107        let device_tx_dispatch = device_tx.clone();
108        let (device_collision_tx, _) =
109            broadcast::channel::<DeviceCollisionEvent>(DEVICE_EVENT_CHANNEL_CAPACITY);
110        let device_collision_tx_dispatch = device_collision_tx.clone();
111        let (i_have_tx, _) = broadcast::channel::<ReceivedIHave>(I_HAVE_CHANNEL_CAPACITY);
112        let i_have_tx_dispatch = i_have_tx.clone();
113        let (network_report_tx, _) = broadcast::channel(NETWORK_REPORT_CHANNEL_CAPACITY);
114        let network_report_tx_dispatch = network_report_tx.clone();
115        let seg_ack_senders: Arc<Mutex<HashMap<SegAckKey, SegmentAckRoute>>> =
116            Arc::new(Mutex::new(HashMap::new()));
117        let seg_ack_senders_dispatch = Arc::clone(&seg_ack_senders);
118        let (cleanup_tx, mut cleanup_rx) = mpsc::unbounded_channel::<TransactionCleanup>();
119        #[cfg(test)]
120        let segmented_cleanup = Arc::new(SegmentedCleanupHook::default());
121        #[cfg(test)]
122        let segmented_cleanup_dispatch = Arc::clone(&segmented_cleanup);
123        let response_limits = ResponseLimits::from_config(&config);
124        let routed_path_limits = Arc::new(RoutedPathLimits::new(routed_path_quarantine_horizon(
125            &config,
126        )));
127        let routed_path_limits_dispatch = Arc::clone(&routed_path_limits);
128        let group_source_request_drops = Arc::new(AtomicU64::new(0));
129        let group_source_drops_dispatch = Arc::clone(&group_source_request_drops);
130        // One of each per client: a peer's flood of undecodable APDUs, or of
131        // responses refused, warns once per interval, the rest at debug
132        // (#1950).
133        let decode_warning = Arc::new(WarnThrottle::default());
134        let response_warning = Arc::new(WarnThrottle::default());
135        #[cfg(test)]
136        let decode_warning_hook = Arc::clone(&decode_warning);
137        #[cfg(test)]
138        let response_warning_hook = Arc::clone(&response_warning);
139
140        let dispatch_task = tokio::spawn(async move {
141            let mut seg_state: HashMap<SegKey, SegmentedReceiveState> = HashMap::new();
142            let mut device_purge_interval = tokio::time::interval(DEVICE_PURGE_INTERVAL);
143            let mut network_control_open = true;
144            // Tokio intervals tick immediately; consume that tick so discovery purges
145            // run on the configured cadence instead of racing the first APDU.
146            device_purge_interval.tick().await;
147
148            loop {
149                tokio::select! {
150                    control = network_control_rx.recv(), if network_control_open => {
151                        match control {
152                            Some(control) => {
153                                // Read once; subscribers first, and a broadcast
154                                // send never waits.
155                                let number = bacnet_network::network_number::NumberControl::parse(&control);
156                                network_reports::publish(&network_report_tx_dispatch, &control, number);
157                                if let Some((tx, parsed)) = number_tx.as_ref().zip(number)
158                                {
159                                    if tx.try_send(parsed).is_err() {
160                                        debug!("client Number control queue full or closed; dropping control");
161                                    }
162                                } else {
163                                    // Preserve the original envelope and ingress sequence for Reject correlation.
164                                    routed_path_limits_dispatch
165                                        .handle_network_control(&tsm_dispatch, control)
166                                        .await;
167                                }
168                            }
169                            None => network_control_open = false,
170                        }
171                    }
172                    cleanup = cleanup_rx.recv() => {
173                        let Some(cleanup) = cleanup else {
174                            break;
175                        };
176                        if cleanup.cancel_tsm {
177                            tsm_dispatch.lock().await.cancel_transaction_for_owner(
178                                &cleanup.mac,
179                                cleanup.invoke_id,
180                                &cleanup.owner,
181                            );
182                        }
183                        // Cleanup is provenance-agnostic: drop any reassembly
184                        // snapshot for this (mac, invoke) with a matching
185                        // owner, regardless of trust context (fail-closed).
186                        let found = seg_state
187                            .keys()
188                            .find(|key| {
189                                key.0 == cleanup.mac
190                                    && key.1 == cleanup.invoke_id
191                                    && seg_state
192                                        .get(*key)
193                                        .is_some_and(|state| {
194                                            state.owner.same_as(&cleanup.owner)
195                                        })
196                            })
197                            .cloned();
198                        #[cfg(test)]
199                        let removed = found.is_some();
200                        if let Some(found) = found {
201                            seg_state.remove(&found);
202                        }
203                        if let Some(expected_sender) = cleanup.seg_ack_sender {
204                            let key = (cleanup.mac, cleanup.invoke_id);
205                            let mut senders = seg_ack_senders_dispatch.lock().await;
206                            if senders
207                                .get(&key)
208                                .is_some_and(|route| {
209                                    route.owner.same_as(&cleanup.owner)
210                                        && route.sender.same_channel(&expected_sender)
211                                })
212                            {
213                                senders.remove(&key);
214                            }
215                        }
216                        #[cfg(test)]
217                        segmented_cleanup_dispatch.record_processed(removed);
218                    }
219                    _ = device_purge_interval.tick() => {
220                        let lost_devices = device_table_dispatch
221                            .lock()
222                            .await
223                            .purge_stale_collect(DEVICE_MAX_AGE);
224                        for device in lost_devices {
225                            let _ = device_tx_dispatch.send(DeviceEvent {
226                                kind: DeviceEventKind::Lost,
227                                device,
228                            });
229                        }
230                    }
231                    received = apdu_rx.recv() => {
232                        let Some(received) = received else {
233                            break;
234                        };
235                        match apdu::decode_apdu(received.apdu.clone()) {
236                            // Its answer would go back to a group address,
237                            // to every node there (#1504).
238                            Ok(Apdu::ConfirmedRequest(_))
239                                if network_dispatch
240                                    .transport()
241                                    .is_group_destination(&received.source_mac) =>
242                            {
243                                group_source_drops_dispatch.fetch_add(1, Ordering::Relaxed);
244                                debug!("Ignoring a ConfirmedRequest from a group address");
245                            }
246                            Ok(decoded) => {
247                                Self::dispatch_apdu(
248                                    DispatchContext {
249                                        tsm: &tsm_dispatch,
250                                        device_table: &device_table_dispatch,
251                                        network: &network_dispatch,
252                                        cov_tx: &cov_tx_dispatch,
253                                        event_tx: &event_tx_dispatch,
254                                        confirmed_cov_ack_policy: &confirmed_cov_ack_policy,
255                                        device_tx: &device_tx_dispatch,
256                                        device_collision_tx: &device_collision_tx_dispatch,
257                                        i_have_tx: &i_have_tx_dispatch,
258                                        seg_ack_senders: &seg_ack_senders_dispatch,
259                                        decode_warning: &decode_warning,
260                                        response_warning: &response_warning,
261                                    },
262                                    &mut seg_state,
263                                    InboundApdu {
264                                        source_mac: &received.source_mac,
265                                        source_network: &received.source_network,
266                                        provenance: received.provenance.clone(),
267                                        response_route: received.response_route(),
268                                        is_group: received.is_group,
269                                        reply_tx: received.reply_tx,
270                                    },
271                                    decoded,
272                                    response_limits,
273                                )
274                                .await;
275                            }
276                            Err(e) => {
277                                warn_throttled!(
278                                    decode_warning,
279                                    error = %e,
280                                    "Failed to decode received APDU"
281                                );
282                            }
283                        }
284                    }
285                }
286            }
287        });
288
289        // Shared: each paced request's guard keeps the pacer it reports to.
290        let pacer = Arc::new(pacing::RequestPacer::new(Duration::from_millis(
291            config.min_request_interval_ms,
292        )));
293        Ok(Self {
294            config,
295            network,
296            tsm,
297            device_table,
298            cov_tx,
299            event_tx,
300            device_tx,
301            device_collision_tx,
302            i_have_tx,
303            network_report_tx,
304            dispatch_task: Some(dispatch_task),
305            network_number_task,
306            seg_ack_senders,
307            cleanup_tx,
308            #[cfg(test)]
309            segmented_post_wait_cleanup: Arc::new(SegmentedPostWaitCleanupHook::default()),
310            #[cfg(test)]
311            segmented_cleanup,
312            #[cfg(test)]
313            decode_warning: decode_warning_hook,
314            #[cfg(test)]
315            response_warning: response_warning_hook,
316            local_mac,
317            routed_path_limits,
318            group_source_request_drops,
319            pacer,
320        })
321    }
322
323    /// Confirmed requests ignored since start because the link-layer address
324    /// they came from is a group destination of the transport
325    /// ([`TransportPort::is_group_destination`]), such as a B/IP broadcast or
326    /// multicast address (#1504). The answer, such as the acknowledgment of
327    /// a confirmed COV notification, would go back to that address and reach
328    /// every node in the group, so the request is neither handled nor
329    /// answered. No built-in transport hands one up; a custom one can.
330    pub fn group_source_request_drops(&self) -> u64 {
331        self.group_source_request_drops.load(Ordering::Relaxed)
332    }
333    /// Get the client's local MAC address.
334    pub fn local_mac(&self) -> &[u8] {
335        &self.local_mac
336    }
337
338    /// Current BACnet max-APDU bucket advertised by this client.
339    ///
340    /// Returns `0` if the live transport budget is below the smallest BACnet
341    /// max-APDU bucket and a confirmed request would currently fail before
342    /// encoding.
343    pub fn max_apdu_length(&self) -> u16 {
344        max_apdu_bucket_at_or_below(
345            self.config
346                .max_apdu_length
347                .min(self.network.transport().egress_apdu_limit()),
348        )
349        .unwrap_or(0)
350    }
351
352    /// Current APDU payload budget reported by the underlying transport.
353    ///
354    /// For BACnet/SC this reflects the negotiated hub envelope after connect
355    /// and may be lower than the encoded BACnet max-APDU bucket advertised by
356    /// the client.
357    pub fn transport_max_apdu_length(&self) -> u16 {
358        self.network.transport().egress_apdu_limit()
359    }
360
361    /// Stop the client, aborting and joining dispatch and local Number work.
362    /// Canceling this waiter retains task joins for a subsequent stop.
363    pub async fn stop(&mut self) -> Result<(), Error> {
364        self.abort_owned_tasks();
365        join_owned_task(&mut self.network_number_task).await;
366        join_owned_task(&mut self.dispatch_task).await;
367        self.tsm.lock().await.cancel_all_transactions();
368        let network = Arc::get_mut(&mut self.network).ok_or_else(|| {
369            Error::Encoding("cannot stop BACnetClient while network references remain".into())
370        })?;
371        network.stop().await?;
372        Ok(())
373    }
374}
375
376impl<T: TransportPort> Drop for BACnetClient<T> {
377    fn drop(&mut self) {
378        self.abort_owned_tasks();
379        if let Ok(mut tsm) = self.tsm.try_lock() {
380            tsm.cancel_all_transactions();
381        }
382    }
383}