Skip to main content

sipx_media/
conference.rs

1//! A conference: every party hears every other party, and never themselves.
2//!
3//! Unlike a [`crate::bridge::Bridge`], a conference cannot pass bytes through. Mixing happens on
4//! samples, so every leg is decoded on the way in and encoded on the way out whatever codec it
5//! negotiated. That is not a shortcoming to be optimised away later โ€” adding two ยต-law codes is
6//! not adding two amplitudes, and a mixer that tried would produce noise.
7//!
8//! The shape is a clock, not a chain of forwards. A bridge can forward each packet as it
9//! arrives because there is exactly one thing to send it to; a mixer has to decide *when* a
10//! frame is complete, because it is waiting on N participants who will not arrive together. So
11//! one task ticks at the packet interval, takes whatever each participant has produced since
12//! the last tick, and sends each of them the sum of the others.
13//!
14//! A participant who has said nothing contributes silence, which is exactly right: the mix goes
15//! out on time and the quiet participant is simply quiet. The alternative โ€” waiting for
16//! everyone โ€” makes the whole conference as late as its worst connection.
17//! **Experimental** (`A-8`): as with [`super::bridge`], real over sessions you own and not
18//! reachable from a `Call` (`C-6`).
19//!
20
21use std::collections::HashMap;
22use std::sync::{Arc, Mutex as StdMutex};
23use std::time::Duration;
24
25use sipx_audio::mix::mix_into;
26use tokio::sync::Mutex;
27use tokio::task::JoinHandle;
28
29use crate::session::{MediaSession, Stop};
30
31/// A conference worker configuration that cannot make forward progress.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
33#[non_exhaustive]
34pub enum ConferenceError {
35    /// The mixer interval is below the one-millisecond runtime floor.
36    #[error("conference mix interval must be at least 1 ms, got {0:?}")]
37    IntervalTooShort(Duration),
38}
39
40/// Who is in the conference.
41type Members = Arc<Mutex<HashMap<u64, Member>>>;
42
43/// The most audio held for a participant the mixer is not draining.
44///
45/// Half a second at 8 kHz: ample slack for a mixer that is keeping up, and a hard stop for one
46/// that is not. Without a bound, a conference whose mixing task has died grows a buffer per
47/// participant for as long as anybody keeps talking.
48const MOST_PENDING: usize = 4_000;
49
50struct Member {
51    session: Arc<MediaSession>,
52    /// What this participant has contributed since the last tick.
53    pending: Vec<i16>,
54}
55
56/// Worker registration and shutdown are one state transition.
57///
58/// A collector must never exist outside this registry while shutdown can drain it. Keeping the
59/// closed bit beside the handles makes the decision durable even if a notification races a task's
60/// first poll.
61#[derive(Debug)]
62struct Workers {
63    closed: bool,
64    collectors: HashMap<u64, JoinHandle<()>>,
65    mixer: Option<JoinHandle<()>>,
66}
67
68#[cfg(test)]
69#[derive(Debug, Default)]
70struct LifecycleHooks {
71    join_before_registration: StdMutex<Option<JoinRegistrationHook>>,
72    close_waiting_for_members: StdMutex<Option<tokio::sync::oneshot::Sender<()>>>,
73}
74
75#[cfg(test)]
76#[derive(Debug)]
77struct JoinRegistrationHook {
78    reached: tokio::sync::oneshot::Sender<()>,
79    release: std::sync::mpsc::Receiver<()>,
80}
81
82impl std::fmt::Debug for Member {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        // The session is deliberately not printed: it is large, and what a reader of a debug
85        // dump wants to know about a participant is how far behind they are.
86        f.debug_struct("Member")
87            .field("pending", &self.pending.len())
88            .finish_non_exhaustive()
89    }
90}
91
92/// Several calls mixed together.
93///
94/// Participants join and leave while it runs. Neither disturbs the others: the mixing clock
95/// does not stop, and a participant who leaves simply stops contributing and stops being sent
96/// to.
97#[derive(Debug)]
98pub struct Conference {
99    members: Members,
100    next_id: std::sync::atomic::AtomicU64,
101    workers: StdMutex<Workers>,
102    samples_per_frame: usize,
103    stop: Arc<Stop>,
104    #[cfg(test)]
105    lifecycle_hooks: LifecycleHooks,
106}
107
108impl Conference {
109    /// Start an empty conference, mixing at this frame size and interval.
110    ///
111    /// The interval must match what the participants' sessions send at, or the conference
112    /// produces frames faster or slower than they can be played and the queues drift. It must
113    /// also be at least one millisecond.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ConferenceError::IntervalTooShort`] before spawning the mixer when `interval`
118    /// is shorter than one millisecond.
119    pub fn new(samples_per_frame: usize, interval: Duration) -> Result<Self, ConferenceError> {
120        if interval < Duration::from_millis(1) {
121            return Err(ConferenceError::IntervalTooShort(interval));
122        }
123        let members: Members = Arc::new(Mutex::new(HashMap::new()));
124        let stop = Arc::new(Stop::default());
125        let mixer = tokio::spawn(mix_loop(
126            Arc::clone(&members),
127            samples_per_frame,
128            interval,
129            Arc::clone(&stop),
130        ));
131        Ok(Self {
132            members,
133            next_id: std::sync::atomic::AtomicU64::new(0),
134            workers: StdMutex::new(Workers {
135                closed: false,
136                collectors: HashMap::new(),
137                mixer: Some(mixer),
138            }),
139            samples_per_frame,
140            stop,
141            #[cfg(test)]
142            lifecycle_hooks: LifecycleHooks::default(),
143        })
144    }
145
146    /// A conference at the usual telephony rate: 20 ms frames of 8 kHz audio.
147    ///
148    /// # Errors
149    ///
150    /// The fixed interval currently satisfies [`ConferenceError`]'s minimum. The fallible return
151    /// keeps this convenience constructor on the same explicit startup contract as [`Self::new`].
152    pub fn narrowband() -> Result<Self, ConferenceError> {
153        Self::new(160, Duration::from_millis(20))
154    }
155
156    /// Add a participant, and return the handle used to remove them.
157    pub async fn join(&self, session: Arc<MediaSession>) -> u64 {
158        let id = self
159            .next_id
160            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
161
162        let mut participants = self.members.lock().await;
163        let mut workers = self.workers_lock();
164        if workers.closed {
165            return id;
166        }
167        participants.insert(
168            id,
169            Member {
170                session: Arc::clone(&session),
171                pending: Vec::new(),
172            },
173        );
174
175        // One collector per participant, because `recv` is a blocking wait on that
176        // participant's channel and the mixer cannot afford to wait on any of them. Spawn and
177        // handle insertion happen while the lifecycle lock is held: close either drains this
178        // handle or marks the conference closed before this point, never between the two.
179        let members = Arc::clone(&self.members);
180        let stop = Arc::clone(&self.stop);
181        let collector = tokio::spawn(async move {
182            loop {
183                let samples = tokio::select! {
184                    () = stop.wait() => return,
185                    samples = session.recv() => samples,
186                };
187                let Some(samples) = samples else {
188                    return;
189                };
190                let mut members = members.lock().await;
191                let Some(member) = members.get_mut(&id) else {
192                    return;
193                };
194                member.pending.extend_from_slice(&samples);
195                if member.pending.len() > MOST_PENDING {
196                    let excess = member.pending.len() - MOST_PENDING;
197                    member.pending.drain(..excess);
198                }
199            }
200        });
201        #[cfg(test)]
202        self.pause_join_before_registration();
203        workers.collectors.insert(id, collector);
204        id
205    }
206
207    /// Remove a participant.
208    ///
209    /// The others carry on. Their mixes simply stop containing this one, which is what leaving
210    /// a conversation sounds like.
211    pub async fn leave(&self, id: u64) {
212        let mut members = self.members.lock().await;
213        let collector = self.workers_lock().collectors.remove(&id);
214        members.remove(&id);
215        drop(members);
216        if let Some(collector) = collector {
217            collector.abort();
218            let _ = collector.await;
219        }
220    }
221
222    /// How many are in it.
223    pub async fn len(&self) -> usize {
224        self.members.lock().await.len()
225    }
226
227    /// Whether nobody is in it.
228    pub async fn is_empty(&self) -> bool {
229        self.len().await == 0
230    }
231
232    /// The frame size being mixed at.
233    #[must_use]
234    pub fn samples_per_frame(&self) -> usize {
235        self.samples_per_frame
236    }
237
238    /// Stop mixing. The participants' sessions are left running.
239    pub async fn close(&self) {
240        // Acquire every async lock before changing the lifecycle. Cancellation while waiting is
241        // therefore a no-op. After this await, worker cancellation and participant release are a
242        // synchronous transition, so dropping this future cannot strand session Arcs in a closed
243        // conference.
244        let mut members = self.lock_members_for_close().await;
245        let workers = self.shutdown();
246        members.clear();
247        drop(members);
248        for worker in workers {
249            // An abort completes promptly at the task's next cancellation point. The result is
250            // cancellation itself, which is the expected shutdown outcome rather than an error
251            // for the caller.
252            let _ = worker.await;
253        }
254    }
255
256    /// Lock worker ownership even if a previous holder was cancelled while mutating it.
257    /// Poisoning cannot make an abort handle unsafe to use, and refusing the lock here would
258    /// turn one cancelled operation into a permanent worker leak.
259    fn workers_lock(&self) -> std::sync::MutexGuard<'_, Workers> {
260        match self.workers.lock() {
261            Ok(workers) => workers,
262            Err(poisoned) => poisoned.into_inner(),
263        }
264    }
265
266    async fn lock_members_for_close(&self) -> tokio::sync::MutexGuard<'_, HashMap<u64, Member>> {
267        #[cfg(not(test))]
268        {
269            self.members.lock().await
270        }
271        #[cfg(test)]
272        {
273            use std::future::Future as _;
274            use std::task::Poll;
275
276            let mut waiting = match self.lifecycle_hooks.close_waiting_for_members.lock() {
277                Ok(mut hook) => hook.take(),
278                Err(poisoned) => poisoned.into_inner().take(),
279            };
280            let mut lock = Box::pin(self.members.lock());
281            std::future::poll_fn(|cx| match lock.as_mut().poll(cx) {
282                Poll::Ready(members) => Poll::Ready(members),
283                Poll::Pending => {
284                    if let Some(waiting) = waiting.take() {
285                        // The receiver is test-owned and may have been cancelled with its task.
286                        // Either way, polling the contended lock is the state the hook records.
287                        let _ = waiting.send(());
288                    }
289                    Poll::Pending
290                }
291            })
292            .await
293        }
294    }
295
296    #[cfg(test)]
297    fn pause_join_before_registration(&self) {
298        let hook = match self.lifecycle_hooks.join_before_registration.lock() {
299            Ok(mut hook) => hook.take(),
300            Err(poisoned) => poisoned.into_inner().take(),
301        };
302        if let Some(hook) = hook {
303            // Both halves are test-owned and bounded. A failed receiver means the test has already
304            // ended; a missing release times out instead of pinning a runtime worker forever.
305            let _ = hook.reached.send(());
306            let _ = hook.release.recv_timeout(Duration::from_secs(2));
307        }
308    }
309
310    /// Idempotently signal and take ownership of every worker this conference owns.
311    fn shutdown(&self) -> Vec<JoinHandle<()>> {
312        let mut state = self.workers_lock();
313        state.closed = true;
314        self.stop.stop();
315        let mut workers = Vec::new();
316        if let Some(mixer) = state.mixer.take() {
317            mixer.abort();
318            workers.push(mixer);
319        }
320        workers.extend(state.collectors.drain().map(|(_, collector)| {
321            collector.abort();
322            collector
323        }));
324        workers
325    }
326}
327
328impl Drop for Conference {
329    fn drop(&mut self) {
330        // Drop cannot await, but aborting before releasing the retained handles makes every task
331        // cancellation-ready and prevents detached work from retaining participant sessions.
332        drop(self.shutdown());
333    }
334}
335
336/// Mix and send, once per interval.
337async fn mix_loop(members: Members, samples_per_frame: usize, interval: Duration, stop: Arc<Stop>) {
338    let mut tick = tokio::time::interval(interval);
339    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
340
341    loop {
342        tokio::select! {
343            () = stop.wait() => return,
344            _ = tick.tick() => {}
345        }
346
347        // Take a frame's worth from each participant, and the sessions to send to, in one
348        // pass. The lock is released before any sending: holding it across an await would let
349        // one slow participant stall the whole conference.
350        let (ids, frames, sessions) = {
351            let mut members = members.lock().await;
352            if members.is_empty() {
353                continue;
354            }
355            let mut ids = Vec::with_capacity(members.len());
356            let mut frames = Vec::with_capacity(members.len());
357            let mut sessions = Vec::with_capacity(members.len());
358            for (id, member) in members.iter_mut() {
359                let take = member.pending.len().min(samples_per_frame);
360                let mut frame: Vec<i16> = member.pending.drain(..take).collect();
361                // A participant who has said nothing this tick contributes silence. Waiting for
362                // them instead would make the conference as late as its worst connection.
363                frame.resize(samples_per_frame, 0);
364                ids.push(*id);
365                frames.push(frame);
366                sessions.push(Arc::clone(&member.session));
367            }
368            (ids, frames, sessions)
369        };
370
371        for (index, session) in sessions.iter().enumerate() {
372            // N-1: everyone except this one. Including their own audio would send their voice
373            // back a round trip late, which is the single most disorienting artefact a
374            // conference can produce.
375            let mut mixed = vec![0i16; samples_per_frame];
376            for (other, frame) in frames.iter().enumerate() {
377                if other == index {
378                    continue;
379                }
380                mix_into(&mut mixed, frame);
381            }
382            if !session.send(mixed).await {
383                // The session has gone. Its collector will notice too; nothing here needs to
384                // tear it down, and doing so would need the lock again mid-send.
385                tracing::debug!(id = ids.get(index), "a conference participant has gone");
386            }
387        }
388    }
389}
390
391#[cfg(test)]
392#[allow(
393    clippy::unwrap_used,
394    clippy::expect_used,
395    clippy::panic,
396    clippy::indexing_slicing
397)]
398mod tests {
399    use super::*;
400    use crate::session::{Codec, Config, MediaPort};
401
402    fn set_close_wait_hook(conference: &Conference) -> tokio::sync::oneshot::Receiver<()> {
403        let (waiting, reached) = tokio::sync::oneshot::channel();
404        match conference.lifecycle_hooks.close_waiting_for_members.lock() {
405            Ok(mut hook) => *hook = Some(waiting),
406            Err(poisoned) => *poisoned.into_inner() = Some(waiting),
407        }
408        reached
409    }
410
411    async fn wait_for_close_to_block(reached: tokio::sync::oneshot::Receiver<()>) {
412        tokio::time::timeout(Duration::from_secs(2), reached)
413            .await
414            .expect("close polls the members lock")
415            .expect("close reports its blocked lock poll");
416    }
417
418    #[tokio::test]
419    async fn cancelling_close_while_it_waits_leaves_no_half_closed_conference() {
420        let port = MediaPort::bind("127.0.0.1:0".parse().expect("valid"))
421            .await
422            .expect("binds");
423        let mut config = Config::new("127.0.0.1:9".parse().expect("valid"), Codec::Pcmu);
424        config.rtcp_interval = None;
425        let session = Arc::new(port.start(config).expect("valid media setup"));
426        let weak = Arc::downgrade(&session);
427        let conference = Arc::new(Conference::narrowband().expect("valid conference timing"));
428        conference.join(Arc::clone(&session)).await;
429
430        // Hold the only async lock close needs. The old ordering marked the conference stopped
431        // and drained every worker before parking here; cancelling the future then stranded the
432        // member Arc in a conference which could no longer run.
433        let members = conference.members.lock().await;
434        let close_waiting = set_close_wait_hook(&conference);
435        let closing = {
436            let conference = Arc::clone(&conference);
437            tokio::spawn(async move { conference.close().await })
438        };
439        wait_for_close_to_block(close_waiting).await;
440        assert!(
441            !closing.is_finished(),
442            "the close future is parked on the held members lock"
443        );
444        closing.abort();
445        let _ = closing.await;
446        assert!(
447            !conference.workers_lock().closed,
448            "cancellation before all locks are held must not half-close the conference"
449        );
450        drop(members);
451
452        drop(session);
453        conference.close().await;
454        assert!(
455            weak.upgrade().is_none(),
456            "a later close releases the participant rather than finding stranded state"
457        );
458    }
459
460    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
461    async fn close_cannot_pass_join_between_collector_spawn_and_registration() {
462        let port = MediaPort::bind("127.0.0.1:0".parse().expect("valid"))
463            .await
464            .expect("binds");
465        let mut config = Config::new("127.0.0.1:9".parse().expect("valid"), Codec::Pcmu);
466        config.rtcp_interval = None;
467        let session = Arc::new(port.start(config).expect("valid media setup"));
468        let weak = Arc::downgrade(&session);
469        let conference = Arc::new(Conference::narrowband().expect("valid conference timing"));
470
471        let (join_reached, reached) = tokio::sync::oneshot::channel();
472        let (release, join_release) = std::sync::mpsc::sync_channel(0);
473        {
474            let mut hook = match conference.lifecycle_hooks.join_before_registration.lock() {
475                Ok(hook) => hook,
476                Err(poisoned) => poisoned.into_inner(),
477            };
478            *hook = Some(JoinRegistrationHook {
479                reached: join_reached,
480                release: join_release,
481            });
482        }
483
484        let joining = {
485            let conference = Arc::clone(&conference);
486            let session = Arc::clone(&session);
487            tokio::spawn(async move { conference.join(session).await })
488        };
489        tokio::time::timeout(Duration::from_secs(2), reached)
490            .await
491            .expect("join reaches the registration boundary")
492            .expect("join reports the registration boundary");
493
494        let close_waiting = set_close_wait_hook(&conference);
495        let closing = {
496            let conference = Arc::clone(&conference);
497            tokio::spawn(async move { conference.close().await })
498        };
499        wait_for_close_to_block(close_waiting).await;
500        assert!(
501            !closing.is_finished(),
502            "close cannot drain workers while join owns the lifecycle transition"
503        );
504
505        release
506            .send(())
507            .expect("join is released to register its collector");
508        joining.await.expect("join task finishes");
509        closing.await.expect("close task finishes");
510        drop(session);
511
512        assert!(conference.is_empty().await);
513        assert!(
514            weak.upgrade().is_none(),
515            "close drains the collector registered by the serialized join"
516        );
517    }
518}