Skip to main content

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;