Skip to main content

o_sfu_core/engine/media_transport/rtc/
commands.rs

1//! mailbox command contract for the RTC worker engine
2//!
3//! `MediaTransport` production paths and cfg-gated worker harnesses translate
4//! transport intent into these values before the packet-loop task dispatches
5//! them while it owns mutable rtc state
6//! request commands carry a oneshot response
7//! fire-and-forget route controls are best-effort because they may target a
8//! worker that has already torn down the corresponding relay or session
9
10use std::{fmt::Write as _, net::SocketAddr, sync::Arc};
11
12use o_sfu_rfc::{tcp_framing::TcpFrame, webrtc::sdp};
13use o_sfu_router::rtp::MediaStream as RouterRtpParameters;
14use str0m::{
15    change::{SdpAnswer, SdpOffer},
16    media::{KeyframeRequestKind, MediaKind, Rid},
17};
18use tokio::sync::{mpsc, oneshot};
19
20use super::{
21    codec::{ParsedAnswerRids, RepairSummary, validate_answer_sdp},
22    state::{
23        relay_registry::{RelayPacketMailbox, RelayTargetId},
24        route_control::PacketLayerGate,
25    },
26};
27use crate::engine::{
28    media_transport::{
29        ActiveSpeakerSource, AppliedSessionAnswer, ConsumerRouteControl,
30        ConsumerRouteControlOutcome, ProducerRouteControl, ReceiverBweTargetUpdate, SessionOffer,
31        SessionUploadSlot, SourceActivityUpdate, TransportAdapterError, TransportConsumerRoute,
32        TransportMediaId, TransportResult, TransportSessionKey, TransportSourceDiagnosticsSnapshot,
33        TransportSourceKey,
34    },
35    metrics::{
36        RtcMetricsRecorder, RtcRemoteControlDropKind, RtcRemotePacketGateConvergence,
37        RtcWorkerObservationKind,
38    },
39    source_model::SourceSyncPolicy,
40};
41
42/// command handle used by remote consumers to push control back to a source worker
43///
44/// a route that consumes media from another worker keeps this handle beside the
45/// remote-source registration
46/// later keyframe or layer-gate requests can then reach the worker that owns
47/// the producer without exposing the source worker internals
48///
49/// sends are deliberately best-effort
50/// stale remote routes, closed workers and full mailboxes are normal during
51/// teardown or topology churn
52#[derive(Debug, Clone)]
53pub struct RemoteSourceControl {
54    tx: mpsc::Sender<RtcWorkerCommand>,
55    target_id: RelayTargetId,
56    rtc_metrics: Arc<RtcMetricsRecorder>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(super) enum RemoteControlSendOutcome {
61    Forwarded,
62    Full,
63    Closed,
64}
65
66impl RemoteSourceControl {
67    /// creates a source-control handle for one relay target on a worker mailbox
68    pub(super) fn new(
69        tx: mpsc::Sender<RtcWorkerCommand>,
70        target_id: RelayTargetId,
71        rtc_metrics: Arc<RtcMetricsRecorder>,
72    ) -> Self {
73        Self {
74            tx,
75            target_id,
76            rtc_metrics,
77        }
78    }
79
80    /// asks the source worker to request a keyframe for a remote consumer
81    ///
82    /// this never waits for the source worker
83    /// `Full` retains caller-side retry authority while `Closed` ends it
84    pub(super) fn request_kf(
85        &self,
86        source: &TransportSourceKey,
87        rid: Option<Rid>,
88        kind: KeyframeRequestKind,
89    ) -> RemoteControlSendOutcome {
90        self.send_command(
91            RouteControlRequest::RequestRemoteKeyframe {
92                source: source.clone(),
93                target_id: self.target_id,
94                rid,
95                kind,
96            },
97            RtcRemoteControlDropKind::Keyframe,
98        )
99    }
100
101    /// publishes the effective remote-source packet gate to the source worker
102    ///
103    /// `Full` requires a later retry. `Closed` ends retry authority.
104    pub(super) fn set_pkt_gate(
105        &self,
106        source: &TransportSourceKey,
107        packet_gate: PacketLayerGate,
108    ) -> RemoteControlSendOutcome {
109        self.send_command(
110            RouteControlRequest::SetRemoteSourcePacketGate {
111                source: source.clone(),
112                target_id: self.target_id,
113                packet_gate,
114            },
115            RtcRemoteControlDropKind::PacketGate,
116        )
117    }
118
119    pub(super) fn record_pkt_gate_retry(&self) {
120        self.rtc_metrics
121            .record_rtc_remote_packet_gate_convergence(RtcRemotePacketGateConvergence::Retry);
122    }
123
124    pub(super) fn record_pkt_gate_flushed(&self) {
125        self.rtc_metrics
126            .record_rtc_remote_packet_gate_convergence(RtcRemotePacketGateConvergence::Flushed);
127    }
128
129    #[inline]
130    fn send_command(
131        &self,
132        request: RouteControlRequest,
133        drop_kind: RtcRemoteControlDropKind,
134    ) -> RemoteControlSendOutcome {
135        match self.tx.try_send(RtcWorkerCommand::RouteControl {
136            request,
137            response: None,
138        }) {
139            Ok(()) => RemoteControlSendOutcome::Forwarded,
140            Err(mpsc::error::TrySendError::Full(_)) => {
141                self.rtc_metrics.record_rtc_remote_control_drop(drop_kind);
142                RemoteControlSendOutcome::Full
143            }
144            Err(mpsc::error::TrySendError::Closed(_)) => {
145                self.rtc_metrics.record_rtc_remote_control_drop(drop_kind);
146                RemoteControlSendOutcome::Closed
147            }
148        }
149    }
150}
151
152/// Response channel for a command executed by the packet loop.
153///
154/// Dropping the receiver cancels only the API wait and does not retract an
155/// enqueued command. Terminal worker cancellation may still win before dispatch.
156pub type RtcWorkerResponse<T> = oneshot::Sender<TransportResult<T>>;
157
158/// SDP answer parsed before packet-loop command delivery.
159pub struct ParsedSessionAnswer {
160    pub(super) answer: SdpAnswer,
161    pub(super) rids: ParsedAnswerRids,
162    pub(super) repair: RepairSummary,
163}
164
165impl ParsedSessionAnswer {
166    /// Validates and parses a remote SDP answer.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`TransportAdapterError::InvalidInput`] for malformed repair
171    /// topology or SDP that str0m cannot parse.
172    pub(in crate::engine::media_transport) fn parse(answer_sdp: &str) -> TransportResult<Self> {
173        let repair = validate_answer_sdp(answer_sdp)?;
174        let answer = SdpAnswer::from_sdp_string(answer_sdp)
175            .map_err(|_error| TransportAdapterError::InvalidInput)?;
176        let rids = ParsedAnswerRids::parse(answer_sdp, &answer);
177        Ok(Self {
178            answer,
179            rids,
180            repair,
181        })
182    }
183}
184
185pub struct RtcSessionOffer {
186    offer: SdpOffer,
187    upload_slots: Vec<SessionUploadSlot>,
188}
189
190impl RtcSessionOffer {
191    pub(super) fn new(offer: SdpOffer, upload_slots: Vec<SessionUploadSlot>) -> Self {
192        Self {
193            offer,
194            upload_slots,
195        }
196    }
197
198    pub(in crate::engine::media_transport) fn into_session_offer(self) -> SessionOffer {
199        // ICE-lite bootstrap adds the session's only host candidate before any
200        // offer, so every server-authored offer carries the complete set.
201        // https://www.rfc-editor.org/rfc/rfc8840.html#section-8
202        let mut sdp = self.offer.session.to_string();
203        sdp.push_str(sdp::EOC_LINE);
204        for media in &self.offer.media_lines {
205            let _ = write!(sdp, "{media}");
206        }
207        SessionOffer::new(sdp).with_upload_slots(self.upload_slots)
208    }
209}
210
211#[derive(Debug)]
212pub enum WorkerMediaControlBatch {
213    ReceiverBwe(Vec<(usize, ReceiverBweTargetUpdate)>),
214    ProducerActivity(Vec<(usize, ProducerRouteControl)>),
215    ConsumerGates {
216        source: TransportSourceKey,
217        updates: Vec<(usize, TransportConsumerRoute, PacketLayerGate)>,
218    },
219    ConsumerFollowUp(Vec<(usize, ConsumerRouteControl)>),
220}
221
222#[derive(Debug)]
223pub enum WorkerMediaControlBatchOutcome {
224    Applied(Vec<TransportResult<()>>),
225    Consumers(Vec<ConsumerRouteControlOutcome>),
226}
227
228pub enum RouteControlRequest {
229    AddRelayTarget {
230        source: TransportSourceKey,
231        target_id: RelayTargetId,
232        target: RelayPacketMailbox,
233    },
234    RemoveRelayTarget {
235        source: TransportSourceKey,
236        target_id: RelayTargetId,
237    },
238    SetRelayTargetActive {
239        source: TransportSourceKey,
240        target_id: RelayTargetId,
241        active: bool,
242    },
243    SetRemoteSourceActivity {
244        source: TransportSourceKey,
245        update: SourceActivityUpdate,
246    },
247    RequestRemoteKeyframe {
248        source: TransportSourceKey,
249        target_id: RelayTargetId,
250        rid: Option<Rid>,
251        kind: KeyframeRequestKind,
252    },
253    SetRemoteSourcePacketGate {
254        source: TransportSourceKey,
255        target_id: RelayTargetId,
256        packet_gate: PacketLayerGate,
257    },
258}
259
260/// production command handled by the rtc packet-loop task
261///
262/// variants are grouped by ownership boundary: negotiation mutates str0m SDP
263/// state, media commands mutate producer or consumer registrations, relay
264/// commands mutate cross-worker fanout and observability commands read
265/// worker-local snapshots
266pub enum RtcWorkerCommand {
267    /// create the initial capability-probe offer before media registration
268    ///
269    /// worker startup binds the shared UDP socket, then this command lazily creates
270    /// session RTC state from its candidate address
271    /// it rejects a pending offer, a committed initial answer or registered media
272    CreateInitialSessionOffer {
273        room_id: Arc<str>,
274        session_key: TransportSessionKey,
275        response: RtcWorkerResponse<RtcSessionOffer>,
276    },
277    /// drain a staged follow-up offer after media topology changed
278    ///
279    /// media add and remove commands stage the SDP work before this command
280    /// runs
281    /// this command hands the staged offer to the worker API and preserves the
282    /// one-outstanding-offer rule owned by the worker
283    CreateSessionRenegotiationOffer {
284        session_key: TransportSessionKey,
285        response: RtcWorkerResponse<RtcSessionOffer>,
286    },
287    /// read active-speaker sources from worker-local route-control state
288    ///
289    /// the result is a cold-path observation for room policy
290    /// it does not mutate route state or packet-loop scheduling
291    ActiveSpeakerSourceSnapshot {
292        response: RtcWorkerResponse<Vec<ActiveSpeakerSource>>,
293    },
294    /// Read packet activity and active-speaker facts for selected sources.
295    SourceDiagnosticsSnapshot {
296        transport_media_ids: Vec<TransportMediaId>,
297        response: RtcWorkerResponse<TransportSourceDiagnosticsSnapshot>,
298    },
299    /// accept the answer for the current pending local offer
300    ///
301    /// this commits str0m SDP state, marks the session dirty, refreshes
302    /// negotiated producer parameters, registers remote candidate recovery hints
303    /// and returns the producer details that became usable after the answer
304    ApplySessionAnswer {
305        session_key: TransportSessionKey,
306        answer: ParsedSessionAnswer,
307        response: RtcWorkerResponse<AppliedSessionAnswer>,
308    },
309    /// remove a session from worker state
310    ///
311    /// teardown removes rtc state, media handles, route destinations, demux
312    /// indexes, bitrate counters and snapshot entries owned by the session
313    CloseSession {
314        session_key: TransportSessionKey,
315        response: RtcWorkerResponse<()>,
316    },
317    /// remove one producer or consumer media registration owned by a session
318    ///
319    /// producer removal drops incoming bitrate tracking and the source route
320    /// consumer removal drops local rewrite state and the destination route
321    /// negotiated media removal may stage the next SDP offer before the handle
322    /// leaves the public registry
323    RemoveMedia {
324        session_key: TransportSessionKey,
325        transport_media_id: TransportMediaId,
326        response: RtcWorkerResponse<()>,
327    },
328    /// resolve negotiated producer parameters for adapter tests
329    ///
330    /// this test-only command reads the answer-derived producer state after
331    /// negotiation so adapter tests can assert the transport boundary without
332    /// reaching into private registries
333    #[cfg(test)]
334    ResolveNegotiatedProducerParameters {
335        session_key: TransportSessionKey,
336        transport_media_id: TransportMediaId,
337        response: RtcWorkerResponse<RouterRtpParameters>,
338    },
339    /// resolve the MID stored by a registered media handle
340    ///
341    /// returns `None` when this worker has no current handle for the id, including
342    /// after media removal
343    #[cfg(test)]
344    ResolveMediaMid {
345        transport_media_id: TransportMediaId,
346        response: RtcWorkerResponse<Option<String>>,
347    },
348    /// register one browser upload as worker-local producer media
349    ///
350    /// before the initial answer this can declare receive state directly in
351    /// str0m
352    /// after negotiation it stages a recv-only m-section plus pending
353    /// receive identities, then registers bitrate counters and the producer
354    /// media handle
355    AddRecvMedia {
356        session_key: TransportSessionKey,
357        media_kind: MediaKind,
358        rtp_parameters: RouterRtpParameters,
359        response: RtcWorkerResponse<TransportMediaId>,
360    },
361    /// register one browser download as consumer media for a source
362    ///
363    /// the worker validates local or remote source ownership, stages or declares
364    /// send-only media, registers the consumer handle and creates the packet-loop
365    /// route destination
366    /// remote sources install rollback-protected control so failed consumer
367    /// setup does not leave stale relay state behind
368    AddSendMedia {
369        consumer_key: TransportSessionKey,
370        media_kind: MediaKind,
371        source: TransportSourceKey,
372        sync: SourceSyncPolicy,
373        remote_source_control: Option<RemoteSourceControl>,
374        consumer_rtp_parameters: RouterRtpParameters,
375        active: bool,
376        response: RtcWorkerResponse<(TransportMediaId, String)>,
377    },
378    ApplyMediaControlBatch {
379        batch: WorkerMediaControlBatch,
380        response: RtcWorkerResponse<WorkerMediaControlBatchOutcome>,
381    },
382    RouteControl {
383        request: RouteControlRequest,
384        response: Option<RtcWorkerResponse<()>>,
385    },
386    RegisterTcpOutbox {
387        peer_addr: SocketAddr,
388        ufrag: String,
389        outbox_tx: mpsc::Sender<TcpFrame>,
390        response: RtcWorkerResponse<()>,
391    },
392}
393
394impl RtcWorkerCommand {
395    /// Classifies mailbox reads without assuming a command's response type is read-only.
396    pub(super) fn observation_kind(&self) -> Option<RtcWorkerObservationKind> {
397        match self {
398            Self::ActiveSpeakerSourceSnapshot { .. } => {
399                Some(RtcWorkerObservationKind::ActiveSpeakerSources)
400            }
401            Self::SourceDiagnosticsSnapshot { .. } => {
402                Some(RtcWorkerObservationKind::SourceDiagnostics)
403            }
404            #[cfg(test)]
405            Self::ResolveMediaMid { .. } => Some(RtcWorkerObservationKind::ResolveMediaMid),
406            #[cfg(test)]
407            Self::ResolveNegotiatedProducerParameters { .. } => {
408                Some(RtcWorkerObservationKind::NegotiatedProducerParameters)
409            }
410            Self::CreateInitialSessionOffer { .. }
411            | Self::CreateSessionRenegotiationOffer { .. }
412            | Self::ApplySessionAnswer { .. }
413            | Self::CloseSession { .. }
414            | Self::RemoveMedia { .. }
415            | Self::AddRecvMedia { .. }
416            | Self::AddSendMedia { .. }
417            | Self::ApplyMediaControlBatch { .. }
418            | Self::RouteControl { .. }
419            | Self::RegisterTcpOutbox { .. } => None,
420        }
421    }
422
423    pub(super) fn may_change_demux_topology(&self) -> bool {
424        match self {
425            Self::CreateInitialSessionOffer { .. }
426            | Self::ApplySessionAnswer { .. }
427            | Self::CloseSession { .. } => true,
428            Self::CreateSessionRenegotiationOffer { .. }
429            | Self::ActiveSpeakerSourceSnapshot { .. }
430            | Self::SourceDiagnosticsSnapshot { .. }
431            | Self::RemoveMedia { .. }
432            | Self::AddRecvMedia { .. }
433            | Self::AddSendMedia { .. }
434            | Self::ApplyMediaControlBatch { .. }
435            | Self::RouteControl { .. }
436            | Self::RegisterTcpOutbox { .. } => false,
437            #[cfg(test)]
438            Self::ResolveMediaMid { .. } => false,
439            #[cfg(test)]
440            Self::ResolveNegotiatedProducerParameters { .. } => false,
441        }
442    }
443}