bacnet_endpoint/session.rs
1//! Single-owner endpoint session: one ingress + shared coordinator.
2//!
3//! Lifecycle lives here ONLY. Role handles expose no lifecycle methods.
4//!
5//! # Lifecycle
6//!
7//! [`EndpointSession::new`] owns `transport` without starting it. The owner
8//! drives [`start`](EndpointSession::start) once, then
9//! [`stop`](EndpointSession::stop) once; both take `&mut self` so only the
10//! owner can drive them. `Drop` aborts dispatch/ingress/role work without
11//! orphaning, but `stop()` remains the graceful path that joins termination
12//! and reports the [`SessionExit`].
13//!
14//! # Cancellation and drop
15//!
16//! Outbound leases use RAII guards. Ordinary `read_property*` calls release their
17//! exact lease when cancelled. Source-audited calls transfer to bounded session
18//! ownership before egress admission, so dropping their caller does not cancel
19//! admitted work. `stop()` seals admission, closes roles, joins dispatch and owned
20//! workers, then stops ingress. Undelivered audit records may be lost on shutdown.
21//! Before reporting termination, it settles forgotten durable writes and awaits
22//! queued save attempts with the database guard released. Canceling this wait
23//! preserves one settlement task for the next `stop()` call to join.
24//! Cloned role handles keep working until shutdown, then fail closed; after
25//! the session drops, [`Weak`](std::sync::Weak) upgrade fails and every role
26//! call reports shutdown.
27//!
28//! The session keeps its object database through `stop()` and lets go of it
29//! when it drops. Every endpoint holder of the database (the session, the
30//! server role's responder, a cloned server role handle, the source Audit
31//! runtime and the session's tasks) lets go through
32//! [`drop_database_off_runtime`], so whichever goes last, in async code the
33//! objects and any durable saves they wait for drop on Tokio's blocking pool,
34//! not on a runtime worker (#1561).
35//!
36//! ```compile_fail,E0596
37//! // Lifecycle is owner-exclusive: `stop` takes `&mut self`, so a shared
38//! // borrow cannot drive shutdown.
39//! # use bacnet_transport::loopback::LoopbackTransport;
40//! # use bacnet_endpoint::session::EndpointSession;
41//! # fn forbidden(session: &EndpointSession<LoopbackTransport>) {
42//! session.stop();
43//! # }
44//! ```
45//!
46//! # Thread safety
47//!
48//! [`EndpointSession`] is `Send` when its transport is `Send`: the dispatch
49//! task, channels, and shared counters are all `Send`-owned. Shared `&self`
50//! borrows (`client`, `server`, counters, `broadcast_i_am`) are safe because
51//! lifecycle mutation is owner-exclusive (`&mut`) or via atomics/async
52//! mutexes; role handles synchronize through the shared session token.
53
54use std::sync::Arc;
55use std::sync::atomic::{AtomicU8, Ordering};
56
57use bacnet_client::EndpointRequester;
58use bacnet_client::client::ClientConfig;
59use bacnet_encoding::apdu::Apdu;
60use bacnet_endpoint_core::coordinator::{
61 AdmissionOutcome, CoordinatorError, OutboundTransactionCoordinator,
62};
63use bacnet_endpoint_core::endpoint_ingress::{
64 ClassifierExit, EndpointIngress, PolicyOutcome, PolicyReason,
65};
66use bacnet_network::layer::ReceivedApdu;
67use bacnet_network::network_number::LocalNetworkNumber;
68use bacnet_objects::database::ObjectDatabase;
69use bacnet_objects::traits::BACnetObject;
70use bacnet_transport::port::TransportPort;
71use bacnet_types::enums::{ObjectType, PropertyIdentifier};
72use bacnet_types::error::Error;
73use bacnet_types::primitives::{ObjectIdentifier, PropertyValue};
74use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
75use tokio::task::JoinHandle;
76
77#[path = "source_profile.rs"]
78mod source_profile;
79#[path = "source_reporter.rs"]
80pub(crate) mod source_reporter;
81#[path = "session_start.rs"]
82mod startup;
83
84#[path = "device_writes.rs"]
85mod device_writes;
86pub use device_writes::{RESPONDER_OPT_IN_SERVICES, RESPONDER_SERVICES, ResponderOptIn};
87#[path = "registered_port.rs"]
88mod registered_port;
89
90use crate::roles::{
91 ClientRoleHandle, ServerRoleHandle, SessionToken, admit_once, decode_terminal,
92 inbound_canonical_peer, is_requester_lease,
93};
94use bacnet_server::server::{
95 __endpoint_EndpointResponder as EndpointResponder,
96 __endpoint_NotificationTransactions as NotificationTransactions,
97 __endpoint_settle_durable_writes as settle_durable_writes, drop_database_off_runtime,
98};
99
100#[derive(Clone, Copy, PartialEq, Eq)]
101enum Lifecycle {
102 Ready = 0,
103 Running = 1,
104 Stopped = 2,
105 Stopping = 3,
106}
107
108/// Which roles the session composes.
109///
110/// Chosen at [`EndpointSession::new`] time (or via the endpoint builders'
111/// `role()` setter) and fixed for the session lifetime. Determines which
112/// role handles exist after [`start`](EndpointSession::start):
113///
114/// - [`ClientOnly`](SessionRole::ClientOnly): `client()` is `Some`, `server()`
115/// is `None`. Inbound requests are counted (`no_server_role`) and their
116/// one-use reply sender released without sending.
117/// - [`ServerOnly`](SessionRole::ServerOnly): `server()` is `Some`,
118/// `client()` is `None`. Terminal responses with no consumer release their
119/// exact lease and count `no_client_role`.
120/// - [`Both`](SessionRole::Both): both handles are present above the one
121/// shared coordinator.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum SessionRole {
124 /// Client requester only.
125 ClientOnly,
126 /// Server responder + notification pool only.
127 ServerOnly,
128 /// Both sibling roles above one ingress/coordinator.
129 Both,
130}
131
132/// Session tuning (bounded queues, client timers, the responder's read work
133/// limit).
134///
135/// Typed at the public boundary: every field is validated where it matters
136/// (`queue_capacity == 0`, `read_work_limit == 0` or a
137/// `min_request_interval_ms` past an hour fails [`EndpointSession::new`];
138/// APDU/timer values flow into the client role config unchanged).
139///
140/// ```
141/// use bacnet_endpoint::session::SessionConfig;
142///
143/// let config = SessionConfig::default();
144/// assert!(config.queue_capacity > 0);
145/// assert_eq!(config.max_apdu_length, 480);
146/// assert_eq!(config.read_work_limit, 256);
147/// assert_eq!(config.min_request_interval_ms, 0);
148/// ```
149#[derive(Clone, Debug)]
150pub struct SessionConfig {
151 /// Bounded capacity for each ingress queue + egress channel.
152 ///
153 /// Must be greater than zero; [`EndpointSession::new`] returns
154 /// [`Error::Encoding`] otherwise.
155 pub queue_capacity: usize,
156 /// Client APDU timeout (ms).
157 pub apdu_timeout_ms: u64,
158 /// Client APDU retries.
159 pub apdu_retries: u8,
160 /// Client max APDU length.
161 ///
162 /// Standalone default is 480. Composing a
163 /// [`DeviceIdentity`](crate::identity::DeviceIdentity) via
164 /// [`device`](EndpointSession::device) overrides this at start with the
165 /// identity's ceiling resolved against the transport's stable local
166 /// receive capacity (480 for standard MS/TP, 1476 for extended MS/TP).
167 pub max_apdu_length: u16,
168 /// Result rows one ReadProperty served by the server role may expand
169 /// (default 256).
170 ///
171 /// The endpoint's counterpart of the server's
172 /// [`ReadPropertyMultipleBudget::max_result_elements`](bacnet_server::server::ReadPropertyMultipleBudget::max_result_elements),
173 /// with the same default. A read counts its own row, and a Group's
174 /// Present_Value adds one row per member property after ALL, REQUIRED
175 /// and OPTIONAL expand. A read past the limit is answered with an Abort
176 /// carrying OUT_OF_RESOURCES before any member is read.
177 ///
178 /// Must be greater than zero; [`EndpointSession::new`] returns
179 /// [`Error::Encoding`] otherwise.
180 pub read_work_limit: usize,
181 /// Least time, in milliseconds, between the client role's confirmed
182 /// requests to one destination (default 0: no pacing), measured as
183 /// [`ClientConfig::min_request_interval_ms`] says for `BACnetClient`
184 /// (#1542).
185 ///
186 /// Only new confirmed requests wait: a retry keeps its request's turn,
187 /// and replies, notifications and unconfirmed requests go at once.
188 /// Stopping or dropping the session ends a wait at once with the
189 /// shutdown error. At
190 /// most [`MAX_MIN_REQUEST_INTERVAL_MS`](bacnet_client::client::MAX_MIN_REQUEST_INTERVAL_MS),
191 /// an hour; [`EndpointSession::new`] returns [`Error::Encoding`] past it.
192 pub min_request_interval_ms: u64,
193}
194
195impl Default for SessionConfig {
196 fn default() -> Self {
197 Self {
198 queue_capacity: 16,
199 apdu_timeout_ms: 1_000,
200 apdu_retries: 0,
201 max_apdu_length: 480,
202 read_work_limit: bacnet_server::server::ReadPropertyMultipleBudget::default()
203 .max_result_elements,
204 min_request_interval_ms: 0,
205 }
206 }
207}
208
209/// Snapshot of policy-outcome ownership (session-owned, bounded).
210///
211/// The session counts every classifier/policy outcome instead of dropping it
212/// silently. Sampled via [`policy_counters`](EndpointSession::policy_counters).
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub struct PolicyCountersSnapshot {
215 /// Ingress classifier outcomes owned by the session.
216 pub ingress_policy: u64,
217 /// Inbound requests with no server role (client-only).
218 pub no_server_role: u64,
219 /// Terminal responses with no client/notification role.
220 pub no_client_role: u64,
221 /// Terminal responses no lease claimed (unknown/peer/service/policy).
222 pub unclaimed_terminal: u64,
223 /// Responder declined (group/unsupported/closed).
224 pub responder_declined: u64,
225}
226
227struct PolicyCounters {
228 ingress_policy: u64,
229 no_server_role: u64,
230 no_client_role: u64,
231 unclaimed_terminal: u64,
232 responder_declined: u64,
233}
234
235struct SessionShared {
236 token: Arc<SessionToken>,
237 counters: Mutex<PolicyCounters>,
238}
239
240/// One owned single-device endpoint session.
241///
242/// Owns ONE [`EndpointIngress`], the shared [`OutboundTransactionCoordinator`],
243/// role registration, policy-outcome ownership, timers, egress and
244/// termination. Start-once/stop-once; `Drop` aborts without orphaning.
245///
246/// An optional [`DeviceIdentity`](crate::identity::DeviceIdentity) is the
247/// single source for I-Am + Device readback + role limits when composed via
248/// [`device`](Self::device). Standalone sessions without an
249/// identity keep the `SessionConfig` 480 default untouched.
250///
251/// ```no_run
252/// use bacnet_endpoint::session::{EndpointSession, SessionConfig, SessionRole};
253/// use bacnet_transport::loopback::LoopbackTransport;
254///
255/// # #[tokio::main]
256/// # async fn main() -> Result<(), bacnet_types::error::Error> {
257/// let (transport, _peer) = LoopbackTransport::pair(vec![0x01], vec![0x02]);
258/// let mut session =
259/// EndpointSession::new(transport, SessionRole::Both, SessionConfig::default())?;
260/// session.start().await?;
261/// assert!(session.is_running());
262/// session.stop().await?;
263/// # Ok(())
264/// # }
265/// ```
266///
267/// `T` defaults to [`AnyTransport`](bacnet_transport::any::AnyTransport), so
268/// `EndpointSession` alone names a session over any built-in transport;
269/// `EndpointSession<BipTransport>` and the like name one over a concrete
270/// transport, as the builders return.
271pub struct EndpointSession<T: TransportPort + 'static = bacnet_transport::any::AnyTransport> {
272 shared: Arc<SessionShared>,
273 coordinator: Arc<OutboundTransactionCoordinator>,
274 ingress: Option<EndpointIngress<T>>,
275 requester: Option<EndpointRequester>,
276 responder: Option<Arc<EndpointResponder>>,
277 notifications: Option<Arc<NotificationTransactions>>,
278 client_handle: Option<ClientRoleHandle>,
279 server_handle: Option<ServerRoleHandle>,
280 dispatch_task: Option<JoinHandle<SessionExit>>,
281 network_number_task: Option<JoinHandle<()>>,
282 cancel_tx: Option<oneshot::Sender<()>>,
283 lifecycle: AtomicU8,
284 role: SessionRole,
285 #[allow(dead_code)]
286 config: SessionConfig,
287 client_config: ClientConfig,
288 database: Option<Arc<RwLock<ObjectDatabase>>>,
289 source_audit_reporter: Option<ObjectIdentifier>,
290 source_audit: Option<Arc<crate::source_audit::SourceAudit>>,
291 pub(crate) source_audit_bindings: Vec<(ObjectIdentifier, std::net::SocketAddrV4)>,
292 source_recipient: Option<Arc<crate::source_audit::recipient::SourceRecipient>>,
293 stop_exit: Option<Result<SessionExit, Error>>,
294 durable_settlement: Option<JoinHandle<()>>,
295 ingress_stopped: bool,
296 identity: Option<crate::identity::DeviceIdentity>,
297 local_receive_capacity: u16,
298 bip_local_address: Option<std::net::SocketAddrV4>,
299 registered_network_port: Option<ObjectIdentifier>,
300 registered_port_lease: std::sync::Weak<()>,
301 device_write_authorizer: Option<bacnet_server::mutation::DeviceWriteAuthorizer>,
302 pub(crate) reinitialize: Option<bacnet_server::server::ReinitializeHandler>,
303 pub(crate) reinit_password: Option<String>,
304 egress: Option<bacnet_endpoint_core::endpoint_ingress::EndpointEgress>,
305 /// The B/IP transport's own exchanges, when the B/IP builder built it.
306 pub(crate) bip_status: Option<bacnet_transport::bip::BvlcClientStatus>,
307 /// The MS/TP transport's counters, when the MS/TP builder built it.
308 pub(crate) mstp_diagnostics: Option<bacnet_transport::mstp::MstpDiagnostics>,
309}
310
311/// Terminal state of the session dispatch task.
312///
313/// Returned by [`stop`](EndpointSession::stop): either an explicit stop won
314/// the race, the ingress classifier exited, or all dispatch receivers closed.
315#[derive(Debug)]
316pub enum SessionExit {
317 /// Explicit `stop()` won the race.
318 Cancelled,
319 /// Ingress classifier exited (input closed / policy route full/closed).
320 Ingress(ClassifierExit),
321 /// Dispatch receivers all closed.
322 ReceiversClosed,
323}
324
325impl<T: TransportPort + 'static> EndpointSession<T> {
326 /// Creates a session owning `transport` (not yet started).
327 ///
328 /// Returns [`Error::Encoding`] when `config.queue_capacity` or
329 /// `config.read_work_limit` is zero, or `config.min_request_interval_ms`
330 /// is more than an hour. Normally built via
331 /// [`BipEndpointBuilder`](crate::bip::BipEndpointBuilder),
332 /// [`ScEndpointBuilder`](crate::sc::ScEndpointBuilder), or
333 /// [`MstpEndpointBuilder`](crate::mstp::MstpEndpointBuilder) instead of
334 /// directly.
335 ///
336 /// ```
337 /// use bacnet_endpoint::session::{EndpointSession, SessionConfig, SessionRole};
338 /// use bacnet_transport::loopback::LoopbackTransport;
339 ///
340 /// let (transport, _peer) = LoopbackTransport::pair(vec![0x01], vec![0x02]);
341 /// let bad = SessionConfig {
342 /// queue_capacity: 0,
343 /// ..SessionConfig::default()
344 /// };
345 /// assert!(EndpointSession::new(transport, SessionRole::Both, bad).is_err());
346 /// ```
347 pub fn new(transport: T, role: SessionRole, config: SessionConfig) -> Result<Self, Error> {
348 if config.queue_capacity == 0 {
349 return Err(Error::Encoding(
350 "endpoint session queue capacity must be greater than zero".into(),
351 ));
352 }
353 if config.read_work_limit == 0 {
354 return Err(Error::Encoding(
355 "endpoint session read work limit must be greater than zero".into(),
356 ));
357 }
358 let interval = config.min_request_interval_ms;
359 if interval > bacnet_client::client::MAX_MIN_REQUEST_INTERVAL_MS {
360 return Err(Error::Encoding(format!(
361 "endpoint session min-request-interval {interval} ms is more than {} ms",
362 bacnet_client::client::MAX_MIN_REQUEST_INTERVAL_MS
363 )));
364 }
365 let coordinator = Arc::new(OutboundTransactionCoordinator::new());
366 let token = SessionToken::new(Arc::clone(&coordinator));
367 let client_config = ClientConfig {
368 apdu_timeout_ms: config.apdu_timeout_ms,
369 apdu_retries: config.apdu_retries,
370 max_apdu_length: config.max_apdu_length,
371 min_request_interval_ms: interval,
372 ..ClientConfig::default()
373 };
374 let local_receive_capacity = transport.local_receive_apdu_capacity();
375 Ok(Self {
376 local_receive_capacity,
377 shared: Arc::new(SessionShared {
378 token,
379 counters: Mutex::new(PolicyCounters {
380 ingress_policy: 0,
381 no_server_role: 0,
382 no_client_role: 0,
383 unclaimed_terminal: 0,
384 responder_declined: 0,
385 }),
386 }),
387 coordinator,
388 ingress: Some(EndpointIngress::new(transport, config.queue_capacity)),
389 requester: None,
390 responder: None,
391 notifications: None,
392 client_handle: None,
393 server_handle: None,
394 dispatch_task: None,
395 network_number_task: None,
396 cancel_tx: None,
397 lifecycle: AtomicU8::new(Lifecycle::Ready as u8),
398 role,
399 config,
400 client_config,
401 database: None,
402 source_audit_reporter: None,
403 source_audit: None,
404 source_audit_bindings: Vec::new(),
405 source_recipient: None,
406 stop_exit: None,
407 durable_settlement: None,
408 ingress_stopped: false,
409 identity: None,
410 bip_local_address: None,
411 registered_network_port: None,
412 registered_port_lease: std::sync::Weak::new(),
413 device_write_authorizer: None,
414 reinitialize: None,
415 reinit_password: None,
416 egress: None,
417 bip_status: None,
418 mstp_diagnostics: None,
419 })
420 }
421
422 /// Attaches the local object database (before start).
423 ///
424 /// Must be called before [`start`](Self::start); when the server role is
425 /// composed without an identity or database, an empty one is used. With a
426 /// [`DeviceIdentity`](crate::identity::DeviceIdentity), omitting the database
427 /// requests stock generation after receive-capacity resolution. Explicitly
428 /// supplied databases, including empty ones, are preserved. Their selected
429 /// Device must match the resolved identity and declare
430 /// segmentation NONE, matching the narrow endpoint roles.
431 /// The session retains ownership even in `ClientOnly` mode and shares this
432 /// same database with the responder in `Both` mode.
433 ///
434 /// # Panics
435 /// Panics if startup has already consumed the session configuration.
436 pub fn with_database(mut self, db: ObjectDatabase) -> Self {
437 self.assert_configurable();
438 self.database = Some(Arc::new(RwLock::new(db)));
439 self
440 }
441
442 /// Select the sole source Audit Reporter in the attached local database.
443 ///
444 /// Activates bounded source READ reporting on direct IPv4 B/IP. The built-in
445 /// Device must have a typed Audit recipient provision, even at Audit_Level
446 /// NONE. Device choices use immutable builder route bindings; direct Address
447 /// choices need no binding. Monitored_Objects must be absent. `Both` requires
448 /// an explicit [`with_device_writes`](Self::with_device_writes) authorizer;
449 /// `ClientOnly` permits trusted local changes and `ServerOnly` is rejected.
450 /// Standalone BACnetClient source reporting remains unsupported.
451 ///
452 /// [`start`](Self::start) requires a concrete built-in local Device
453 /// ([`ObjectDatabase::local_device`]: the lowest instance when the database
454 /// holds several) matching the optional identity, and one selected Reporter capability with
455 /// no conflicting source Reporter. Preflight validation changes no source flags
456 /// and leaves configuration retryable; post-ingress failure joins terminal cleanup.
457 /// Successful startup installs the Device
458 /// recipient mutation owner and source projection without sending traffic.
459 /// Device/Reporter membership is protected until owned frames quiesce. The
460 /// private adapter forwards the original object behavior when sealed/released.
461 /// Repeated calls before start replace the selection.
462 ///
463 /// ```compile_fail,E0603
464 /// use bacnet_endpoint::session::source_reporter::SourceReporter;
465 /// ```
466 ///
467 /// ```no_run
468 /// use std::net::{Ipv4Addr, SocketAddrV4};
469 /// use bacnet_endpoint::{bip::BipEndpointBuilder, DeviceIdentity, SessionRole};
470 /// use bacnet_objects::{audit::AuditReporterObject, traits::BACnetObject};
471 /// use bacnet_types::{constructed::BACnetRecipient, enums::ObjectType, primitives::ObjectIdentifier};
472 /// # async fn example() -> Result<(), bacnet_types::error::Error> {
473 /// let identity = DeviceIdentity::new(123, 42)?;
474 /// let mut db = identity.build_database()?;
475 /// let logger = ObjectIdentifier::new(ObjectType::DEVICE, 999)?;
476 /// db.get_mut(&identity.device_oid()).unwrap().device_authority_internal().unwrap()
477 /// .provision_audit_recipient(BACnetRecipient::Device(logger))?;
478 /// let reporter = AuditReporterObject::new(1, "Source configuration")?;
479 /// let reporter_oid = reporter.object_identifier();
480 /// db.add(Box::new(reporter))?;
481 /// let mut session = BipEndpointBuilder::new(Ipv4Addr::LOCALHOST, 0, Ipv4Addr::BROADCAST)
482 /// .role(SessionRole::ClientOnly).database(db).device(identity)
483 /// .source_audit_device_binding(logger, SocketAddrV4::new(Ipv4Addr::LOCALHOST, 47808))
484 /// .build_session()?.with_source_audit_reporter(reporter_oid);
485 /// session.start().await?; // Startup emits no audit traffic.
486 /// session.stop().await?;
487 /// # Ok(())
488 /// # }
489 /// ```
490 ///
491 /// # Panics
492 /// Panics if startup has already consumed the session configuration.
493 pub fn with_source_audit_reporter(mut self, reporter: ObjectIdentifier) -> Self {
494 self.assert_configurable();
495 self.source_audit_reporter = Some(reporter);
496 self
497 }
498
499 /// Composes the single Device identity (before start).
500 ///
501 /// Truth direction: identity overrides the standalone `SessionConfig`
502 /// 480 default for the client role max-APDU; timers/retries stay from
503 /// `SessionConfig`. Startup resolves the ceiling against the owned link.
504 /// An absent database requests stock generation; an explicitly supplied
505 /// database is preserved and its selected Device must match the resolved
506 /// identity. Sessions without an identity keep the 480 default.
507 ///
508 /// # Panics
509 /// Panics if startup has already consumed the session configuration.
510 pub fn device(mut self, identity: crate::identity::DeviceIdentity) -> Self {
511 self.assert_configurable();
512 self.client_config.max_apdu_length = identity.max_apdu_length();
513 self.config.max_apdu_length = identity.max_apdu_length();
514 self.identity = Some(identity);
515 self
516 }
517
518 fn assert_configurable(&self) {
519 assert_eq!(
520 self.lifecycle.load(Ordering::Acquire),
521 Lifecycle::Ready as u8,
522 "endpoint configuration must precede startup"
523 );
524 }
525
526 /// Starts ingress, roles and the single dispatch consumer once.
527 ///
528 /// Start-once: a second call returns
529 /// [`Error::Encoding`] without
530 /// binding again. Takes `&mut self` so only the owner can start.
531 /// Source-profile validation runs before lifecycle consumption or ingress
532 /// startup; its errors leave the session ready for correction and retry.
533 /// Profile initialization errors after ingress starts cancel and join ingress
534 /// before returning, leaving the session terminal. Canceling that cleanup
535 /// leaves a stopping session whose teardown can resume with [`stop`](Self::stop).
536 pub async fn start(&mut self) -> Result<(), Error> {
537 if self.lifecycle.load(Ordering::Acquire) != Lifecycle::Ready as u8 {
538 return Err(Error::Encoding(
539 "endpoint session cannot be started more than once".into(),
540 ));
541 }
542 let (device_target, source_routes) = self.prepare_start().await?;
543 if self.lifecycle.compare_exchange(
544 Lifecycle::Ready as u8,
545 Lifecycle::Running as u8,
546 Ordering::AcqRel,
547 Ordering::Acquire,
548 ) != Ok(Lifecycle::Ready as u8)
549 {
550 return Err(Error::Encoding(
551 "endpoint session cannot be started more than once".into(),
552 ));
553 }
554 let ingress = self
555 .ingress
556 .as_mut()
557 .ok_or_else(|| Error::Encoding("endpoint session ingress owner is missing".into()))?;
558 let receivers = match ingress.start().await {
559 Ok(receivers) => receivers,
560 Err(error) => {
561 let _ = self.stop().await;
562 return Err(error);
563 }
564 };
565 if let Err(error) = self
566 .publish_registered_port(
567 (receivers.bip_port.clone(), receivers.bip6_port.clone()),
568 receivers.egress.local_network_number(),
569 )
570 .await
571 {
572 let _ = self.stop().await;
573 return Err(error);
574 }
575 let bip_local_address = receivers.bip_local_address;
576 if let Err(error) = self.start_roles(receivers, source_routes, device_target) {
577 // Keep cleanup ownership in self before awaiting. Cancellation leaves
578 // Stopping plus intact joins; stop/Drop can still finish teardown.
579 let _ = self.stop().await;
580 return Err(error);
581 }
582 self.bip_local_address = bip_local_address;
583 Ok(())
584 }
585
586 /// Stops dispatch, roles and ingress once; joins termination.
587 ///
588 /// Returns the dispatch [`SessionExit`]. A canceled stop retains its joins
589 /// and sealed membership protection; a later call finishes teardown. Calling before
590 /// `start()` or twice returns
591 /// [`Error::Encoding`]. Takes
592 /// `&mut self` so only the owner can stop; cloned role handles observe
593 /// shutdown and fail closed.
594 ///
595 /// With an attached database, waits for its lock and for queued durable save
596 /// attempts, including corrections for abandoned staged writes. Release any
597 /// application-held database guard so shutdown can progress. A stalled backend
598 /// keeps this call pending; completion does not certify backend success or
599 /// power-loss durability. The database remains attached after termination.
600 pub async fn stop(&mut self) -> Result<SessionExit, Error> {
601 let state = self.lifecycle.load(Ordering::Acquire);
602 if state != Lifecycle::Running as u8 && state != Lifecycle::Stopping as u8 {
603 return Err(Error::Encoding("endpoint session is not running".into()));
604 }
605 self.lifecycle
606 .store(Lifecycle::Stopping as u8, Ordering::Release);
607 self.shared.token.shutdown();
608 if let Some(task) = self.network_number_task.as_mut() {
609 task.abort();
610 let _ = task.await;
611 }
612 self.network_number_task = None;
613 if let Some(source) = &self.source_audit {
614 source.close();
615 }
616 if let Some(requester) = &self.requester {
617 requester.close();
618 }
619 if let Some(responder) = &self.responder {
620 responder.close();
621 }
622 if let Some(notifications) = &self.notifications {
623 notifications.close();
624 }
625 if let Some(cancel) = self.cancel_tx.take() {
626 let _ = cancel.send(());
627 }
628 if self.stop_exit.is_none() {
629 self.stop_exit = Some(match self.dispatch_task.as_mut() {
630 Some(task) => task.await.map_err(|error| {
631 Error::Encoding(format!("endpoint session dispatch failed: {error}"))
632 }),
633 None => Ok(SessionExit::ReceiversClosed),
634 });
635 self.dispatch_task.take();
636 }
637 if let Some(notifications) = &self.notifications {
638 while let Some(result) = notifications.join_next().await {
639 NotificationTransactions::observe(Some(result));
640 }
641 }
642 if !self.ingress_stopped {
643 if let Some(ingress) = self.ingress.as_mut()
644 && let Err(error) = ingress.stop().await
645 && self.stop_exit.as_ref().is_some_and(Result::is_ok)
646 {
647 self.stop_exit = Some(Err(error));
648 }
649 self.ingress_stopped = true;
650 }
651 // The owner remains in the session through every await. The DB barrier
652 // observes completed synchronous commits; queued source readers recheck
653 // the sealed weak sink after taking this guard and cannot reenter it.
654 if let Some(runtime) = &self.source_recipient {
655 runtime.uninstall(
656 &mut *self
657 .database
658 .as_ref()
659 .expect("source database")
660 .write()
661 .await,
662 );
663 }
664 self.source_recipient.take();
665 self.source_audit.take();
666 self.notifications.take();
667 self.requester.take();
668 self.responder.take();
669 self.client_handle = None;
670 self.server_handle = None;
671 if self.durable_settlement.is_none()
672 && let Some(db) = &self.database
673 {
674 self.durable_settlement = Some(tokio::spawn(settle_durable_writes(Arc::clone(db))));
675 }
676 if let Some(task) = self.durable_settlement.as_mut() {
677 // Keep the one settlement owner across canceled stop waiters.
678 let result = task.await;
679 self.durable_settlement = None;
680 result.map_err(|error| {
681 Error::Encoding(format!("endpoint durable settlement failed: {error}"))
682 })?;
683 }
684 self.lifecycle
685 .store(Lifecycle::Stopped as u8, Ordering::Release);
686 self.stop_exit.take().expect("joined dispatch")
687 }
688
689 /// Borrows the client role (`None` when not composed).
690 ///
691 /// Returns `None` for [`ServerOnly`](SessionRole::ServerOnly) sessions,
692 /// and after [`stop`](Self::stop) (handles are released at termination).
693 pub fn client(&self) -> Option<&ClientRoleHandle> {
694 self.client_handle.as_ref()
695 }
696
697 /// Borrows the server role (`None` when not composed).
698 ///
699 /// Returns `None` for [`ClientOnly`](SessionRole::ClientOnly) sessions,
700 /// and after [`stop`](Self::stop).
701 pub fn server(&self) -> Option<&ServerRoleHandle> {
702 self.server_handle.as_ref()
703 }
704
705 /// Clones the client role handle with its session binding.
706 ///
707 /// The clone holds only a [`Weak`](std::sync::Weak) session token: it
708 /// works while the session runs and fails closed after `stop()`/drop.
709 /// Used to hand the client role to tasks that outlive the borrow.
710 pub fn cloned_client_handle(&self) -> Option<ClientRoleHandle> {
711 self.client_handle.clone()
712 }
713
714 /// Clones the server role handle with its session binding.
715 ///
716 /// Same [`Weak`](std::sync::Weak)-token semantics as
717 /// [`cloned_client_handle`](Self::cloned_client_handle); see
718 /// [`is_session_alive`](crate::roles::ServerRoleHandle::is_session_alive).
719 pub fn cloned_server_handle(&self) -> Option<ServerRoleHandle> {
720 self.server_handle.clone()
721 }
722
723 /// Samples policy-outcome ownership counters.
724 ///
725 /// Every classifier/policy outcome is counted, never silently dropped.
726 pub async fn policy_counters(&self) -> PolicyCountersSnapshot {
727 let counters = self.shared.counters.lock().await;
728 PolicyCountersSnapshot {
729 ingress_policy: counters.ingress_policy,
730 no_server_role: counters.no_server_role,
731 no_client_role: counters.no_client_role,
732 unclaimed_terminal: counters.unclaimed_terminal,
733 responder_declined: counters.responder_declined,
734 }
735 }
736
737 /// Samples the shared outbound lease count.
738 ///
739 /// Returns `usize::MAX` only when the coordinator lock is poisoned
740 /// (internal invariant violation surfaced honestly, never hidden).
741 pub fn active_leases(&self) -> usize {
742 self.coordinator.active_count().unwrap_or(usize::MAX)
743 }
744
745 /// Shares the device-wide coordinator (dispatch + tests only).
746 #[doc(hidden)]
747 pub fn coordinator(&self) -> Arc<OutboundTransactionCoordinator> {
748 Arc::clone(&self.coordinator)
749 }
750
751 /// Borrows the composed identity (narrow admin; no lifecycle).
752 ///
753 /// RB-15 narrow admin borrow: roles hold no transport/session lifecycle;
754 /// only the session owner exposes identity + counters + coordinator.
755 /// A role handle attempting `stop`/transport access fails at compile
756 /// time (no such method) and at runtime its post-stop calls fail closed.
757 pub fn identity(&self) -> Option<&crate::identity::DeviceIdentity> {
758 self.identity.as_ref()
759 }
760
761 /// Actual announced B/IP address after successful startup.
762 /// Absent before publication, while stopping, after stop, or for other links.
763 pub fn bip_local_address(&self) -> Option<std::net::SocketAddrV4> {
764 (self.is_running() && self.egress.as_ref().is_some_and(|egress| egress.is_open()))
765 .then_some(self.bip_local_address)
766 .flatten()
767 }
768
769 /// How the transport's foreign-device registration is going (#938):
770 /// whether it holds a live lease, the time left on it, and the last
771 /// attempt's result and outcome, a BBMD's refusal included. It is there
772 /// for a session [`BipEndpointBuilder`](crate::bip::BipEndpointBuilder)
773 /// built, in any mode and after stop, and `None` for any other: take a
774 /// [`BipTransport::bvlc_client_status`](bacnet_transport::bip::BipTransport::bvlc_client_status)
775 /// before handing a transport to [`Self::new`].
776 pub fn foreign_registration(
777 &self,
778 ) -> Option<bacnet_transport::bip::ForeignRegistrationSnapshot> {
779 let status = self.bip_status.as_ref()?;
780 Some(status.snapshot().foreign_registration)
781 }
782
783 /// The MS/TP transport's counters (#958): a handle to the same
784 /// [`MstpDiagnostics`](bacnet_transport::mstp::MstpDiagnostics) the
785 /// transport updates, readable in any state, after stop included. It is
786 /// there for a session [`MstpEndpointBuilder`](crate::mstp::MstpEndpointBuilder)
787 /// built, and `None` for any other: take
788 /// [`MstpTransport::diagnostics`](bacnet_transport::mstp::MstpTransport::diagnostics)
789 /// before handing a transport to [`Self::new`].
790 pub fn mstp_diagnostics(&self) -> Option<bacnet_transport::mstp::MstpDiagnostics> {
791 self.mstp_diagnostics.clone()
792 }
793
794 /// Returns the composed session role (narrow admin).
795 pub fn session_role(&self) -> SessionRole {
796 self.role
797 }
798
799 /// Returns true while the session dispatch is running (narrow admin).
800 pub fn is_running(&self) -> bool {
801 self.lifecycle.load(Ordering::Acquire) == Lifecycle::Running as u8
802 }
803
804 /// Broadcasts an I-Am consistent with the composed identity.
805 ///
806 /// Missing-identity path: errors without sending (no invented device).
807 /// Present-identity path: encodes [`encode_iam_apdu`](crate::identity::encode_iam_apdu)
808 /// and sends one local-broadcast Unconfirmed-Request via the session
809 /// egress. Field-for-field identical to the server discovery I-Am built
810 /// from [`server_config`](crate::identity::server_config).
811 /// Transport-dependent: B/IP reaches the local subnet broadcast; SC
812 /// relays via the hub as a broadcast NPDU. Fails when the session is not
813 /// running.
814 pub async fn broadcast_i_am(&self) -> Result<(), Error> {
815 let identity = self
816 .identity
817 .as_ref()
818 .ok_or_else(|| Error::Encoding("endpoint I-Am requires a composed identity".into()))?;
819 let egress = self
820 .egress
821 .as_ref()
822 .ok_or_else(|| Error::Encoding("endpoint session is not running".into()))?;
823 if !self.is_running() {
824 return Err(Error::Encoding("endpoint session is not running".into()));
825 }
826 let database = self
827 .database
828 .as_ref()
829 .ok_or_else(|| Error::Encoding("endpoint I-Am has no Device database".into()))?;
830 let database = database.read().await;
831 if !database
832 .get(&identity.device_oid())
833 .is_some_and(|object| object.object_identifier() == identity.device_oid())
834 {
835 return Err(Error::Encoding("endpoint I-Am Device is absent".into()));
836 }
837 let apdu = crate::identity::encode_iam_apdu(identity)?;
838 egress
839 .send_apdu(
840 apdu,
841 bacnet_endpoint_core::endpoint_ingress::EndpointApduDestination::LocalBroadcast,
842 false,
843 bacnet_npdu::NetworkPriority::Normal,
844 Vec::new(),
845 )
846 .await
847 }
848}
849
850#[cfg(test)]
851#[path = "source_reporter_tests.rs"]
852mod source_reporter_tests;
853
854#[cfg(test)]
855#[path = "device_write_tests.rs"]
856mod device_write_tests;
857
858impl<T: TransportPort + 'static> Drop for EndpointSession<T> {
859 fn drop(&mut self) {
860 // Synchronous abort path: never orphan dispatch/ingress/role work.
861 // `stop()` remains the graceful path; Drop only seals + aborts.
862 self.shared.token.shutdown();
863 if let Some(task) = &self.network_number_task {
864 task.abort();
865 }
866 if let Some(source) = self.source_audit.take() {
867 source.close();
868 }
869 if let Some(requester) = self.requester.take() {
870 requester.close();
871 }
872 if let Some(responder) = self.responder.take() {
873 responder.close();
874 }
875 if let Some(notifications) = self.notifications.take() {
876 notifications.close();
877 }
878 if let Some(cancel) = self.cancel_tx.take() {
879 let _ = cancel.send(());
880 }
881 if let Some(task) = self.dispatch_task.take() {
882 task.abort();
883 }
884 if let Some(task) = self.durable_settlement.take() {
885 task.abort();
886 }
887 // The session's own handle, kept through stop(), goes off the
888 // runtime if it is the last (#1561). Every other endpoint holder
889 // (responder, source Audit, the aborted tasks' handles) lets go the
890 // same way when it drops, whichever goes last.
891 if let Some(db) = self.database.take() {
892 drop(drop_database_off_runtime(db));
893 }
894 }
895}
896
897struct DispatchParts {
898 inbound: mpsc::Receiver<ReceivedApdu>,
899 terminal: mpsc::Receiver<ReceivedApdu>,
900 policy: mpsc::Receiver<PolicyOutcome>,
901 requester: Option<EndpointRequester>,
902 responder: Option<Arc<EndpointResponder>>,
903 notifications: Option<Arc<NotificationTransactions>>,
904 coordinator: Arc<OutboundTransactionCoordinator>,
905 shared: Arc<SessionShared>,
906 /// The layer's network number, which the session's Number owner
907 /// publishes; answers relayed with it as SNET match like direct ones.
908 local_network: LocalNetworkNumber,
909}
910
911#[path = "session_dispatch.rs"]
912mod dispatch;
913use dispatch::{DispatchEvent, next_event};
914
915async fn dispatch_loop(mut parts: DispatchParts, mut cancel: oneshot::Receiver<()>) -> SessionExit {
916 // One consumer retains all receivers and joins; cancellation stays first.
917 let mut next = 0;
918 loop {
919 let event = tokio::select! {
920 biased;
921 _ = &mut cancel => return SessionExit::Cancelled,
922 event = next_event(&mut parts, &mut next) => event,
923 };
924 match event {
925 DispatchEvent::Worker(joined) => NotificationTransactions::observe(Some(joined)),
926 DispatchEvent::Inbound(received) => handle_inbound(&mut parts, received).await,
927 DispatchEvent::Terminal(received) => handle_terminal(&mut parts, received).await,
928 DispatchEvent::Policy(outcome) => handle_ingress_policy(&parts.shared, outcome).await,
929 DispatchEvent::Closed => return SessionExit::ReceiversClosed,
930 }
931 }
932}
933
934async fn handle_inbound(parts: &mut DispatchParts, received: ReceivedApdu) {
935 // Preserve the full envelope structurally (raw + effective group,
936 // attributes, ingress identity, provenance) — no new decisions here
937 // beyond role presence + responder scope.
938 let _peer = inbound_canonical_peer(&received, parts.local_network.get());
939 let _link_group = received.link_layer_group;
940 let _is_group = received.is_group;
941 let _attributes = received.data_attributes.clone();
942 let _provenance = &received.provenance;
943 let _ingress = received.ingress_network;
944 let Some(responder) = parts.responder.as_ref() else {
945 let mut counters = parts.shared.counters.lock().await;
946 counters.no_server_role += 1;
947 // One-use reply ownership: release a lone reply sender without
948 // sending bytes, mirroring queue-admission drop semantics.
949 if let Some(reply_tx) = received.reply_tx {
950 drop(reply_tx);
951 }
952 return;
953 };
954 // RB-17 deferred-reply wiring (minimal correlation, session-side): when
955 // the one-shot suspension arm is set and this request carries the MS/TP
956 // one-use prompt reply sender, drop the sender BEFORE the responder sees
957 // it. Dropping releases the MAC to send ReplyPostponed; the responder
958 // then observes `reply_tx: None` and answers through the single
959 // EndpointEgress → queue_npdu path, which the MAC transmits as
960 // DataNotExpectingReply at the next token opportunity with the identical
961 // wire invoke ID + destination. Prompt (sender present, unarmed) and
962 // token-owned (sender absent) stay distinct by construction: the
963 // responder never sees both for one request. No second serial owner, no
964 // MAC state duplication, no transport change.
965 let mut received = received;
966 if !received.provenance.is_direct_peer()
967 && received.direct_response.is_none()
968 && received.reply_tx.is_some()
969 && parts.shared.token.take_suspend()
970 {
971 let _ = received.reply_tx.take();
972 }
973 match responder.handle(received).await {
974 Ok(true) | Ok(false) => {}
975 Err(_) => {
976 let mut counters = parts.shared.counters.lock().await;
977 counters.responder_declined += 1;
978 }
979 }
980}
981
982async fn handle_terminal(parts: &mut DispatchParts, received: ReceivedApdu) {
983 let Some(apdu) = decode_terminal(&received) else {
984 let mut counters = parts.shared.counters.lock().await;
985 counters.unclaimed_terminal += 1;
986 if let Some(reply_tx) = received.reply_tx {
987 drop(reply_tx);
988 }
989 return;
990 };
991 // Inbound server transactions never allocate here: requests are not
992 // terminal traffic. A Confirmed/Unconfirmed request in this queue is a
993 // classifier violation → policy-owned, no coordinator interaction.
994 if matches!(
995 apdu,
996 Apdu::ConfirmedRequest(_) | Apdu::UnconfirmedRequest(_)
997 ) {
998 let mut counters = parts.shared.counters.lock().await;
999 counters.unclaimed_terminal += 1;
1000 if let Some(reply_tx) = received.reply_tx {
1001 drop(reply_tx);
1002 }
1003 return;
1004 }
1005 // Exactly one shared-coordinator admit per terminal APDU (exact-once
1006 // terminal claim). Equal numeric IDs are unambiguous: the admitted
1007 // lease owner selects requester vs notification.
1008 let outcome = admit_once(
1009 &parts.coordinator,
1010 &received,
1011 parts.local_network.get(),
1012 &apdu,
1013 );
1014 let admission = match outcome {
1015 Ok(AdmissionOutcome::Admitted(admission)) => admission,
1016 Ok(_) => {
1017 let mut counters = parts.shared.counters.lock().await;
1018 counters.unclaimed_terminal += 1;
1019 if let Some(reply_tx) = received.reply_tx {
1020 drop(reply_tx);
1021 }
1022 return;
1023 }
1024 Err(CoordinatorError::StatePoisoned) => {
1025 let mut counters = parts.shared.counters.lock().await;
1026 counters.unclaimed_terminal += 1;
1027 if let Some(reply_tx) = received.reply_tx {
1028 drop(reply_tx);
1029 }
1030 return;
1031 }
1032 };
1033 if is_requester_lease(&admission) {
1034 let Some(requester) = parts.requester.as_ref() else {
1035 // Lease was admitted but has no consumer: release the exact
1036 // lease so exhaustion → release → reuse still holds.
1037 let _ = parts.coordinator.release(admission.token());
1038 let mut counters = parts.shared.counters.lock().await;
1039 counters.no_client_role += 1;
1040 if let Some(reply_tx) = received.reply_tx {
1041 drop(reply_tx);
1042 }
1043 return;
1044 };
1045 // `complete_pre_admitted` delivers via the exact token without
1046 // re-admitting; a `false` means the TSM no longer owns it (lost
1047 // consumer / stale), the coordinator lease was already completed
1048 // inside the TSM path on success.
1049 let _ = requester
1050 .complete_pre_admitted(admission, apdu, received)
1051 .await;
1052 } else {
1053 let Some(notifications) = parts.notifications.as_ref() else {
1054 let _ = parts.coordinator.release(admission.token());
1055 let mut counters = parts.shared.counters.lock().await;
1056 counters.no_client_role += 1;
1057 if let Some(reply_tx) = received.reply_tx {
1058 drop(reply_tx);
1059 }
1060 return;
1061 };
1062 let _ = notifications.complete_pre_admitted(admission, &apdu);
1063 // Notification replies carry no reply sender; drop one if present.
1064 if let Some(reply_tx) = received.reply_tx {
1065 drop(reply_tx);
1066 }
1067 }
1068}
1069
1070async fn handle_ingress_policy(shared: &Arc<SessionShared>, outcome: PolicyOutcome) {
1071 // Policy-outcome ownership: the session counts every classifier outcome
1072 // (malformed/unsupported/route-full/route-closed) instead of dropping it.
1073 let mut counters = shared.counters.lock().await;
1074 counters.ingress_policy += 1;
1075 let _ = outcome.reason;
1076 // Policy outcomes own the full envelope including a possible one-use
1077 // reply sender; releasing here preserves single-consumer + one-use
1078 // ownership without sending bytes.
1079 if let Some(reply_tx) = outcome.received.reply_tx {
1080 drop(reply_tx);
1081 }
1082 let _ = PolicyReason::MalformedApdu;
1083}
1084
1085#[cfg(test)]
1086#[path = "source_read_tests.rs"]
1087mod source_read_tests;
1088
1089#[cfg(test)]
1090#[path = "registered_port_bip6_tests.rs"]
1091mod registered_port_bip6_tests;
1092#[cfg(test)]
1093#[path = "registered_port_lifetime_tests.rs"]
1094mod registered_port_lifetime_tests;
1095#[cfg(test)]
1096#[path = "registered_port_wire_tests.rs"]
1097mod registered_port_wire_tests;
1098
1099#[cfg(test)]
1100#[path = "network_number_tests.rs"]
1101mod network_number_tests;
1102
1103/// Reruns a test whose closing rebind loses the freed port to another
1104/// process (#1419).
1105#[cfg(test)]
1106#[path = "../tests/support/port_retry.rs"]
1107mod port_retry;
1108
1109#[cfg(test)]
1110#[path = "local_network_tests.rs"]
1111mod local_network_tests;
1112
1113#[cfg(test)]
1114#[path = "read_work_limit_tests.rs"]
1115mod read_work_limit_tests;
1116
1117#[cfg(test)]
1118#[path = "client_pacing_tests.rs"]
1119mod client_pacing_tests;
1120
1121#[path = "device_identity.rs"]
1122mod device_identity;