Skip to content

Commit daba066

Browse files
kixelatedclaude
andcommitted
fix(moq-gst): settle pump teardown with one compare-exchange
A pump reaching its pad and a teardown stopping it were two independent checks, so both could win: a catalog close reading the not-yet-live flag could cancel just after the pump re-read cancellation, leaving the pump to expose a pad and immediately yank it without an EOS. The shared flag becomes a three-state `PumpState`. A pump earns its pad by winning `SUBSCRIBING -> LIVE`, and every teardown path tries `SUBSCRIBING -> CANCELLED` first, so exactly one of them succeeds. A catalog close only signals the watch when it won, leaving a streaming pump to drain to its own EOS as before; removal, reshape, and shutdown still cancel unconditionally. Also strengthens two regression tests that could pass against the broken implementation by luck. A catalog consumer skips to the newest snapshot, so a session could read one update carrying both renditions and reach video before parking on audio, decided by `plan.add` ordering. The stalled- rendition test now waits for the pending subscription through the broadcast's `Dynamic` before announcing video, and the refused-rendition test announces both in a single snapshot so neither outcome depends on ordering. Both now fail against the previous behaviour on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ea35539 commit daba066

1 file changed

Lines changed: 128 additions & 27 deletions

File tree

rs/moq-gst/src/source/imp.rs

Lines changed: 128 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::collections::HashMap;
2-
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2+
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
33
use std::sync::{Arc, LazyLock, Mutex};
44
use std::time::Duration;
55

@@ -287,6 +287,38 @@ struct Shape {
287287
container: hang::catalog::Container,
288288
}
289289

