Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions rs/moq-cli/src/play/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,6 @@ impl<O: Output> Media<O> {
let Some(event) = event else { return Ok(()) };
let announce = match event {
AnnounceEvent::Start(announce) | AnnounceEvent::Restart(announce) => announce,
// The same instance, which what is playing rides out. With nothing
// playing, it is the path served again after a gap the announcements
// folded away (a covering route of one epoch arriving as the exact one
// went), so play it.
AnnounceEvent::Update(announce) if playing.is_none() => announce,
AnnounceEvent::Update(_) => continue,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry an idle player when an Update makes the route usable

When the initial Start races an advertise-only route or its handler, Source::broadcast() can fail with Unroutable, and the result branch leaves playing as None. The origin deliberately emits an Update when servability changes so failed requests can retry (rs/moq-net/src/model/origin.rs:1117-1119), but this unconditional continue discards that recovery signal, leaving playback blank until an unrelated restart occurs. Preserve recovery for an idle player or use the origin's routed resolution behavior for the initial subscription.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. An advertise-only route (server: None, source: None) is only built by origin::Producer::announce, which is #[cfg(test)]. Every production route serves what it claims: broadcast::Producer::announce carries its source, and origin::Producer::dynamic (what both the lite and IETF subscribers use for peer announces) carries a server. So a Start never lands on a route that answers Unroutable for lack of a server, and the servability flip in origin.rs cannot strand a player outside tests.

Reviving an idle player on every Update would also retry on arbitrary re-pricing, which is the ambiguity this PR removes: an Update is the same instance, and a gap is End then Start since #5188.

(Written by Claude Opus 5.5)

AnnounceEvent::End(_) => {
tracing::info!(broadcast = %self.broadcast, "offline, waiting for it to return");
Expand Down Expand Up @@ -774,10 +769,10 @@ mod tests {
}

/// A covering route of the same epoch that arrives as the exact route goes, both
/// before the player looks, reaches it as an update: the announcements never show
/// the path unserved. A player with nothing playing still plays it.
/// before the player looks, is the path served again after a gap. A player with
/// nothing playing plays it.
#[tokio::test]
async fn an_update_while_idle_plays_the_route() {
async fn a_covering_route_after_a_gap_plays_while_idle() {
tokio::time::pause();

const OLD: f32 = 0.25;
Expand Down Expand Up @@ -817,6 +812,53 @@ mod tests {
drop((served, served_catalog));
}

/// The exact route goes while the old run still holds its delay, and a covering
/// route of the same epoch takes over. Playback reaches the covering route, whether
/// the announcements show the gap (a restart) or the origin rides it out (the same
/// run carrying on).
#[tokio::test]
async fn a_handoff_to_a_covering_route_plays_on_while_draining() {
tokio::time::pause();

const OLD: f32 = 0.25;
const NEW: f32 = 0.5;
let origin = moq_tokio::origin::spawn();
let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint());

// One instance, served by the exact route and mirrored behind a covering one.
let (exact, exact_catalog, mut old) = publish(&origin, route.clone());
let mut mirror = moq_net::broadcast::Info::new().produce();
let mirror_catalog = catalog::Producer::new(&mut mirror, Default::default()).unwrap();
let mut copy = rendition(&mirror, &mirror_catalog, "audio");
let recorder = Recorder::default();
let player = tokio::spawn(media(&origin, Duration::from_millis(500), recorder.clone()).run());
for index in 0..50 {
old.write(packet(index, OLD)).unwrap();
copy.write(packet(index, OLD)).unwrap();
tokio::time::sleep(PACKET_DURATION).await;
}

exact.unannounce();
let pool = origin.dynamic("", route).unwrap();
let consumer = mirror.consume();
tokio::spawn(async move {
while let Ok(request) = pool.requested_broadcast().await {
request.accept(consumer.clone());
}
});
for index in 50..60 {
copy.write(packet(index, NEW)).unwrap();
tokio::time::sleep(PACKET_DURATION).await;
}

settle(player, &recorder).await;
assert!(
played(&recorder, NEW).0 > Duration::ZERO,
"the player never reached the covering route"
);
drop((mirror, mirror_catalog, copy, exact, exact_catalog, old));
}

/// Re-pricing the same instance is an update, which playback rides through on
/// one sink rather than starting over.
#[tokio::test]
Expand Down
Loading