11use std:: collections:: HashMap ;
2- use std:: sync:: atomic:: { AtomicBool , AtomicU64 , Ordering } ;
2+ use std:: sync:: atomic:: { AtomicU8 , AtomicU64 , Ordering } ;
33use std:: sync:: { Arc , LazyLock , Mutex } ;
44use 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.
291323struct 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
306354async 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