290+
/// A pump's progress, shared with its [`ActiveTrack`] so teardown and pad creation can't both
291+
/// win. A pump is torn down two different ways depending on how far it got: before it owns a pad,
292+
/// stopping it means it must never create one; after, it owns a pad and has to drop it. One
293+
/// compare-exchange settles which of the two happened, so a pad can't slip out between a
294+
/// teardown's check and the pump's creation.
295+
struct PumpState(AtomicU8);
296+
297+
impl PumpState {
298+
const SUBSCRIBING: u8 = 0;
299+
const LIVE: u8 = 1;
300+
const CANCELLED: u8 = 2;
301+
302+
fn new() -> Self {
303+
Self(AtomicU8::new(Self::SUBSCRIBING))
304+
}
305+
306+
/// Claim the right to create a pad, false once a teardown got here first.
307+
fn go_live(&self) -> bool {
308+
self.0
309+
.compare_exchange(Self::SUBSCRIBING, Self::LIVE, Ordering::AcqRel, Ordering::Acquire)
310+
.is_ok()
311+
}
312+
313+
/// Stop a pump that hasn't taken a pad, false if it already has one and must be torn down
314+
/// through its cancel watch instead.
315+
fn cancel_before_live(&self) -> bool {
316+
self.0
317+
.compare_exchange(Self::SUBSCRIBING, Self::CANCELLED, Ordering::AcqRel, Ordering::Acquire)
318+
.is_ok()
319+
}
320+
}
321+
290322
/// A rendition we're currently serving, keyed in the session by moq track name.
291323
struct ActiveTrack {
292324
/// Identity we diff against on each catalog update; a change recreates the pad.
@@ -298,9 +330,25 @@ struct ActiveTrack {
298330
/// `is_finished()` to prune this entry once the pump ends (the `JoinSet` owns
299331
/// the task and reaps it); teardown goes through `cancel`, never `abort()`.
300332
task: tokio::task::AbortHandle,
301-
/// Set by the pump once it owns a pad. Until then it is still awaiting its subscription,
302-
/// which is the one state a closing catalog has to break it out of.
303-
live: Arc<AtomicBool>,
333+
/// Shared with the pump, so teardown and pad creation agree on which of them happened.
334+
state: Arc<PumpState>,
335+
}
336+
337+
impl ActiveTrack {
338+
/// Tear the pump down whatever stage it reached: one still subscribing never takes a pad,
339+
/// one that has drops it when it sees the watch.
340+
fn cancel(&self) {
341+
self.state.cancel_before_live();
342+
let _ = self.cancel.send(true);
343+
}
344+
345+
/// Tear the pump down only if it is still waiting on its subscription, leaving a streaming
346+
/// one to reach its own EOS.
347+
fn cancel_if_subscribing(&self) {
348+
if self.state.cancel_before_live() {
349+
let _ = self.cancel.send(true);
350+
}
351+
}
304352
}
305353

306354
async fn run_session(
@@ -384,9 +432,7 @@ async fn follow_catalog(
384432
None => {
385433
catalog_closed = true;
386434
for track in active.values() {
387-
if !track.live.load(Ordering::Relaxed) {
388-
let _ = track.cancel.send(true);
389-
}
435+
track.cancel_if_subscribing();
390436
}
391437
}
392438
}
@@ -399,7 +445,7 @@ async fn follow_catalog(
399445
// while we await the rest.
400446
// On the clean catalog-closed exit `active`/`pumps` are already drained, so this is a no-op.
401447
for (_, track) in active.drain() {
402-
let _ = track.cancel.send(true);
448+
track.cancel();
403449
}
404450
while pumps.join_next().await.is_some() {}
405451

@@ -461,7 +507,7 @@ fn reconcile(
461507
// Changed renditions also land in `plan.add`, so they respawn below under a fresh pad id.
462508
for name in plan.remove {
463509
if let Some(track) = active.remove(&name) {
464-
let _ = track.cancel.send(true);
510+
track.cancel();
465511
}
466512
}
467513

@@ -492,7 +538,7 @@ fn reconcile(
492538
};
493539

494540
let (cancel_tx, cancel_rx) = watch::channel(false);
495-
let live = Arc::new(AtomicBool::new(false));
541+
let state = Arc::new(PumpState::new());
496542
let task = pumps.spawn_on(
497543
Pump {
498544
element: element.clone(),
@@ -501,7 +547,7 @@ fn reconcile(
501547
caps: d.shape.caps.clone(),
502548
track,
503549
container,
504-
live: live.clone(),
550+
state: state.clone(),
505551
cancel: cancel_rx,
506552
}
507553
.run(),
@@ -514,7 +560,7 @@ fn reconcile(
514560
shape: d.shape.clone(),
515561
cancel: cancel_tx,
516562
task,
517-
live,
563+
state,
518564
},
519565
);
520566
}
@@ -585,8 +631,8 @@ struct Pump {
585631
caps: gst::Caps,
586632
track: moq_net::track::Consumer,
587633
container: moq_mux::catalog::hang::Container,
588-
/// Shared with this rendition's [`ActiveTrack::live`].
589-
live: Arc<AtomicBool>,
634+
/// Shared with this rendition's [`ActiveTrack::state`].
635+
state: Arc<PumpState>,
590636
cancel: watch::Receiver<bool>,
591637
}
592638

@@ -602,7 +648,7 @@ impl Pump {
602648
caps,
603649
track,
604650
container,
605-
live,
651+
state,
606652
mut cancel,
607653
} = self;
608654
// Resolves once the publisher answers with the track info. A catalog can name a track its
@@ -621,10 +667,12 @@ impl Pump {
621667
};
622668
let mut track = moq_mux::container::Consumer::new(subscriber, container).with_latency(Duration::from_secs(1));
623669

624-
// `select!` polls its ready branches in random order, so a cancel that landed while the
625-
// subscription was resolving can lose that race. Re-read it before taking a pad: a
626-
// rendition reconcile has already removed or reshaped must never publish one.
627-
if *cancel.borrow() {
670+
// Winning this is what earns a pad. Losing means a teardown got here while the
671+
// subscription was still resolving (this rendition was removed, reshaped, or outlived by
672+
// a closing catalog), and it must not publish a pad at all: the watch alone can't say
673+
// that, since a cancel landing just after we read it would leave a pad exposed and then
674+
// yanked without an EOS.
675+
if !state.go_live() {
628676
return;
629677
}
630678

@@ -635,7 +683,6 @@ impl Pump {
635683
let Some(pad) = create_pad(&element, &descriptor, &caps) else {
636684
return;
637685
};
638-
live.store(true, Ordering::Relaxed);
639686

640687
let mut reference_ts = None;
641688
loop {
@@ -955,6 +1002,15 @@ mod session_tests {
9551002
.collect()
9561003
}
9571004

1005+
/// Block until a consumer asks the broadcast for a track, returning the request unanswered so
1006+
/// its subscriber stays parked. Bounded so a session that never subscribes fails the test.
1007+
fn await_request(dynamic: &mut moq_net::broadcast::Dynamic) -> moq_net::track::Request {
1008+
super::RUNTIME
1009+
.block_on(async { tokio::time::timeout(Duration::from_secs(10), dynamic.requested_track()).await })
1010+
.expect("no track was ever requested")
1011+
.expect("broadcast closed")
1012+
}
1013+
9581014
/// Poll for a pad rather than sleeping a fixed beat: the pumps run on another runtime, so
9591015
/// the only ordering we have is "eventually". Fails the test if it never shows up.
9601016
fn await_pad(element: &super::super::MoqSrc, kind: &str) -> gst::Pad {
@@ -979,7 +1035,7 @@ mod session_tests {
9791035
let mut broadcast = moq_net::broadcast::Info::new().produce();
9801036
// A live handler is what makes an unserved name park rather than resolve `NotFound`,
9811037
// which is how it behaves over the wire: the publisher just never answers.
982-
let _dynamic = broadcast.dynamic();
1038+
let mut dynamic = broadcast.dynamic();
9831039
let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
9841040

9851041
// First update announces audio only, and no producer ever answers for it.
@@ -993,6 +1049,13 @@ mod session_tests {
9931049
let weak = element.downgrade();
9941050
let session = super::RUNTIME.spawn(async move { follow_catalog(consumer, weak, &mut shutdown_rx).await });
9951051

1052+
// Wait for the audio subscription before announcing video, and hold the request
1053+
// unanswered. A catalog consumer skips to the newest snapshot, so without this the
1054+
// session could read one update carrying both renditions, and then whether it reached
1055+
// video before parking on audio would come down to `plan.add` ordering.
1056+
let pending = await_request(&mut dynamic);
1057+
assert_eq!(pending.name(), "audio");
1058+
9961059
// Second update adds video, backed by a real track so its subscription resolves.
9971060
let _video = broadcast.create_track("video", None).unwrap();
9981061
{
@@ -1018,22 +1081,22 @@ mod session_tests {
10181081
let mut broadcast = moq_net::broadcast::Info::new().produce();
10191082
let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
10201083

1084+
// Both renditions in one snapshot, so the result can't hinge on which update the
1085+
// session read: with no handler alive, `audio` resolves `NotFound` rather than parking,
1086+
// and whichever order `plan.add` visits them in, `video` still has to reach a pad and
1087+
// the session still has to end cleanly.
1088+
let _video = broadcast.create_track("video", None).unwrap();
10211089
{
10221090
let mut guard = catalog.lock();
10231091
guard.audio.renditions = BTreeMap::from([("audio".to_string(), audio_rendition())]);
1092+
guard.video.renditions = BTreeMap::from([("video".to_string(), video_rendition())]);
10241093
}
10251094

10261095
let (shutdown, mut shutdown_rx) = watch::channel(false);
10271096
let consumer = broadcast.consume();
10281097
let weak = element.downgrade();
10291098
let session = super::RUNTIME.spawn(async move { follow_catalog(consumer, weak, &mut shutdown_rx).await });
10301099

1031-
let _video = broadcast.create_track("video", None).unwrap();
1032-
{
1033-
let mut guard = catalog.lock();
1034-
guard.video.renditions = BTreeMap::from([("video".to_string(), video_rendition())]);
1035-
}
1036-
10371100
await_pad(&element, "video_");
10381101

10391102
let _ = shutdown.send(true);
@@ -1075,6 +1138,44 @@ mod session_tests {
10751138
assert!(pads(&element, "video_").is_empty(), "the unserved rendition got a pad");
10761139
}
10771140

1141+
/// A subscription can resolve after its pump was already torn down. The pump has to stay
1142+
/// dead: exposing a pad at that point publishes a rendition the session has finished with,
1143+
/// and then yanks it without an EOS.
1144+
#[test]
1145+
fn a_subscription_resolving_after_cancellation_creates_no_pad() {
1146+
let _pad_ids = pad_ids();
1147+
let element = element();
1148+
1149+
let mut broadcast = moq_net::broadcast::Info::new().produce();
1150+
let mut dynamic = broadcast.dynamic();
1151+
let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
1152+
1153+
{
1154+
let mut guard = catalog.lock();
1155+
guard.video.renditions = BTreeMap::from([("stalled".to_string(), video_rendition())]);
1156+
}
1157+
1158+
let (_shutdown, mut shutdown_rx) = watch::channel(false);
1159+
let consumer = broadcast.consume();
1160+
let weak = element.downgrade();
1161+
let session = super::RUNTIME.spawn(async move { follow_catalog(consumer, weak, &mut shutdown_rx).await });
1162+
1163+
// Hold the subscription pending, then close the catalog so the pump is cancelled while
1164+
// it is still waiting.
1165+
let request = await_request(&mut dynamic);
1166+
catalog.finish().unwrap();
1167+
super::RUNTIME
1168+
.block_on(async { tokio::time::timeout(Duration::from_secs(10), session).await })
1169+
.expect("session never ended")
1170+
.unwrap()
1171+
.unwrap();
1172+
1173+
// Only now answer it. The pump is gone, so nothing may reach a pad.
1174+
let _serving = request.accept(moq_net::track::Info::default());
1175+
std::thread::sleep(Duration::from_millis(200));
1176+
assert!(pads(&element, "video_").is_empty(), "a cancelled pump still took a pad");
1177+
}
1178+
10781179
/// Pipelines link `moqsrc`'s pads by name, so the first video rendition that actually
10791180
/// arrives has to be `video_0`. A rendition announced but never served must not claim that
10801181
/// name and leave the real one on `video_1`, where `s.video_0 ! ...` never links.

0 commit comments

Comments
 (0)