Skip to main content

rvoip_core/
adapter.rs

1use crate::capability::{CapabilityDescriptor, NegotiatedCodecs};
2use crate::commands::{AudioSource, MuteDirection};
3use crate::connection::Transport;
4use crate::error::{Result, RvoipError};
5use crate::identity::IdentityAssurance;
6use crate::ids::ConnectionId;
7use crate::message::Message;
8use crate::stream::MediaStream;
9use crate::DataMessage;
10use std::fmt;
11use std::sync::{Arc, OnceLock};
12use tokio::sync::mpsc;
13
14pub use rvoip_core_traits::adapter::{
15    AdapterEvent, AdapterKind, ConnectionHandle, EndReason, ExternalConnectionReference,
16    ExternalConnectionReferenceError, InboundConnectionContext, InboundContextError,
17    InboundRoutingHint, InboundSignalingMetadata, OriginateContext, OriginateRequest,
18    OutboundActivation, PlaybackHandle, RejectReason, SignatureHeaders, TransferStatus,
19    TransferTarget, MAX_EXTERNAL_CONNECTION_REFERENCES, MAX_EXTERNAL_REFERENCE_KIND_BYTES,
20    MAX_EXTERNAL_REFERENCE_VALUE_BYTES, MAX_INBOUND_ROUTING_HINT_BYTES,
21};
22pub use rvoip_core_traits::ids::TransferAttemptId;
23
24/// Core-private adapter-to-Orchestrator event envelope.
25///
26/// This type is public only because [`ConnectionAdapter`] is implemented by
27/// transport crates. Application-facing subscriptions continue to expose
28/// [`AdapterEvent`] and therefore retain its existing source surface.
29#[doc(hidden)]
30#[derive(Clone)]
31#[non_exhaustive]
32pub enum OrchestratorAdapterEvent {
33    Public(AdapterEvent),
34    AuthenticatedInboundConnection {
35        connection: crate::connection::Connection,
36        participant_id: String,
37        principal: crate::identity::AuthenticatedPrincipal,
38    },
39}
40
41impl fmt::Debug for OrchestratorAdapterEvent {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Public(event) => formatter.debug_tuple("Public").field(event).finish(),
45            Self::AuthenticatedInboundConnection { connection, .. } => formatter
46                .debug_struct("AuthenticatedInboundConnection")
47                .field("transport", &connection.transport)
48                .field("direction", &connection.direction)
49                .field("state", &connection.state)
50                .field("stream_count", &connection.streams.len())
51                .field("participant_present", &true)
52                .field("principal_present", &true)
53                .finish(),
54        }
55    }
56}
57
58impl From<AdapterEvent> for OrchestratorAdapterEvent {
59    fn from(event: AdapterEvent) -> Self {
60        Self::Public(event)
61    }
62}
63
64/// Direct fallback for terminal adapter events when the adapter's bounded
65/// event queue is saturated or closed.
66///
67/// The Orchestrator implementation invalidates/removes the connection before
68/// awaiting the remaining media cleanup. Adapters invoke this only after
69/// removing their own route and stream state; the peer task retains its
70/// bounded admission permit until cleanup converges.
71#[async_trait::async_trait]
72pub trait AdapterLifecycleSink: Send + Sync {
73    async fn deliver_terminal(&self, event: AdapterEvent);
74}
75
76/// Shareable, late-bound lifecycle sink used by adapters whose server loops
77/// start before the adapter is registered with an Orchestrator.
78#[derive(Clone, Default)]
79pub struct AdapterLifecycleSinkSlot {
80    inner: Arc<OnceLock<Arc<dyn AdapterLifecycleSink>>>,
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum TerminalDelivery {
85    Queued,
86    Fallback,
87    Undeliverable,
88}
89
90/// Lifecycle guarantees an adapter can provide to the Orchestrator.
91///
92/// All fields default to `false`. This keeps third-party adapter
93/// implementations source compatible while allowing security-sensitive core
94/// features to reject an adapter that cannot satisfy their fail-closed
95/// contract.
96#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub struct AdapterLifecycleCapabilities {
98    /// [`ConnectionAdapter::is_connection_live`] is authoritative for every
99    /// route owned by this adapter.
100    pub authoritative_liveness: bool,
101    /// Authenticated inbound identity and connection creation are delivered
102    /// as one [`OrchestratorAdapterEvent::AuthenticatedInboundConnection`]
103    /// before any operational event for that connection.
104    pub atomic_inbound_handoff: bool,
105    /// The adapter installs and uses [`AdapterLifecycleSink`] when its normal
106    /// bounded event path cannot deliver a terminal event.
107    pub terminal_fallback: bool,
108    /// Outbound routes remain externally dormant after `originate` returns:
109    /// they publish no peer-visible call signaling and release no lifecycle
110    /// event until core calls the outbound activation hook. Local allocation,
111    /// SDP construction, and ICE gathering are allowed during preparation.
112    pub staged_outbound_activation: bool,
113}
114
115impl AdapterLifecycleCapabilities {
116    /// Capabilities required by the Orchestrator's fail-closed inbound
117    /// admission gate.
118    pub const FAIL_CLOSED_INBOUND: Self = Self {
119        authoritative_liveness: true,
120        atomic_inbound_handoff: true,
121        terminal_fallback: true,
122        staged_outbound_activation: false,
123    };
124
125    pub const fn supports_fail_closed_inbound(self) -> bool {
126        self.authoritative_liveness && self.atomic_inbound_handoff && self.terminal_fallback
127    }
128}
129
130impl AdapterLifecycleSinkSlot {
131    pub fn install(
132        &self,
133        sink: Arc<dyn AdapterLifecycleSink>,
134    ) -> std::result::Result<(), Arc<dyn AdapterLifecycleSink>> {
135        self.inner.set(sink)
136    }
137
138    /// Deliver a terminal event through the installed fallback. Returns
139    /// `false` when the adapter has not been registered with an Orchestrator.
140    pub async fn deliver_terminal(&self, event: AdapterEvent) -> bool {
141        let Some(sink) = self.inner.get().cloned() else {
142            return false;
143        };
144        sink.deliver_terminal(event).await;
145        true
146    }
147
148    /// Prefer the adapter's normal bounded event queue so terminal events
149    /// retain FIFO ordering. If that queue is full or closed, invoke the
150    /// direct lifecycle sink instead of waiting indefinitely or allocating an
151    /// unbounded overflow queue.
152    pub async fn queue_or_deliver_terminal(
153        &self,
154        events: &mpsc::Sender<AdapterEvent>,
155        event: AdapterEvent,
156    ) -> TerminalDelivery {
157        match events.try_send(event) {
158            Ok(()) => TerminalDelivery::Queued,
159            Err(mpsc::error::TrySendError::Full(event))
160            | Err(mpsc::error::TrySendError::Closed(event)) => {
161                if self.deliver_terminal(event).await {
162                    TerminalDelivery::Fallback
163                } else {
164                    TerminalDelivery::Undeliverable
165                }
166            }
167        }
168    }
169
170    /// Atomic-stream counterpart to [`Self::queue_or_deliver_terminal`].
171    pub async fn queue_or_deliver_orchestrator_terminal(
172        &self,
173        events: &mpsc::Sender<OrchestratorAdapterEvent>,
174        event: AdapterEvent,
175    ) -> TerminalDelivery {
176        match events.try_send(OrchestratorAdapterEvent::Public(event)) {
177            Ok(()) => TerminalDelivery::Queued,
178            Err(mpsc::error::TrySendError::Full(OrchestratorAdapterEvent::Public(event)))
179            | Err(mpsc::error::TrySendError::Closed(OrchestratorAdapterEvent::Public(event))) => {
180                if self.deliver_terminal(event).await {
181                    TerminalDelivery::Fallback
182                } else {
183                    TerminalDelivery::Undeliverable
184                }
185            }
186            Err(mpsc::error::TrySendError::Full(_)) | Err(mpsc::error::TrySendError::Closed(_)) => {
187                debug_assert!(false, "terminal event wrapper changed unexpectedly");
188                TerminalDelivery::Undeliverable
189            }
190        }
191    }
192}
193
194/// Expand atomic authenticated-inbound events into the historical direct
195/// adapter sequence without changing the Orchestrator's source queue.
196///
197/// The input receiver has already accepted the connection and its principal
198/// as one bounded item. This forwarding task preserves event order and awaits
199/// both compatibility events before reading the next source item. It is used
200/// only for explicit direct adapter subscriptions; Orchestrator registration
201/// consumes the unexpanded receiver through
202/// [`ConnectionAdapter::subscribe_orchestrator_events`].
203pub fn legacy_normalized_event_receiver(
204    mut source: mpsc::Receiver<OrchestratorAdapterEvent>,
205    capacity: usize,
206) -> mpsc::Receiver<AdapterEvent> {
207    let (events, receiver) = mpsc::channel(capacity.max(2));
208    tokio::spawn(async move {
209        while let Some(event) = source.recv().await {
210            match event {
211                OrchestratorAdapterEvent::AuthenticatedInboundConnection {
212                    connection,
213                    participant_id,
214                    principal,
215                } => {
216                    let connection_id = connection.id.clone();
217                    if events
218                        .send(AdapterEvent::InboundConnection { connection })
219                        .await
220                        .is_err()
221                    {
222                        break;
223                    }
224                    if events
225                        .send(AdapterEvent::PrincipalAuthenticated {
226                            connection_id,
227                            participant_id,
228                            principal,
229                        })
230                        .await
231                        .is_err()
232                    {
233                        break;
234                    }
235                }
236                OrchestratorAdapterEvent::Public(event) => {
237                    if events.send(event).await.is_err() {
238                        break;
239                    }
240                }
241            }
242        }
243    });
244    receiver
245}
246
247/// The cross-transport adapter contract. Every transport-specific crate
248/// (rvoip-sip, rvoip-webrtc, rvoip-quic, rvoip-webtransport, rvoip-websocket)
249/// implements this so the [`crate::Orchestrator`] can dispatch generically.
250#[async_trait::async_trait]
251pub trait ConnectionAdapter: Send + Sync {
252    fn transport(&self) -> Transport;
253    fn kind(&self) -> AdapterKind;
254
255    /// Explicit lifecycle guarantees implemented by this adapter.
256    ///
257    /// The default advertises no guarantees. In particular, overriding only
258    /// one of `install_lifecycle_sink`, `is_connection_live`, or
259    /// `subscribe_orchestrator_events` is not enough to opt into fail-closed
260    /// inbound admission: the adapter must advertise the complete contract.
261    fn lifecycle_capabilities(&self) -> AdapterLifecycleCapabilities {
262        AdapterLifecycleCapabilities::default()
263    }
264
265    /// Whether this adapter consumes final inbound-admission confirmations.
266    ///
267    /// This is intentionally a separate, source-compatible capability rather
268    /// than a field on [`AdapterLifecycleCapabilities`]. Existing adapters
269    /// therefore remain build-compatible and default to the historical
270    /// behavior.
271    fn supports_inbound_admission_confirmation(&self) -> bool {
272        false
273    }
274
275    /// Report the final policy outcome for one exact inbound lifecycle.
276    ///
277    /// Core calls this synchronously, at most once for a
278    /// `(transport, connection_id, lifecycle_generation)` tuple, and only
279    /// when an inbound admission gate is installed. `accepted` becomes true
280    /// only after publication has committed; every fail-closed disposition is
281    /// false. The callback deliberately carries no principal, credentials,
282    /// attachment context, or signaling metadata.
283    ///
284    /// Implementations must return promptly and be idempotent. An adapter may
285    /// use the generation to reject a delayed notification for a superseded
286    /// local waiter. Waiting for this callback belongs inside the adapter's
287    /// protocol task; it must not block the Orchestrator event loop.
288    /// `accepted=true` is a policy/publication result, not a continuing
289    /// liveness guarantee: a terminal event may follow immediately and must
290    /// independently cancel or close the adapter's protocol waiter/response.
291    fn notify_inbound_admission_outcome(
292        &self,
293        _connection_id: &ConnectionId,
294        _lifecycle_generation: u64,
295        _accepted: bool,
296    ) {
297    }
298
299    /// Install the Orchestrator's terminal-event fallback. The default is a
300    /// no-op for adapters that cannot overrun their lifecycle event path.
301    fn install_lifecycle_sink(&self, _sink: Arc<dyn AdapterLifecycleSink>) -> Result<()> {
302        Ok(())
303    }
304
305    /// Whether the adapter still owns a live route for `conn`. The
306    /// Orchestrator consults this before accepting queued inbound/principal
307    /// events, preventing an event that was queued before abrupt teardown
308    /// from resurrecting a cleaned connection.
309    fn is_connection_live(&self, _conn: &ConnectionId) -> bool {
310        true
311    }
312
313    /// Take adapter-owned context captured for one inbound connection.
314    ///
315    /// Implementations must bind the value to the exact connection,
316    /// transport, and authenticated principal that produced it and return it
317    /// at most once. The default keeps existing adapters source compatible.
318    fn take_inbound_context(&self, _conn: &ConnectionId) -> Option<InboundConnectionContext> {
319        None
320    }
321
322    /// Subscribe to the adapter's atomic lifecycle stream for Orchestrator use.
323    ///
324    /// The default preserves source and behavioral compatibility for adapters
325    /// that do not distinguish their public event stream. SIP and WebRTC
326    /// override this method so an authenticated inbound handoff
327    /// remains one bounded queue item on the security-sensitive path while
328    /// their legacy public subscription continues to expand that item into
329    /// `InboundConnection` followed by `PrincipalAuthenticated`.
330    fn subscribe_orchestrator_events(&self) -> mpsc::Receiver<OrchestratorAdapterEvent> {
331        let mut public = self.subscribe_events();
332        let (events, receiver) = mpsc::channel(256);
333        tokio::spawn(async move {
334            while let Some(event) = public.recv().await {
335                if events
336                    .send(OrchestratorAdapterEvent::Public(event))
337                    .await
338                    .is_err()
339                {
340                    break;
341                }
342            }
343        });
344        receiver
345    }
346
347    /// Create an outbound route.
348    ///
349    /// Adapters advertising
350    /// [`AdapterLifecycleCapabilities::staged_outbound_activation`] must
351    /// return a live but externally dormant route. `originate` may allocate
352    /// local protocol state, construct SDP, and gather ICE, but it must not
353    /// send a peer-visible call command (for example SIP INVITE or a provider
354    /// originate request). The adapter also retains operational, principal,
355    /// and terminal events in one bounded FIFO until activation. This ordering
356    /// lets the Orchestrator claim and durably bind the returned ID before an
357    /// external call exists or an event can refer to it. Core deliberately
358    /// does not stage events for unknown IDs.
359    async fn originate(&self, request: OriginateRequest) -> Result<ConnectionHandle>;
360
361    /// Publish peer-visible signaling and release events for a successfully
362    /// claimed outbound route.
363    ///
364    /// The default is a no-op for legacy adapters. Implementations that
365    /// advertise staged outbound activation must make this operation
366    /// idempotent and release retained events in FIFO order. Adapters that do
367    /// not advertise it are compatibility-only for outbound origination:
368    /// core cannot recover an operational, principal, or terminal event that
369    /// such an adapter emits before `originate` returns its previously
370    /// unknown connection ID.
371    async fn activate_outbound(&self, _conn: ConnectionId) -> Result<()> {
372        Ok(())
373    }
374
375    /// Activate a claimed outbound route and return its opaque activation
376    /// receipt.
377    ///
378    /// Core calls this hook for both prepared and legacy outbound paths. The
379    /// default preserves existing adapters by delegating to
380    /// [`Self::activate_outbound`] and returning an empty receipt. Adapters
381    /// that own stable external identifiers should override this method,
382    /// perform the activation exactly once, and include those identifiers in
383    /// the returned [`OutboundActivation`]. The override must be idempotent
384    /// and return the same identifiers after a repeated activation. Core
385    /// exposes the receipt only after activation, route liveness, lifecycle,
386    /// and event-stream checks all succeed.
387    async fn activate_outbound_with_receipt(
388        &self,
389        conn: ConnectionId,
390    ) -> Result<OutboundActivation> {
391        self.activate_outbound(conn).await?;
392        Ok(OutboundActivation::default())
393    }
394
395    /// Start provisional inbound media without finally accepting the route.
396    ///
397    /// The default is unsupported. SIP adapters use this to negotiate and
398    /// send a 183 response with SDP while an [`InboundAdmission`](crate::InboundAdmission)
399    /// remains unresolved. Implementations must not emit a final answer or a
400    /// transport-neutral `Connected` event from this operation.
401    async fn start_inbound_early_media(&self, _conn: ConnectionId) -> Result<()> {
402        Err(RvoipError::NotImplemented(
403            "adapter does not support provisional inbound early media",
404        ))
405    }
406
407    async fn accept(&self, conn: ConnectionId) -> Result<()>;
408    async fn reject(&self, conn: ConnectionId, reason: RejectReason) -> Result<()>;
409    async fn end(&self, conn: ConnectionId, reason: EndReason) -> Result<()>;
410    async fn hold(&self, conn: ConnectionId) -> Result<()>;
411    async fn resume(&self, conn: ConnectionId) -> Result<()>;
412    async fn transfer(&self, conn: ConnectionId, target: TransferTarget) -> Result<()>;
413
414    /// Submit a transfer with an application-owned correlation identifier.
415    ///
416    /// The default preserves compatibility by delegating to [`Self::transfer`].
417    /// Adapters that can bind protocol status to the exact submitted transfer
418    /// override this method and echo `attempt_id` in
419    /// [`AdapterEvent::TransferStatus`].
420    async fn transfer_with_attempt(
421        &self,
422        conn: ConnectionId,
423        _attempt_id: TransferAttemptId,
424        target: TransferTarget,
425    ) -> Result<()> {
426        self.transfer(conn, target).await
427    }
428
429    async fn streams(&self, conn: ConnectionId) -> Result<Vec<Arc<dyn MediaStream>>>;
430
431    /// Allocate a fresh per-`(subscriber, publisher_strm)` MediaStream for
432    /// the multi-party fanout path (plan §12 MP3c / G4). Required so a
433    /// subscriber in an N-party room can demultiplex datagrams from
434    /// multiple upstream publishers via distinct `stream_local_id`s on
435    /// the wire — without this, all publishers land on the subscriber's
436    /// default stream and the audio mixes at the jitter buffer.
437    ///
438    /// The default implementation returns
439    /// [`RvoipError::NotImplemented`] so non-UCTP adapters (SIP,
440    /// WebRTC) — which don't carry multi-party fanout responsibility —
441    /// can stay unchanged. UCTP-family adapters override this to:
442    /// 1. Allocate a fresh `stream_local_id` on the subscriber's
443    ///    substrate connection.
444    /// 2. Construct a directional `MediaStream` with that id.
445    /// 3. Register it in the per-peer streams map so subsequent
446    ///    [`Self::streams`] calls return it and inbound datagrams on
447    ///    that id route correctly (subscribers may publish back).
448    /// 4. Emit a `stream.opened` envelope to the peer announcing the
449    ///    new id per CONVERSATION_PROTOCOL.md §10.1 multi-party note.
450    ///
451    /// `Orchestrator::fanout_frame` falls back to the legacy
452    /// pick-by-kind behavior when this returns `NotImplemented`, so
453    /// single-publisher rooms keep working everywhere.
454    async fn allocate_subscriber_stream(
455        &self,
456        _subscriber: ConnectionId,
457        _kind: crate::stream::StreamKind,
458        _codec: crate::capability::CodecInfo,
459    ) -> Result<Arc<dyn MediaStream>> {
460        Err(RvoipError::NotImplemented(
461            "ConnectionAdapter::allocate_subscriber_stream",
462        ))
463    }
464
465    async fn send_message(&self, conn: ConnectionId, message: Message) -> Result<()>;
466    async fn send_data_message(&self, _conn: ConnectionId, _message: DataMessage) -> Result<()> {
467        Err(RvoipError::NotImplemented(
468            "ConnectionAdapter::send_data_message",
469        ))
470    }
471    async fn send_dtmf(&self, conn: ConnectionId, digits: &str, duration_ms: u32) -> Result<()>;
472    async fn renegotiate_media(
473        &self,
474        conn: ConnectionId,
475        capabilities: CapabilityDescriptor,
476    ) -> Result<NegotiatedCodecs>;
477
478    /// P2 — local mute/unmute on a per-direction basis. Default
479    /// `NotImplemented` so adapters opt in; the Orchestrator surfaces
480    /// the error verbatim when a caller invokes mute against a
481    /// transport that hasn't wired it.
482    async fn mute(&self, _conn: ConnectionId, _direction: MuteDirection) -> Result<()> {
483        Err(RvoipError::NotImplemented("ConnectionAdapter::mute"))
484    }
485    async fn unmute(&self, _conn: ConnectionId, _direction: MuteDirection) -> Result<()> {
486        Err(RvoipError::NotImplemented("ConnectionAdapter::unmute"))
487    }
488
489    /// P2 — play `source` toward the peer on `conn`. Adapters that
490    /// implement this construct a [`PlaybackHandle`] via
491    /// [`PlaybackHandle::new`], spawn the playback task watching the
492    /// returned `cancel_rx`, and return the handle. Default
493    /// `NotImplemented`.
494    async fn play_audio(
495        &self,
496        _conn: ConnectionId,
497        _source: AudioSource,
498    ) -> Result<PlaybackHandle> {
499        Err(RvoipError::NotImplemented("ConnectionAdapter::play_audio"))
500    }
501
502    /// P12.6 — send an `identity.step-up-request` envelope to the peer
503    /// asking them to present higher-assurance credentials. The peer's
504    /// `identity.step-up-response` arrives as
505    /// [`AdapterEvent::StepUpResponse`] which the orchestrator
506    /// re-emits as [`crate::events::Event::IdentityStepUpResponseReceived`].
507    /// UCTP-family adapters override this; SIP / WebRTC default to
508    /// `NotImplemented` since step-up is a UCTP-native flow per
509    /// CONVERSATION_PROTOCOL.md §5.8.
510    async fn send_step_up_request(
511        &self,
512        _conn: ConnectionId,
513        _required: crate::capability::IdentityAssuranceRequirement,
514        _allowed_methods: Vec<String>,
515        _reason: Option<String>,
516    ) -> Result<()> {
517        Err(RvoipError::NotImplemented(
518            "ConnectionAdapter::send_step_up_request",
519        ))
520    }
521
522    fn subscribe_events(&self) -> mpsc::Receiver<AdapterEvent>;
523    fn capabilities(&self) -> CapabilityDescriptor;
524
525    async fn verify_request_signature(
526        &self,
527        conn: ConnectionId,
528        signature: SignatureHeaders,
529    ) -> Result<IdentityAssurance>;
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use std::sync::atomic::{AtomicBool, Ordering};
536
537    struct RecordingSink {
538        delivered: AtomicBool,
539    }
540
541    #[async_trait::async_trait]
542    impl AdapterLifecycleSink for RecordingSink {
543        async fn deliver_terminal(&self, event: AdapterEvent) {
544            assert!(matches!(event, AdapterEvent::Ended { .. }));
545            self.delivered.store(true, Ordering::Release);
546        }
547    }
548
549    fn terminal_event() -> AdapterEvent {
550        AdapterEvent::Ended {
551            connection_id: ConnectionId::new(),
552            reason: EndReason::Normal,
553        }
554    }
555
556    #[tokio::test]
557    async fn saturated_event_queue_uses_direct_terminal_fallback() {
558        let (events_tx, _events_rx) = mpsc::channel(1);
559        events_tx
560            .try_send(AdapterEvent::Native {
561                kind: "occupied",
562                detail: "queue full".into(),
563            })
564            .expect("fill event queue");
565
566        let sink = Arc::new(RecordingSink {
567            delivered: AtomicBool::new(false),
568        });
569        let slot = AdapterLifecycleSinkSlot::default();
570        assert!(slot.install(sink.clone()).is_ok());
571
572        assert_eq!(
573            slot.queue_or_deliver_terminal(&events_tx, terminal_event())
574                .await,
575            TerminalDelivery::Fallback
576        );
577        assert!(sink.delivered.load(Ordering::Acquire));
578    }
579
580    #[tokio::test]
581    async fn closed_event_queue_uses_direct_terminal_fallback() {
582        let (events_tx, events_rx) = mpsc::channel(1);
583        drop(events_rx);
584        let sink = Arc::new(RecordingSink {
585            delivered: AtomicBool::new(false),
586        });
587        let slot = AdapterLifecycleSinkSlot::default();
588        assert!(slot.install(sink.clone()).is_ok());
589
590        assert_eq!(
591            slot.queue_or_deliver_terminal(&events_tx, terminal_event())
592                .await,
593            TerminalDelivery::Fallback
594        );
595        assert!(sink.delivered.load(Ordering::Acquire));
596    }
597
598    #[tokio::test]
599    async fn available_event_queue_preserves_normal_terminal_ordering() {
600        let (events_tx, mut events_rx) = mpsc::channel(1);
601        let sink = Arc::new(RecordingSink {
602            delivered: AtomicBool::new(false),
603        });
604        let slot = AdapterLifecycleSinkSlot::default();
605        assert!(slot.install(sink.clone()).is_ok());
606
607        assert_eq!(
608            slot.queue_or_deliver_terminal(&events_tx, terminal_event())
609                .await,
610            TerminalDelivery::Queued
611        );
612        assert!(!sink.delivered.load(Ordering::Acquire));
613        assert!(matches!(
614            events_rx.try_recv().expect("queued terminal event"),
615            AdapterEvent::Ended { .. }
616        ));
617    }
618
619    #[tokio::test]
620    async fn unregistered_saturated_queue_reports_undeliverable_terminal() {
621        let (events_tx, _events_rx) = mpsc::channel(1);
622        events_tx
623            .try_send(AdapterEvent::Native {
624                kind: "occupied",
625                detail: "queue full".into(),
626            })
627            .expect("fill event queue");
628
629        assert_eq!(
630            AdapterLifecycleSinkSlot::default()
631                .queue_or_deliver_terminal(&events_tx, terminal_event())
632                .await,
633            TerminalDelivery::Undeliverable
634        );
635    }
636
637    #[tokio::test]
638    async fn second_sink_install_is_rejected_and_first_sink_is_retained() {
639        let first = Arc::new(RecordingSink {
640            delivered: AtomicBool::new(false),
641        });
642        let second = Arc::new(RecordingSink {
643            delivered: AtomicBool::new(false),
644        });
645        let slot = AdapterLifecycleSinkSlot::default();
646        assert!(slot.install(first.clone()).is_ok());
647        assert!(slot.install(second.clone()).is_err());
648
649        assert!(slot.deliver_terminal(terminal_event()).await);
650        assert!(first.delivered.load(Ordering::Acquire));
651        assert!(!second.delivered.load(Ordering::Acquire));
652    }
653}