Skip to main content

rvoip_core/
operational_events.rs

1//! Authoritative, bounded operational lifecycle events.
2//!
3//! [`crate::Event`] remains a compatibility and observability broadcast. It
4//! may lag or lose messages when a receiver overruns. Applications whose call
5//! state depends on connection events can instead install a receiver of
6//! [`OperationalEvent`] values through
7//! [`crate::Orchestrator::install_operational_event_stream`]. Core awaits this
8//! single-consumer stream before publishing the corresponding compatibility
9//! event, so bounded backpressure is propagated to adapter ingestion without
10//! an overflow queue or detached forwarding task.
11//!
12//! The per-adapter normalization task created by
13//! [`crate::Orchestrator::register`] is retained by the Orchestrator and can
14//! be joined through [`crate::Orchestrator::drain_connection_lifecycle_tasks`].
15//! This module adds no separate forwarding task.
16
17use std::fmt;
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::Arc;
20
21use chrono::{DateTime, Utc};
22use tokio::sync::{mpsc, watch};
23
24use crate::adapter::{EndReason, TransferAttemptId, TransferStatus, TransferTarget};
25use crate::connection::Transport;
26use crate::ids::ConnectionId;
27use crate::DataMessage;
28
29/// Health of the optional authoritative operational stream.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31#[non_exhaustive]
32pub enum OperationalEventStreamHealth {
33    /// No authoritative stream was requested; legacy broadcast behavior is
34    /// unchanged.
35    NotInstalled,
36    /// The installed receiver remains available.
37    Healthy,
38    /// The receiver was lost or the process-local sequence space was
39    /// exhausted. This state is sticky for the process lifetime.
40    Degraded,
41}
42
43#[derive(Clone, Copy, Debug)]
44pub(crate) enum OperationalEventStreamFailure {
45    ReceiverLost,
46    DeliveryCancelled,
47    SequenceExhausted,
48    SendFailed,
49}
50
51impl OperationalEventStreamFailure {
52    fn metric_label(self) -> &'static str {
53        match self {
54            Self::ReceiverLost => "receiver_lost",
55            Self::DeliveryCancelled => "delivery_cancelled",
56            Self::SequenceExhausted => "sequence_exhausted",
57            Self::SendFailed => "send_failed",
58        }
59    }
60}
61
62/// Task-free subscription to sticky authoritative-stream health.
63///
64/// [`Self::current`] exposes the initial state immediately. [`Self::changed`]
65/// waits for either a published health transition or loss of the operational
66/// event receiver. Receiver loss is converted to the same sticky `Degraded`
67/// state inline, so subscribing does not create an idle background task that
68/// could outlive core lifecycle drain.
69#[derive(Clone)]
70pub struct OperationalEventStreamHealthSubscription {
71    updates: watch::Receiver<OperationalEventStreamHealth>,
72    receiver_closed: mpsc::Sender<OperationalEvent>,
73    health: Arc<OperationalEventStreamHealthState>,
74}
75
76impl OperationalEventStreamHealthSubscription {
77    /// Return current sticky health without waiting.
78    pub fn current(&self) -> OperationalEventStreamHealth {
79        if self.receiver_closed.is_closed() {
80            self.health
81                .mark_degraded(OperationalEventStreamFailure::ReceiverLost);
82        }
83        self.health.current()
84    }
85
86    /// Wait for degradation and return the new sticky state.
87    ///
88    /// `Degraded` is terminal for this process-local stream. Calling this
89    /// method after observing it therefore returns `Degraded` immediately;
90    /// correctness consumers should stop accepting work at that point.
91    pub async fn changed(&mut self) -> OperationalEventStreamHealth {
92        let current = self.current();
93        if current == OperationalEventStreamHealth::Degraded {
94            self.updates.borrow_and_update();
95            return current;
96        }
97        tokio::select! {
98            changed = self.updates.changed() => {
99                if changed.is_err() {
100                    // The subscription owns the health state, which owns the
101                    // watch sender, so this is unreachable without a future
102                    // implementation error. Fail closed if that invariant is
103                    // ever broken.
104                    self.health.mark_degraded(
105                        OperationalEventStreamFailure::DeliveryCancelled,
106                    );
107                }
108            }
109            () = self.receiver_closed.closed() => {
110                self.health.mark_degraded(
111                    OperationalEventStreamFailure::ReceiverLost,
112                );
113            }
114        }
115        let current = self.health.current();
116        self.updates.borrow_and_update();
117        current
118    }
119}
120
121/// Sanitized terminal disposition. Adapter-owned free-form failure text is
122/// deliberately excluded from the correctness stream.
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124#[non_exhaustive]
125pub enum OperationalEndReason {
126    Normal,
127    Cancelled,
128    Failed,
129    Timeout,
130    BridgeTorn,
131}
132
133impl From<&EndReason> for OperationalEndReason {
134    fn from(reason: &EndReason) -> Self {
135        match reason {
136            EndReason::Normal => Self::Normal,
137            EndReason::Cancelled => Self::Cancelled,
138            EndReason::Failed { .. } => Self::Failed,
139            EndReason::Timeout => Self::Timeout,
140            EndReason::BridgeTorn => Self::BridgeTorn,
141        }
142    }
143}
144
145/// Stable failure category that cannot carry adapter credentials or peer
146/// supplied diagnostic text.
147#[derive(Clone, Copy, Debug, Eq, PartialEq)]
148#[non_exhaustive]
149pub enum OperationalFailureReason {
150    AdapterReported,
151    CoreReported,
152}
153
154/// Redacted transfer destination shape. URI contents remain with the caller
155/// and adapter; they are not copied into the operational stream.
156#[derive(Clone, Debug, Eq, PartialEq)]
157#[non_exhaustive]
158pub enum OperationalTransferTarget {
159    Uri,
160    Connection(ConnectionId),
161    Session(crate::ids::SessionId),
162}
163
164impl From<&TransferTarget> for OperationalTransferTarget {
165    fn from(target: &TransferTarget) -> Self {
166        match target {
167            TransferTarget::Uri(_) => Self::Uri,
168            TransferTarget::Connection(connection_id) => Self::Connection(connection_id.clone()),
169            TransferTarget::Session(session_id) => Self::Session(session_id.clone()),
170        }
171    }
172}
173
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175#[non_exhaustive]
176pub enum OperationalTransferOutcome {
177    /// The adapter accepted the command for asynchronous processing.
178    Submitted,
179    Succeeded,
180    Failed,
181}
182
183/// Transport-neutral events required to maintain authoritative call state.
184///
185/// Payload-bearing variants intentionally use a custom [`Debug`]
186/// implementation. DTMF digits, DataChannel bytes and labels are available to
187/// the owning receiver but never appear in routine formatting.
188#[derive(Clone, Eq, PartialEq)]
189#[non_exhaustive]
190pub enum OperationalEventKind {
191    Connected,
192    /// Provisional signaling for this exact connection lifecycle.
193    ///
194    /// `early_media` means the adapter has received a media-bearing
195    /// provisional response and can expose its negotiated media stream. It
196    /// does not mean the connection has been finally answered.
197    Progress {
198        status_code: u16,
199        early_media: bool,
200    },
201    /// Coalesced proof that core consumed media from this exact live
202    /// Connection. `generation` is consecutive for the Connection lifecycle
203    /// even when lower-level graph observations were overwritten under
204    /// backpressure.
205    MediaActivity {
206        generation: u64,
207    },
208    Ended {
209        reason: OperationalEndReason,
210    },
211    Failed {
212        reason: OperationalFailureReason,
213    },
214    Dtmf {
215        digits: String,
216        duration_ms: u32,
217    },
218    DataMessage {
219        message: DataMessage,
220    },
221    Transfer {
222        /// Exact transfer submission when the caller used the correlated API.
223        /// `None` identifies the legacy uncorrelated submission path.
224        attempt_id: Option<TransferAttemptId>,
225        target: OperationalTransferTarget,
226        outcome: OperationalTransferOutcome,
227    },
228    /// Protocol-authoritative asynchronous transfer progress or completion.
229    TransferStatus {
230        attempt_id: Option<TransferAttemptId>,
231        status: TransferStatus,
232    },
233}
234
235impl fmt::Debug for OperationalEventKind {
236    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237        match self {
238            Self::Connected => formatter.write_str("Connected"),
239            Self::Progress {
240                status_code,
241                early_media,
242            } => formatter
243                .debug_struct("Progress")
244                .field("status_code", status_code)
245                .field("early_media", early_media)
246                .finish(),
247            Self::MediaActivity { generation } => formatter
248                .debug_struct("MediaActivity")
249                .field("generation", generation)
250                .finish(),
251            Self::Ended { reason } => formatter
252                .debug_struct("Ended")
253                .field("reason", reason)
254                .finish(),
255            Self::Failed { reason } => formatter
256                .debug_struct("Failed")
257                .field("reason", reason)
258                .finish(),
259            Self::Dtmf { duration_ms, .. } => formatter
260                .debug_struct("Dtmf")
261                .field("digits", &"[redacted]")
262                .field("duration_ms", duration_ms)
263                .finish(),
264            Self::DataMessage { message } => formatter
265                .debug_struct("DataMessage")
266                .field("label", &"[redacted]")
267                .field("content_type", &"[redacted]")
268                .field("body_bytes", &message.bytes.len())
269                .field("message_id", &"[redacted]")
270                .field("reliability", &message.reliability)
271                .finish(),
272            Self::Transfer {
273                attempt_id,
274                target,
275                outcome,
276            } => formatter
277                .debug_struct("Transfer")
278                .field("attempt_id_present", &attempt_id.is_some())
279                .field("target", &RedactedTransferTarget(target))
280                .field("outcome", outcome)
281                .finish(),
282            Self::TransferStatus { attempt_id, status } => formatter
283                .debug_struct("TransferStatus")
284                .field("attempt_id_present", &attempt_id.is_some())
285                .field("status", status)
286                .finish(),
287        }
288    }
289}
290
291struct RedactedTransferTarget<'a>(&'a OperationalTransferTarget);
292
293impl fmt::Debug for RedactedTransferTarget<'_> {
294    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self.0 {
296            OperationalTransferTarget::Uri => formatter.write_str("Uri([redacted])"),
297            OperationalTransferTarget::Connection(_) => {
298                formatter.write_str("Connection([redacted])")
299            }
300            OperationalTransferTarget::Session(_) => formatter.write_str("Session([redacted])"),
301        }
302    }
303}
304
305/// One globally ordered operational event.
306#[derive(Clone, Eq, PartialEq)]
307#[non_exhaustive]
308pub struct OperationalEvent {
309    pub sequence: u64,
310    pub connection_id: ConnectionId,
311    pub transport: Transport,
312    pub at: DateTime<Utc>,
313    pub kind: OperationalEventKind,
314}
315
316impl fmt::Debug for OperationalEvent {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        formatter
319            .debug_struct("OperationalEvent")
320            .field("sequence", &self.sequence)
321            .field("connection_id", &self.connection_id)
322            .field("transport", &self.transport)
323            .field("at", &self.at)
324            .field("kind", &self.kind)
325            .finish()
326    }
327}
328
329/// Core-owned half of the installed stream.
330pub(crate) struct OperationalEventStream {
331    sender: mpsc::Sender<OperationalEvent>,
332    next_sequence: AtomicU64,
333    health: Arc<OperationalEventStreamHealthState>,
334}
335
336struct OperationalEventStreamHealthState {
337    degraded: AtomicBool,
338    updates: watch::Sender<OperationalEventStreamHealth>,
339}
340
341pub(crate) struct OperationalSendGuard<'a> {
342    stream: &'a OperationalEventStream,
343    armed: bool,
344}
345
346impl OperationalSendGuard<'_> {
347    pub(crate) fn disarm(&mut self) {
348        self.armed = false;
349    }
350}
351
352impl Drop for OperationalSendGuard<'_> {
353    fn drop(&mut self) {
354        if self.armed {
355            self.stream
356                .mark_degraded(OperationalEventStreamFailure::DeliveryCancelled);
357        }
358    }
359}
360
361impl OperationalEventStreamHealthState {
362    fn current(&self) -> OperationalEventStreamHealth {
363        if self.degraded.load(Ordering::Acquire) {
364            OperationalEventStreamHealth::Degraded
365        } else {
366            OperationalEventStreamHealth::Healthy
367        }
368    }
369
370    fn mark_degraded(&self, failure: OperationalEventStreamFailure) {
371        if !self.degraded.swap(true, Ordering::AcqRel) {
372            self.updates
373                .send_replace(OperationalEventStreamHealth::Degraded);
374            metrics::counter!(
375                "rvoip_core_operational_event_stream_failures_total",
376                "reason" => failure.metric_label()
377            )
378            .increment(1);
379            tracing::error!(
380                reason = failure.metric_label(),
381                "authoritative operational event stream degraded; failing closed"
382            );
383        }
384    }
385}
386
387impl OperationalEventStream {
388    pub(crate) fn new(capacity: usize) -> (Self, mpsc::Receiver<OperationalEvent>) {
389        let (sender, receiver) = mpsc::channel(capacity);
390        let (health_updates, _initial_health) =
391            watch::channel(OperationalEventStreamHealth::Healthy);
392        (
393            Self {
394                sender,
395                next_sequence: AtomicU64::new(1),
396                health: Arc::new(OperationalEventStreamHealthState {
397                    degraded: AtomicBool::new(false),
398                    updates: health_updates,
399                }),
400            },
401            receiver,
402        )
403    }
404
405    pub(crate) fn health(&self) -> OperationalEventStreamHealth {
406        if self.sender.is_closed() {
407            self.mark_degraded(OperationalEventStreamFailure::ReceiverLost);
408        }
409        self.health.current()
410    }
411
412    pub(crate) fn subscribe_health(&self) -> OperationalEventStreamHealthSubscription {
413        // Detect a receiver that disappeared before this subscriber was
414        // installed. `send_replace` retains the transition, so the returned
415        // subscription always exposes the current sticky value.
416        let _ = self.health();
417        OperationalEventStreamHealthSubscription {
418            updates: self.health.updates.subscribe(),
419            receiver_closed: self.sender.clone(),
420            health: Arc::clone(&self.health),
421        }
422    }
423
424    /// Arm a cancellation boundary before core starts mutating lifecycle
425    /// state whose authoritative outcome must subsequently be published.
426    ///
427    /// Callers disarm only after publication (or after proving that no
428    /// authoritative event was owed). Dropping an armed guard permanently
429    /// degrades the stream so cancellation cannot erase a lifecycle outcome
430    /// while leaving the process apparently healthy.
431    pub(crate) fn delivery_guard(&self) -> OperationalSendGuard<'_> {
432        OperationalSendGuard {
433            stream: self,
434            armed: true,
435        }
436    }
437
438    pub(crate) async fn send(
439        &self,
440        connection_id: ConnectionId,
441        transport: Transport,
442        at: DateTime<Utc>,
443        kind: OperationalEventKind,
444    ) -> bool {
445        if self.health() == OperationalEventStreamHealth::Degraded {
446            return false;
447        }
448        // If the owning adapter task is cancelled while awaiting receiver
449        // capacity, an authoritative event may have been accepted by core but
450        // not delivered. Make that loss observable and fail closed instead of
451        // continuing with a falsely healthy stream.
452        let mut cancellation_guard = self.delivery_guard();
453        // Reserve bounded capacity before assigning the global sequence. A
454        // cancelled waiter therefore cannot create a visible sequence gap.
455        let permit = match self.sender.reserve().await {
456            Ok(permit) => permit,
457            Err(_) => {
458                self.mark_degraded(OperationalEventStreamFailure::SendFailed);
459                return false;
460            }
461        };
462        let Ok(sequence) =
463            self.next_sequence
464                .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
465                    current.checked_add(1)
466                })
467        else {
468            self.mark_degraded(OperationalEventStreamFailure::SequenceExhausted);
469            return false;
470        };
471        permit.send(OperationalEvent {
472            sequence,
473            connection_id,
474            transport,
475            at,
476            kind,
477        });
478        cancellation_guard.disarm();
479        true
480    }
481
482    pub(crate) fn mark_degraded(&self, failure: OperationalEventStreamFailure) {
483        self.health.mark_degraded(failure);
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::ids::MessageId;
491    use bytes::Bytes;
492    use rvoip_core_traits::data::DataReliability;
493
494    #[test]
495    fn debug_redacts_operational_payloads() {
496        let secret = "credential-like-secret";
497        let message = DataMessage {
498            label: secret.into(),
499            content_type: "application/secret".into(),
500            bytes: Bytes::from(secret),
501            reliability: DataReliability::ReliableOrdered,
502            message_id: MessageId::from_string(secret),
503        };
504        let values = [
505            OperationalEventKind::Dtmf {
506                digits: secret.into(),
507                duration_ms: 100,
508            },
509            OperationalEventKind::DataMessage { message },
510            OperationalEventKind::Transfer {
511                attempt_id: Some(TransferAttemptId::from_string(secret)),
512                target: OperationalTransferTarget::Uri,
513                outcome: OperationalTransferOutcome::Succeeded,
514            },
515            OperationalEventKind::TransferStatus {
516                attempt_id: Some(TransferAttemptId::from_string(secret)),
517                status: TransferStatus::Failed {
518                    status_code: 503,
519                    reason: secret.into(),
520                },
521            },
522        ];
523        for value in values {
524            let debug = format!("{value:?}");
525            assert!(!debug.contains(secret));
526            assert!(debug.contains("[redacted]"));
527        }
528    }
529
530    #[tokio::test]
531    async fn cancelled_backpressured_send_marks_stream_degraded() {
532        let (stream, _receiver) = OperationalEventStream::new(1);
533        let stream = std::sync::Arc::new(stream);
534        let mut health = stream.subscribe_health();
535        assert_eq!(
536            health.current(),
537            OperationalEventStreamHealth::Healthy,
538            "a new subscription exposes current health immediately"
539        );
540        assert!(
541            stream
542                .send(
543                    ConnectionId::new(),
544                    Transport::Sip,
545                    Utc::now(),
546                    OperationalEventKind::Connected,
547                )
548                .await
549        );
550        let blocked_stream = stream.clone();
551        let blocked = tokio::spawn(async move {
552            blocked_stream
553                .send(
554                    ConnectionId::new(),
555                    Transport::Sip,
556                    Utc::now(),
557                    OperationalEventKind::Connected,
558                )
559                .await
560        });
561        for _ in 0..10 {
562            tokio::task::yield_now().await;
563        }
564        assert_eq!(stream.sender.capacity(), 0);
565        assert!(!blocked.is_finished());
566        blocked.abort();
567        assert!(blocked.await.unwrap_err().is_cancelled());
568        let changed = tokio::time::timeout(std::time::Duration::from_secs(1), health.changed())
569            .await
570            .expect("cancellation publishes a health transition");
571        assert_eq!(changed, OperationalEventStreamHealth::Degraded);
572        assert_eq!(stream.health(), OperationalEventStreamHealth::Degraded);
573        assert_eq!(
574            stream.subscribe_health().current(),
575            OperationalEventStreamHealth::Degraded,
576            "degradation is retained for later subscribers"
577        );
578    }
579
580    #[tokio::test]
581    async fn cancelled_lifecycle_after_mutation_before_send_marks_stream_degraded() {
582        let (stream, _receiver) = OperationalEventStream::new(1);
583        let stream = std::sync::Arc::new(stream);
584        let (armed, armed_rx) = tokio::sync::oneshot::channel();
585        let (_release, release_rx) = tokio::sync::oneshot::channel::<()>();
586        let guarded_stream = std::sync::Arc::clone(&stream);
587        let lifecycle = tokio::spawn(async move {
588            let _delivery = guarded_stream.delivery_guard();
589            let _ = armed.send(());
590            // Deterministic stand-in for owned media/session teardown after
591            // the connection registry has already been retired but before
592            // the authoritative terminal send starts.
593            let _ = release_rx.await;
594        });
595        armed_rx.await.unwrap();
596        lifecycle.abort();
597        assert!(lifecycle.await.unwrap_err().is_cancelled());
598        assert_eq!(stream.health(), OperationalEventStreamHealth::Degraded);
599    }
600
601    #[tokio::test]
602    async fn sequence_exhaustion_notifies_health_subscribers() {
603        let (stream, _receiver) = OperationalEventStream::new(1);
604        let mut health = stream.subscribe_health();
605        stream.next_sequence.store(u64::MAX, Ordering::Release);
606
607        assert!(
608            !stream
609                .send(
610                    ConnectionId::new(),
611                    Transport::Sip,
612                    Utc::now(),
613                    OperationalEventKind::Connected,
614                )
615                .await
616        );
617        let changed = tokio::time::timeout(std::time::Duration::from_secs(1), health.changed())
618            .await
619            .expect("sequence exhaustion publishes a health transition");
620        assert_eq!(changed, OperationalEventStreamHealth::Degraded);
621    }
622
623    #[tokio::test]
624    async fn failed_bounded_send_notifies_health_subscribers() {
625        let (stream, mut receiver) = OperationalEventStream::new(1);
626        let stream = Arc::new(stream);
627        let mut health = stream.subscribe_health();
628        assert!(
629            stream
630                .send(
631                    ConnectionId::new(),
632                    Transport::Sip,
633                    Utc::now(),
634                    OperationalEventKind::Connected,
635                )
636                .await
637        );
638
639        let blocked_stream = Arc::clone(&stream);
640        let blocked = tokio::spawn(async move {
641            blocked_stream
642                .send(
643                    ConnectionId::new(),
644                    Transport::Sip,
645                    Utc::now(),
646                    OperationalEventKind::Connected,
647                )
648                .await
649        });
650        tokio::time::timeout(std::time::Duration::from_secs(1), async {
651            while stream.sender.capacity() != 0 || blocked.is_finished() {
652                tokio::task::yield_now().await;
653            }
654        })
655        .await
656        .expect("second send waits for bounded capacity");
657
658        receiver.close();
659        assert!(!blocked.await.expect("sender task completed"));
660        let changed = tokio::time::timeout(std::time::Duration::from_secs(1), health.changed())
661            .await
662            .expect("send failure publishes a health transition");
663        assert_eq!(changed, OperationalEventStreamHealth::Degraded);
664    }
665}