Skip to main content

rvoip_core/
virtual_publisher.rs

1//! MediaGraph-backed logical publishers for transport subscribers.
2//!
3//! A virtual publisher exposes any Connection's reusable audio source under a
4//! canonical `(SessionId, StreamId)` in the Orchestrator publisher registry.
5//! It is deliberately transport-neutral: UCTP subscribers use the existing
6//! subscription path, while the source may be SIP, WebRTC, Amazon, or another
7//! adapter. The source receiver remains owned exactly once by `MediaGraph`.
8
9use std::fmt;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Weak};
12
13use tokio::task::JoinHandle;
14
15use crate::ids::{ConnectionId, SessionId, StreamId};
16use crate::media_graph::{ManagedMediaRoute, MediaGraphRouteStatus};
17use crate::orchestrator::Orchestrator;
18use crate::subscriptions::{PublisherRegistrationId, PublisherRegistry};
19use crate::Result;
20
21/// The per-publisher graph-to-fanout queue. Ten 20 ms audio frames bound the
22/// additional buffering to roughly 200 ms while the MediaGraph's own slow-sink
23/// policy provides the overload/eviction backstop.
24pub const DEFAULT_VIRTUAL_PUBLISHER_QUEUE_CAPACITY: usize = 10;
25
26/// Canonical identity under which a Connection's audio is published.
27#[derive(Clone, Eq, PartialEq)]
28pub struct VirtualPublisherDescriptor {
29    pub session_id: SessionId,
30    pub stream_id: StreamId,
31    pub participant: String,
32}
33
34impl fmt::Debug for VirtualPublisherDescriptor {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        formatter
37            .debug_struct("VirtualPublisherDescriptor")
38            .field("session_id", &self.session_id)
39            .field("stream_id", &self.stream_id)
40            .field("participant_present", &!self.participant.is_empty())
41            .field("participant_bytes", &self.participant.len())
42            .finish()
43    }
44}
45
46impl VirtualPublisherDescriptor {
47    pub fn new(session_id: SessionId, stream_id: StreamId, participant: impl Into<String>) -> Self {
48        Self {
49            session_id,
50            stream_id,
51            participant: participant.into(),
52        }
53    }
54}
55
56struct VirtualPublisherCleanup {
57    orchestrator: Weak<Orchestrator>,
58    registry: Arc<PublisherRegistry>,
59    session_id: SessionId,
60    stream_id: StreamId,
61    publisher_id: ConnectionId,
62    registration_id: PublisherRegistrationId,
63    active: AtomicBool,
64}
65
66impl VirtualPublisherCleanup {
67    fn new(
68        orchestrator: Weak<Orchestrator>,
69        registry: Arc<PublisherRegistry>,
70        publisher_id: ConnectionId,
71        descriptor: &VirtualPublisherDescriptor,
72        registration_id: PublisherRegistrationId,
73    ) -> Self {
74        metrics::gauge!("rvoip_virtual_publishers_active").increment(1.0);
75        Self {
76            orchestrator,
77            registry,
78            session_id: descriptor.session_id.clone(),
79            stream_id: descriptor.stream_id.clone(),
80            publisher_id,
81            registration_id,
82            active: AtomicBool::new(true),
83        }
84    }
85
86    fn is_current(&self) -> bool {
87        self.active.load(Ordering::Acquire)
88            && self.registry.registration_is_current(
89                &self.session_id,
90                self.stream_id.as_str(),
91                self.registration_id,
92            )
93    }
94
95    fn unregister(&self) {
96        if !self.active.swap(false, Ordering::AcqRel) {
97            return;
98        }
99        let removed = self.registry.remove_registration(
100            &self.session_id,
101            self.stream_id.as_str(),
102            self.registration_id,
103        );
104        if removed {
105            if let Some(orchestrator) = self.orchestrator.upgrade() {
106                orchestrator.drop_publisher_stream_subscriptions(
107                    &self.session_id,
108                    &self.publisher_id,
109                    &self.stream_id,
110                );
111            }
112        }
113        metrics::gauge!("rvoip_virtual_publishers_active").decrement(1.0);
114    }
115}
116
117struct TaskCleanup(Arc<VirtualPublisherCleanup>);
118
119impl Drop for TaskCleanup {
120    fn drop(&mut self) {
121        self.0.unregister();
122    }
123}
124
125/// Owning lease for a MediaGraph-backed publisher.
126///
127/// Explicit [`Self::close`] waits for the pump to stop and the graph route to
128/// be removed. Dropping the handle aborts the pump, unregisters only its own
129/// generation, and drops the managed graph route. Both paths are idempotent
130/// with transport/session teardown.
131#[must_use = "dropping the managed publisher immediately unregisters it"]
132pub struct ManagedVirtualPublisher {
133    source_connection_id: ConnectionId,
134    descriptor: VirtualPublisherDescriptor,
135    route: Option<ManagedMediaRoute>,
136    task: Option<JoinHandle<()>>,
137    cleanup: Arc<VirtualPublisherCleanup>,
138}
139
140impl ManagedVirtualPublisher {
141    pub(crate) fn start(
142        orchestrator: Weak<Orchestrator>,
143        source_connection_id: ConnectionId,
144        descriptor: VirtualPublisherDescriptor,
145        route: ManagedMediaRoute,
146        mut frames: tokio::sync::mpsc::Receiver<crate::stream::MediaFrame>,
147        registry: Arc<PublisherRegistry>,
148        registration_id: PublisherRegistrationId,
149    ) -> Self {
150        let cleanup = Arc::new(VirtualPublisherCleanup::new(
151            orchestrator.clone(),
152            registry,
153            source_connection_id.clone(),
154            &descriptor,
155            registration_id,
156        ));
157        let task_cleanup = TaskCleanup(Arc::clone(&cleanup));
158        let task_session_id = descriptor.session_id.clone();
159        let task_stream_id = descriptor.stream_id.clone();
160        let task_publisher_id = source_connection_id.clone();
161        let task = tokio::spawn(async move {
162            let cleanup = task_cleanup;
163            while let Some(mut frame) = frames.recv().await {
164                // A compatible legacy registration may intentionally replace
165                // this row. Stop before publishing under an identity we no
166                // longer own, and let the managed graph route observe its
167                // receiver closing.
168                if !cleanup.0.is_current() {
169                    break;
170                }
171                let Some(orchestrator) = orchestrator.upgrade() else {
172                    break;
173                };
174                frame.stream_id = task_stream_id.clone();
175                let delivered = orchestrator
176                    .fanout_frame(&task_session_id, &task_publisher_id, &task_stream_id, frame)
177                    .await;
178                metrics::counter!("rvoip_virtual_publisher_frames_total").increment(1);
179                metrics::counter!("rvoip_virtual_publisher_deliveries_total")
180                    .increment(delivered as u64);
181            }
182        });
183
184        Self {
185            source_connection_id,
186            descriptor,
187            route: Some(route),
188            task: Some(task),
189            cleanup,
190        }
191    }
192
193    pub fn source_connection_id(&self) -> &ConnectionId {
194        &self.source_connection_id
195    }
196
197    pub fn descriptor(&self) -> &VirtualPublisherDescriptor {
198        &self.descriptor
199    }
200
201    pub fn route_status(&self) -> MediaGraphRouteStatus {
202        self.route
203            .as_ref()
204            .expect("managed virtual publisher route is present until close")
205            .status()
206    }
207
208    /// Stop fanout, unregister the exact publisher generation, and await graph
209    /// route removal. Cleanup completes even when the graph already ended.
210    pub async fn close(mut self) -> Result<()> {
211        if let Some(task) = self.task.take() {
212            task.abort();
213            let _ = task.await;
214        }
215        self.cleanup.unregister();
216        if let Some(route) = self.route.take() {
217            // A source disconnect may already have closed the graph. The
218            // owning registration and task are gone either way, so a terminal
219            // route needs no second acknowledgement.
220            if matches!(
221                route.state(),
222                crate::media_graph::MediaGraphRouteState::Terminal(_)
223            ) {
224                drop(route);
225            } else {
226                let _ = route.remove().await?;
227            }
228        }
229        Ok(())
230    }
231}
232
233impl Drop for ManagedVirtualPublisher {
234    fn drop(&mut self) {
235        if let Some(task) = self.task.take() {
236            task.abort();
237        }
238        self.cleanup.unregister();
239        self.route.take();
240    }
241}
242
243#[cfg(test)]
244mod diagnostic_tests {
245    use super::*;
246
247    #[test]
248    fn virtual_publisher_descriptor_debug_redacts_all_identity_values() {
249        const CANARY: &str = "virtual-publisher-canary\r\nAuthorization: exposed";
250        let descriptor = VirtualPublisherDescriptor::new(
251            SessionId::from_string(CANARY),
252            StreamId::from_string(CANARY),
253            CANARY,
254        );
255        let debug = format!("{descriptor:?}");
256        assert!(!debug.contains(CANARY));
257        assert!(debug.contains("participant_present: true"));
258    }
259}