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}