Skip to main content

o_sfu_core/engine/room/transition/
publication.rs

1//! publication transitions keep unnegotiated media out of the room graph
2//!
3//! ```text
4//! publish intent
5//!   |
6//!   +-- existing producer --> activity commit --> effects after lock
7//!   |
8//!   +-- offer in flight ----> queued intent ---> answer ---> stage next offer
9//!   |
10//!   +-- new producer -------> StagedPublish ---> answer-proven RTP
11//!                              |                  |
12//!                              |                  v
13//!                              |                room graph commit
14//!                              |                  |
15//!                              v                  v
16//!                         rollback teardown   effects after lock
17//! ```
18//!
19//! only answer-proven RTP enters the room graph
20//! teardown, worker route updates and fanout run after state mutation releases
21//! the room lock
22
23use o_sfu_router::rtp::MediaStream as RouterRtpParameters;
24use tracing::warn;
25
26use super::super::{
27    Room, RoomUserOperation,
28    effects::batch::{RoomEffectContext, RoomEffects},
29    media_graph::{ProducerActivityCommit, PublishIntentPlan, ValidatedPublish},
30};
31#[cfg(any(test, feature = "testing-transport"))]
32use crate::engine::{ConnectionId, UserId};
33use crate::engine::{
34    media_transport::{AppliedSessionAnswer, TransportAdapterError},
35    source_model::{SourceDeactivateIntent, SourcePublishIntent, UserStreamId},
36};
37
38mod staging;
39#[cfg(any(test, feature = "testing-transport"))]
40#[path = "TESTS/publication_support.rs"]
41mod test_support;
42
43pub use staging::{StagedPublish, StagedPublishes};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PublishIntentOutcome {
47    Noop,
48    Queue,
49    Activated,
50    Staged,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum DeactivateIntentOutcome {
55    Noop,
56    RolledBack,
57    Deactivated,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum PublishStageOutcome {
62    Staged,
63    Duplicate,
64    DuplicateAfterReservation,
65    #[cfg(test)]
66    Rejected,
67}
68
69impl RoomUserOperation<'_> {
70    #[cfg(test)]
71    pub async fn stage_negotiated_publish(
72        self,
73        intent: &SourcePublishIntent,
74    ) -> Result<PublishStageOutcome, TransportAdapterError> {
75        let Some(validated_descriptor) = ({
76            let state = self.room.state.read().await;
77            state.validate_publish(self.user_id, self.connection_id, intent)
78        }) else {
79            return Ok(PublishStageOutcome::Rejected);
80        };
81        self.stage_validated_publish(validated_descriptor).await
82    }
83
84    async fn stage_validated_publish(
85        self,
86        validated_descriptor: ValidatedPublish,
87    ) -> Result<PublishStageOutcome, TransportAdapterError> {
88        let is_duplicate = {
89            let state = self.room.state.read().await;
90            state.staged_publishes.contains(
91                self.user_id,
92                self.connection_id,
93                &validated_descriptor.stream_id,
94            )
95        };
96        if is_duplicate {
97            return Ok(PublishStageOutcome::Duplicate);
98        }
99        let rtp_parameters = RouterRtpParameters::default();
100        let media = match self
101            .media_transport
102            .publish_media(
103                &validated_descriptor.session_key,
104                validated_descriptor.media_kind,
105                &rtp_parameters,
106            )
107            .await
108        {
109            Ok(media) => media,
110            Err(error) => {
111                warn!(
112                    user_id = ?self.user_id,
113                    connection_id = ?self.connection_id,
114                    stream_id = %validated_descriptor.stream_id,
115                    media_kind = ?validated_descriptor.media_kind,
116                    "failed to stage negotiated publish stream"
117                );
118                return Err(error);
119            }
120        };
121        let reserved_publish = StagedPublish::new(validated_descriptor, media);
122        let duplicate = {
123            let mut state = self.room.state.write().await;
124            // `publish_media` ran without the room lock. Revalidate connection
125            // identity and stream uniqueness before room state accepts it.
126            if state
127                .validate_publish_commit(&reserved_publish.descriptor, reserved_publish.media)
128                .is_some()
129            {
130                state.staged_publishes.stage(reserved_publish)
131            } else {
132                Some(reserved_publish)
133            }
134        };
135        if let Some(duplicate) = duplicate {
136            duplicate.release_reserved_media(self).await;
137            return Ok(PublishStageOutcome::DuplicateAfterReservation);
138        }
139        Ok(PublishStageOutcome::Staged)
140    }
141
142    pub(crate) async fn start_publish(
143        self,
144        intent: &SourcePublishIntent,
145        can_stage: bool,
146    ) -> Result<PublishIntentOutcome, TransportAdapterError> {
147        let has_staged_publish = {
148            let state = self.room.state.read().await;
149            state
150                .staged_publishes
151                .contains(self.user_id, self.connection_id, intent.stream_id())
152        };
153        if has_staged_publish {
154            return Ok(PublishIntentOutcome::Noop);
155        }
156        let source_policy_guard = self.room.source_policy_turn.lock().await;
157        let plan = {
158            let mut state = self.room.state.write().await;
159            state.apply_publish_intent(self.user_id, self.connection_id, intent, can_stage)
160        };
161        match plan {
162            PublishIntentPlan::Activate(commit) => {
163                // Prevent another source-policy turn from interleaving with this
164                // activity commit and its ordered policy and transport effects.
165                self.execute_publication_activity(commit).await;
166                Ok(PublishIntentOutcome::Activated)
167            }
168            PublishIntentPlan::Noop => {
169                drop(source_policy_guard);
170                Ok(PublishIntentOutcome::Noop)
171            }
172            PublishIntentPlan::Queue => {
173                drop(source_policy_guard);
174                Ok(PublishIntentOutcome::Queue)
175            }
176            PublishIntentPlan::Stage(validated) => {
177                drop(source_policy_guard);
178                if self.stage_validated_publish(validated).await? == PublishStageOutcome::Staged {
179                    Ok(PublishIntentOutcome::Staged)
180                } else {
181                    Ok(PublishIntentOutcome::Noop)
182                }
183            }
184        }
185    }
186
187    pub async fn rollback_staged_publish(self, stream_id: &UserStreamId) -> bool {
188        let Some(staged) = ({
189            let mut state = self.room.state.write().await;
190            state
191                .staged_publishes
192                .take(self.user_id, self.connection_id, stream_id)
193        }) else {
194            return false;
195        };
196        staged.release_reserved_media(self).await;
197        true
198    }
199
200    pub(crate) async fn deactivate_publication(
201        self,
202        intent: &SourceDeactivateIntent,
203    ) -> DeactivateIntentOutcome {
204        if self.rollback_staged_publish(intent.stream_id()).await {
205            return DeactivateIntentOutcome::RolledBack;
206        }
207        // Keep publication activity and its policy effects in one serialized
208        // turn so policy cannot observe the state change without its transport work.
209        let _source_policy_guard = self.room.source_policy_turn.lock().await;
210        let commit = {
211            let mut state = self.room.state.write().await;
212            state.apply_publication_activity(
213                self.user_id,
214                self.connection_id,
215                intent.stream_id(),
216                false,
217                intent.presence(),
218            )
219        };
220        let Ok(commit) = commit else {
221            return DeactivateIntentOutcome::Noop;
222        };
223        self.execute_publication_activity(commit).await;
224        DeactivateIntentOutcome::Deactivated
225    }
226
227    /// Resolves this connection's staged publishes against an accepted answer.
228    pub(crate) async fn commit_staged_publishes(self, applied_answer: &AppliedSessionAnswer) {
229        // Hold one source-policy turn across the answer batch. Policy must not
230        // observe a committed prefix while later answer-proven publishes remain
231        // outside the room graph.
232        let _source_policy_guard = self.room.source_policy_turn.lock().await;
233        let staged = {
234            let mut state = self.room.state.write().await;
235            state
236                .staged_publishes
237                .take_for_connection(self.user_id, self.connection_id)
238        };
239        for publish in staged {
240            publish.commit_answer_guarded(self, applied_answer).await;
241        }
242    }
243
244    async fn execute_publication_activity(self, commit: ProducerActivityCommit) {
245        RoomEffects::from_publication_activity(commit)
246            .execute_with_source_policy_guard(
247                self.room,
248                RoomEffectContext::runtime(self.media_transport),
249            )
250            .await;
251    }
252}
253
254impl Room {
255    #[cfg(any(test, feature = "testing-transport"))]
256    #[must_use]
257    pub async fn has_staged_publish(
258        &self,
259        user_id: &UserId,
260        connection_id: ConnectionId,
261        stream_id: &UserStreamId,
262    ) -> bool {
263        self.state
264            .read()
265            .await
266            .staged_publishes
267            .contains(user_id, connection_id, stream_id)
268    }
269}
270
271#[cfg(test)]
272#[path = "TESTS/publication.rs"]
273mod tests;