Skip to main content

o_sfu_protocol/
core.rs

1//! Pure client-side signaling state machine for the `o-sfu` protocol.
2//!
3//! [`ProtocolCore`] performs no I/O. Each transition returns ordered [`Command`]
4//! values for the host to execute before reporting follow-up events. This keeps
5//! transitions deterministic and lets Wasm, native and test hosts share the
6//! same lifecycle rules.
7
8use std::collections::BTreeMap;
9
10use secrecy::SecretString;
11use serde::{Deserialize, Serialize};
12
13mod connection_lifecycle;
14mod outbound_batch;
15mod request_flow;
16mod request_tracker;
17mod server_events;
18mod sticky_replay;
19mod timers;
20
21use outbound_batch::{FlushMode, OutboundBatcher};
22use request_tracker::RequestTracker;
23use sticky_replay::StickyReplayState;
24use timers::RequestTimeoutId;
25
26use crate::{
27    shared::{
28        AvailableFeatures, DownloadStates, JsonPayload, RecordingState, RecordingStateUpdate,
29        StreamType, UserId, UserInfo,
30    },
31    signaling::{
32        AuthPayload, ClientBroadcastPayload, ClientEnvelope, ClientMessage, MAX_ENVELOPE_BATCH_LEN,
33        NegotiationUploadSlot, PeerSnapshot, RequestId, ServerEnvelope, StreamIntentPayload,
34        SubscribePayload, TrackBinding, WebSocketCloseCode, WelcomePayload, decode_envelope_batch,
35    },
36    wire::ServerMessage,
37};
38
39/// host-facing timer id used by the recovery backoff scheduler
40pub const RECOVERY_TIMER_ID: u32 = 1;
41const BATCH_FLUSH_TIMER_ID: u32 = 2;
42const INITIAL_RECOVERY_DELAY_MS: u32 = 1_000;
43const MAX_RECOVERY_DELAY_MS: u32 = 30_000;
44const BATCH_FLUSH_DELAY_MS: u32 = 100;
45const REQUEST_TIMEOUT_MS: u32 = 5_000;
46const MAX_OUTBOUND_BATCH_LEN: usize = 16;
47
48/// One ordered side effect for the host that drives [`ProtocolCore`].
49///
50/// The host must execute each returned vector before reporting follow-up events.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52#[serde(tag = "kind", rename_all = "camelCase")]
53pub enum Command {
54    /// Send the already serialized JSON frame unchanged over the WebSocket.
55    SendWebSocket {
56        frame: String,
57    },
58    /// Apply a remote SDP offer to the local `RTCPeerConnection`.
59    ApplyNegotiation {
60        #[serde(rename = "requestId")]
61        request_id: RequestId,
62        #[serde(rename = "negotiationKind")]
63        kind: NegotiationKind,
64        sdp: String,
65        #[serde(rename = "uploadSlots")]
66        upload_slots: Vec<NegotiationUploadSlot>,
67    },
68    ClosePeerConnection,
69    CloseWebSocket {
70        code: u16,
71    },
72    /// Notify listeners of a connection-state transition, with an optional
73    /// human-readable cause (e.g. `"kicked"`, `"full"`).
74    EmitStateChange {
75        state: ConnectionState,
76        cause: Option<String>,
77    },
78    SetAvailableFeatures {
79        #[serde(serialize_with = "crate::host_bridge::serialize_features")]
80        features: AvailableFeatures,
81    },
82    SetRecordingState {
83        #[serde(serialize_with = "crate::host_bridge::serialize_recording_state")]
84        state: RecordingState,
85    },
86    /// Emit a protocol-domain event for the host projection layer.
87    #[serde(rename = "emitUpdate")]
88    EmitEvent {
89        #[serde(
90            rename = "update",
91            serialize_with = "crate::host_bridge::serialize_protocol_event"
92        )]
93        event: ProtocolEvent,
94    },
95    BeginPendingRequest {
96        request: PendingRequest,
97    },
98    /// Cancel `timeout_timer_id` before resolving `request_id`.
99    CompletePendingRequest {
100        #[serde(rename = "requestId")]
101        request_id: RequestId,
102        #[serde(rename = "timeoutTimerId")]
103        timeout_timer_id: u32,
104        ok: bool,
105    },
106    /// Cancel `timeout_timer_id` then fail `request_id` using its public method's policy.
107    RejectPendingRequest {
108        #[serde(rename = "requestId")]
109        request_id: RequestId,
110        #[serde(rename = "timeoutTimerId")]
111        timeout_timer_id: u32,
112        error: String,
113    },
114    /// Start a one-shot timer; the host must call [`ProtocolCore::on_timer`]
115    /// when it fires.
116    ScheduleTimer {
117        id: u32,
118        ms: u32,
119    },
120    CancelTimer {
121        id: u32,
122    },
123    /// Open a new WebSocket to the given URL.
124    Connect {
125        url: String,
126    },
127}
128
129pub(crate) type Commands = Vec<Command>;
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum ConnectionState {
134    Disconnected,
135    Connecting,
136    Authenticated,
137    Connected,
138    Recovering,
139    Closed,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum ProtocolEvent {
144    PeerSnapshot {
145        peers: Vec<PeerSnapshot>,
146    },
147    TrackSnapshot {
148        bindings: Vec<TrackBinding>,
149    },
150    PeerInfo {
151        user_id: UserId,
152        info: UserInfo,
153    },
154    PeerLeft {
155        user_id: UserId,
156    },
157    Broadcast {
158        sender_id: UserId,
159        message: JsonPayload,
160    },
161    RecordingStateChanged {
162        state: RecordingStateUpdate,
163    },
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub enum NegotiationKind {
169    Offer,
170    Renegotiate,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub(crate) enum PendingRequestKind {
175    StartRecording,
176    StopRecording,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
180#[serde(rename_all = "camelCase")]
181pub struct PendingRequest {
182    pub request_id: RequestId,
183    pub timeout_timer_id: u32,
184    pub timeout_ms: u32,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188struct ConnectContext {
189    url: String,
190    jwt: String,
191    room: Option<String>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195enum ProtocolPhase {
196    Disconnected,
197    Connecting,
198    Authenticated(Option<RequestId>),
199    Connected(Option<RequestId>),
200    Recovering,
201    Closed,
202}
203
204impl ProtocolPhase {
205    const fn connection_state(&self) -> ConnectionState {
206        match self {
207            Self::Disconnected => ConnectionState::Disconnected,
208            Self::Connecting => ConnectionState::Connecting,
209            Self::Authenticated(_) => ConnectionState::Authenticated,
210            Self::Connected(_) => ConnectionState::Connected,
211            Self::Recovering => ConnectionState::Recovering,
212            Self::Closed => ConnectionState::Closed,
213        }
214    }
215
216    const fn is_awaiting_welcome(&self) -> bool {
217        matches!(self, Self::Connecting | Self::Recovering)
218    }
219
220    const fn can_send_client_messages(&self) -> bool {
221        matches!(self, Self::Authenticated(_) | Self::Connected(_))
222    }
223
224    const fn can_enter_connected(&self) -> bool {
225        matches!(self, Self::Authenticated(None))
226    }
227
228    fn accept_negotiation(
229        &mut self,
230        request_id: &RequestId,
231        kind: NegotiationKind,
232    ) -> Result<(), NegotiationRejection> {
233        match (self, kind) {
234            (Self::Authenticated(pending), NegotiationKind::Offer)
235            | (Self::Connected(pending), NegotiationKind::Renegotiate) => {
236                if pending.is_some() {
237                    return Err(NegotiationRejection::ProtocolError);
238                }
239                *pending = Some(request_id.clone());
240                Ok(())
241            }
242            (Self::Authenticated(_), NegotiationKind::Renegotiate)
243            | (Self::Connected(_), NegotiationKind::Offer) => {
244                Err(NegotiationRejection::ProtocolError)
245            }
246            (
247                Self::Disconnected | Self::Connecting | Self::Recovering | Self::Closed,
248                NegotiationKind::Offer | NegotiationKind::Renegotiate,
249            ) => Err(NegotiationRejection::Ignored),
250        }
251    }
252
253    fn resolve_negotiation(&mut self, request_id: &RequestId, kind: NegotiationKind) -> bool {
254        let pending = match (self, kind) {
255            (Self::Authenticated(pending), NegotiationKind::Offer)
256            | (Self::Connected(pending), NegotiationKind::Renegotiate) => pending,
257            (Self::Authenticated(_), NegotiationKind::Renegotiate)
258            | (Self::Connected(_), NegotiationKind::Offer)
259            | (
260                Self::Disconnected | Self::Connecting | Self::Recovering | Self::Closed,
261                NegotiationKind::Offer | NegotiationKind::Renegotiate,
262            ) => return false,
263        };
264        if pending.as_ref() != Some(request_id) {
265            return false;
266        }
267        *pending = None;
268        true
269    }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub(super) enum NegotiationRejection {
274    Ignored,
275    ProtocolError,
276}
277
278/// The stored state falls into three groups:
279///   - session state needed to interpret later protocol messages
280///   - remembered client intent that should survive reconnects
281///   - in-flight host work that must be cancelled or resolved during cleanup
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct ProtocolCore {
284    /// Lifecycle and server-driven negotiation state.
285    phase: ProtocolPhase,
286    /// Users retained by SDP MID for peer departure and teardown cleanup.
287    ///
288    /// Track snapshots replace the map and the last binding for a MID wins.
289    /// Peer departures remove their entries. Disconnect and socket loss clear it.
290    track_users_by_mid: BTreeMap<String, UserId>,
291    /// Latest client intent that must be replayed after a recovered socket is
292    /// authenticated.
293    ///
294    /// Publication, subscription and local user-info updates are kept here
295    /// because they describe what the user still wants. One-off broadcasts and
296    /// request-response operations are not sticky because replaying them later
297    /// would change their meaning.
298    sticky_replay: StickyReplayState,
299    /// Saved admission context for the active connection attempt.
300    ///
301    /// Recovery reuses this URL, JWT and optional room to open the next socket.
302    /// Explicit disconnects, terminal close codes and fresh connects clear or
303    /// replace it so old credentials cannot revive a stopped session.
304    connect_context: Option<ConnectContext>,
305    /// Delay that will be used for the next recovery retry.
306    ///
307    /// The value is reset after a successful welcome or intentional lifecycle
308    /// reset. Transient websocket loss consumes the current value when
309    /// scheduling recovery, then increases it for the following retry.
310    recovery_delay_ms: u32,
311    /// Buffered outbound envelopes waiting for an immediate flush, size limit
312    /// or batch timer.
313    ///
314    /// The batcher owns only serializable protocol envelopes and the knowledge
315    /// that a flush timer is pending. The host still owns the actual timer and
316    /// websocket write side effects emitted as commands.
317    outbound_batch: OutboundBatcher,
318    /// Tracks request-response operations that must resolve exactly once.
319    ///
320    /// Each live request is paired with one timeout timer. Responses and timer
321    /// callbacks both flow through this tracker so stale, mismatched or racing
322    /// events cannot resolve the wrong host promise.
323    request_tracker: RequestTracker,
324}
325
326impl Default for ProtocolCore {
327    fn default() -> Self {
328        Self::new()
329    }
330}
331
332impl ProtocolCore {
333    /// Builds a fresh protocol state machine with no remembered user intent.
334    ///
335    /// Reconnect replay is opt-in through the mutating APIs below, so a new
336    /// core starts from a fully fresh state instead of assuming any previous room,
337    /// publication, or subscription state.
338    #[must_use]
339    pub fn new() -> Self {
340        Self {
341            phase: ProtocolPhase::Disconnected,
342            track_users_by_mid: BTreeMap::new(),
343            sticky_replay: StickyReplayState::new(),
344            connect_context: None,
345            recovery_delay_ms: INITIAL_RECOVERY_DELAY_MS,
346            outbound_batch: OutboundBatcher::new(),
347            request_tracker: RequestTracker::new(),
348        }
349    }
350
351    #[must_use]
352    pub const fn state(&self) -> ConnectionState {
353        self.phase.connection_state()
354    }
355
356    /// Authenticates a newly opened socket with the stored connect context.
357    ///
358    /// Recovery reuses the same JWT and optional room that [`ProtocolCore::connect`] captured,
359    /// which keeps every socket attempt tied to one explicit admission context.
360    pub fn on_ws_open(&mut self) -> Vec<Command> {
361        if !self.phase.is_awaiting_welcome() {
362            return Vec::new();
363        }
364        let Some(connect_context) = self.connect_context.as_ref() else {
365            return Vec::new();
366        };
367        self.enqueue_client_message(
368            ClientMessage::Auth(AuthPayload {
369                jwt: SecretString::from(connect_context.jwt.clone()),
370                channel: connect_context.room.clone(),
371            }),
372            FlushMode::Immediate,
373        )
374    }
375
376    /// handle ws message
377    ///
378    /// Malformed batches or envelopes are treated as protocol violations.
379    /// The whole batch is decoded before any envelope is applied so partially
380    /// applied server state cannot survive after a later decode error.
381    pub fn on_ws_message(&mut self, frame: &str) -> Vec<Command> {
382        let Ok(batch) = decode_envelope_batch(frame, MAX_ENVELOPE_BATCH_LEN) else {
383            return close_for_protocol_error();
384        };
385        let Ok(envelopes) = batch
386            .into_iter()
387            .map(ServerEnvelope::decode)
388            .collect::<Result<Vec<_>, _>>()
389        else {
390            return close_for_protocol_error();
391        };
392        let mut commands = Vec::new();
393        for envelope in envelopes {
394            match envelope {
395                ServerEnvelope::Message(message) => {
396                    if self.phase.is_awaiting_welcome()
397                        && !matches!(message, ServerMessage::Welcome(_))
398                    {
399                        return close_for_protocol_error();
400                    }
401                    commands.extend(server_events::handle_server_message(self, message));
402                }
403                ServerEnvelope::Request {
404                    request_id,
405                    request,
406                } => {
407                    commands.extend(request_flow::handle_server_request(
408                        self, request_id, request,
409                    ));
410                }
411                ServerEnvelope::Response {
412                    response_to,
413                    response,
414                } => {
415                    commands.extend(request_flow::handle_server_response(
416                        self,
417                        &response_to,
418                        response,
419                    ));
420                }
421            }
422        }
423        commands
424    }
425
426    fn accept_welcome(&mut self, payload: WelcomePayload) -> Commands {
427        if !self.phase.is_awaiting_welcome() {
428            return Vec::new();
429        }
430        let WelcomePayload {
431            features,
432            recording,
433            peers,
434        } = payload;
435        self.recovery_delay_ms = INITIAL_RECOVERY_DELAY_MS;
436        self.phase = ProtocolPhase::Authenticated(None);
437
438        let mut commands = vec![
439            Command::SetAvailableFeatures { features },
440            Command::SetRecordingState { state: recording },
441            Command::EmitStateChange {
442                state: self.phase.connection_state(),
443                cause: None,
444            },
445        ];
446        if !peers.is_empty() {
447            commands.push(Command::EmitEvent {
448                event: ProtocolEvent::PeerSnapshot { peers },
449            });
450        }
451        commands.extend(self.replay_session_state());
452        commands
453    }
454
455    /// Marks the local transport layer as ready after the initial negotiation.
456    ///
457    /// The host should call this only once the peer connection is usable for
458    /// media, because it is what upgrades the core from authenticated signaling
459    /// state to a fully connected user.
460    pub fn on_transport_ready(&mut self) -> Vec<Command> {
461        if !self.phase.can_enter_connected() {
462            return Vec::new();
463        }
464        self.phase = ProtocolPhase::Connected(None);
465        let mut commands = vec![Command::EmitStateChange {
466            state: self.state(),
467            cause: None,
468        }];
469        commands.extend(self.replay_publication_state());
470        commands
471    }
472
473    /// Stores the desired publication state and sends it when the media transport is ready.
474    ///
475    /// Publish intent is sticky across reconnects, which lets UI toggles be issued
476    /// before authentication completes without losing the latest desired state.
477    pub fn publish(&mut self, stream_type: StreamType, active: bool) -> Vec<Command> {
478        self.sticky_replay.set_publish_active(stream_type, active);
479        if !matches!(&self.phase, ProtocolPhase::Connected(_)) {
480            return Vec::new();
481        }
482        let message = if active {
483            ClientMessage::Publish(StreamIntentPayload { stream_type })
484        } else {
485            ClientMessage::Unpublish(StreamIntentPayload { stream_type })
486        };
487        self.enqueue_client_message(message, FlushMode::Batched)
488    }
489
490    /// Remembers the latest per-peer subscription intent for reconnect replay.
491    ///
492    /// Repeated updates merge at the sticky layer, so callers can send partial
493    /// audio/camera/screen adjustments without rebuilding the full preference set
494    /// on every change or after recovery.
495    pub fn subscribe(&mut self, user_id: UserId, states: DownloadStates) -> Vec<Command> {
496        self.sticky_replay
497            .remember_subscription_states(&user_id, &states);
498        if !self.phase.can_send_client_messages() {
499            return Vec::new();
500        }
501        self.enqueue_client_message(
502            ClientMessage::Subscribe(SubscribePayload { user_id, states }),
503            FlushMode::Batched,
504        )
505    }
506
507    /// Persists the latest local user metadata patch for the current room.
508    ///
509    /// User info is replayed after reconnect so transient transport failures do
510    /// not silently reset presence indicators such as mute, hand raise or camera
511    /// state back to server defaults.
512    pub fn update_info(&mut self, info: UserInfo) -> Vec<Command> {
513        self.sticky_replay.remember_info(&info);
514        if !self.phase.can_send_client_messages() {
515            return Vec::new();
516        }
517        self.enqueue_client_message(ClientMessage::Info(info), FlushMode::Batched)
518    }
519
520    /// Sends a best-effort broadcast to the current room.
521    ///
522    /// Broadcast payloads are not sticky: if the client is not yet
523    /// authenticated, the message is dropped instead of being replayed later out
524    /// of its original conversational context.
525    pub fn broadcast(&mut self, message: JsonPayload) -> Vec<Command> {
526        if !self.phase.can_send_client_messages() {
527            return Vec::new();
528        }
529        self.enqueue_client_message(
530            ClientMessage::Broadcast(ClientBroadcastPayload { message }),
531            FlushMode::Batched,
532        )
533    }
534
535    /// Dispatches all timer callbacks through one entry point.
536    ///
537    /// Timer ids are part of the protocol-core contract: recovery, outbound batch
538    /// flushing, and request timeouts each reserve their own namespace and must be
539    /// routed back here by the host in the order they fire.
540    pub fn on_timer(&mut self, timer_id: u32) -> Vec<Command> {
541        if timer_id == RECOVERY_TIMER_ID {
542            return connection_lifecycle::handle_recovery_timer(self);
543        }
544        if timer_id == BATCH_FLUSH_TIMER_ID {
545            return self.outbound_batch.flush(false);
546        }
547        if let Some(commands) = RequestTimeoutId::try_from_raw(timer_id)
548            .and_then(|timeout_id| self.request_tracker.resolve_timeout(timeout_id))
549        {
550            return commands;
551        }
552        Vec::new()
553    }
554
555    fn enqueue_client_message(&mut self, message: ClientMessage, mode: FlushMode) -> Commands {
556        let Some(envelope) = ClientEnvelope::Message(message).into_envelope().ok() else {
557            return Vec::new();
558        };
559        self.outbound_batch.enqueue(envelope, mode)
560    }
561
562    fn clear_runtime_state(&mut self) {
563        self.track_users_by_mid.clear();
564        self.outbound_batch.clear();
565        self.request_tracker.clear();
566    }
567
568    /// Tears down runtime state while emitting the cleanup commands the host still owes.
569    ///
570    /// This is used on disconnect and terminal close paths where queued batches,
571    /// timeout timers, and pending requests must be cancelled explicitly instead of
572    /// being forgotten inside the pure state machine.
573    fn teardown_runtime_state(&mut self) -> Commands {
574        let mut commands = self.outbound_batch.discard_pending();
575        commands.extend(self.request_tracker.fail_all());
576        if !self.track_users_by_mid.is_empty() {
577            self.track_users_by_mid.clear();
578            commands.push(Command::EmitEvent {
579                event: ProtocolEvent::TrackSnapshot {
580                    bindings: Vec::new(),
581                },
582            });
583        }
584        commands
585    }
586
587    /// Flushes room-level intent immediately after the server snapshot is known.
588    fn replay_session_state(&mut self) -> Commands {
589        if !self.phase.can_send_client_messages() {
590            return Vec::new();
591        }
592        let Some(replay_batch) = self.sticky_replay.replay_session_batch() else {
593            return Vec::new();
594        };
595        self.outbound_batch.extend(replay_batch);
596        self.outbound_batch.flush(true)
597    }
598
599    /// Flushes publish intent after the recovered media transport is ready.
600    fn replay_publication_state(&mut self) -> Commands {
601        if !self.phase.can_send_client_messages() {
602            return Vec::new();
603        }
604        let replay_batch: Vec<_> = self
605            .sticky_replay
606            .active_publications()
607            .filter_map(|stream_type| {
608                ClientEnvelope::Message(ClientMessage::Publish(StreamIntentPayload { stream_type }))
609                    .into_envelope()
610                    .ok()
611            })
612            .collect();
613        if replay_batch.is_empty() {
614            return Vec::new();
615        }
616        self.outbound_batch.extend(replay_batch);
617        self.outbound_batch.flush(true)
618    }
619}
620
621fn empty_features() -> AvailableFeatures {
622    AvailableFeatures {
623        rtc: false,
624        transcription: false,
625        audio_recording: false,
626        video_recording: false,
627    }
628}
629
630fn close_for_protocol_error() -> Commands {
631    vec![Command::CloseWebSocket {
632        code: u16::from(WebSocketCloseCode::ProtocolError),
633    }]
634}
635
636/// Grows reconnect delay by 1.5x while keeping the backoff bounded.
637///
638/// The sequence is modest so short-lived outages recover quickly,
639/// but repeated failures still spread out retries and avoid hot-loop reconnects.
640fn next_recovery_delay(current_delay_ms: u32) -> u32 {
641    (current_delay_ms.saturating_mul(3) / 2).min(MAX_RECOVERY_DELAY_MS)
642}
643
644#[cfg(test)]
645#[path = "core/TESTS/mod.rs"]
646mod tests;