From 7d766205e9d4fee969388e116a9d2f5745fb6b07 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 06:10:39 -0800 Subject: [PATCH 1/3] chore(quest): claim quest/m0/unpublish-clean.md Quest-Claim: 0e6b5d7717bf8fa5 From f9b8980b1585340750204b3b4a1df649be90a563 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 06:22:31 -0800 Subject: [PATCH 2/3] test(studio): the scene test's viewer is served before the scene goes `a_published_scene_has_its_own_broadcast` failed with `the video finishes: Unroutable` when its last viewer subscribed and the studio quit before the relay had the subscription's answer. The relay refuses a subscription still unanswered once the scene's broadcast is withdrawn, since the program's broadcast still covers its path there. A viewer the relay already serves ends cleanly. Each viewer now waits for its first group. Closes quest/m0/unpublish-clean.md. Co-Authored-By: Claude Opus 5.5 --- quest/m0/README.md | 1 - quest/m0/unpublish-clean.md | 24 ------------------------ rs/moq-studio/src/engine.rs | 23 +++++++++++++++++++---- 3 files changed, 19 insertions(+), 29 deletions(-) delete mode 100644 quest/m0/unpublish-clean.md diff --git a/quest/m0/README.md b/quest/m0/README.md index 19208a46..b71b4d8f 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -102,7 +102,6 @@ repository. - [Browser sources on the GPU under Mesa](/quest/m0/cef-headless.md) - shared-texture pages paint on Intel and AMD under Wayland, and a page that cannot paint fails loudly - [Browser sources make no outside requests](/quest/m0/browser-offline.md) - no component updates, spellcheck downloads, or network time from the helper - [moq-studio builds on its own](/quest/m0/studio-builds-alone.md) - `cargo build -p moq-studio` no longer leans on a test dependency's features -- [Unpublishing a scene ends its broadcast cleanly](/quest/m0/unpublish-clean.md) - viewers get a clean finish, not `Unroutable`, when a scene stops publishing - [A media file's sound plays with its pictures](/quest/m0/media-av-sync.md) - media sources write a bus latency ahead, as broadcast sources do - [The Apple signing identity is available](/quest/m0/signing.md) - the Apple credentials packaging and the macOS camera need; Windows ships unsigned - [Packaging](/quest/m0/packaging.md) - a signed macOS app, a Windows installer, and a Flatpak for Linux diff --git a/quest/m0/unpublish-clean.md b/quest/m0/unpublish-clean.md deleted file mode 100644 index bbac118a..00000000 --- a/quest/m0/unpublish-clean.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Unpublishing a scene ends its broadcast cleanly - -## Goal - -When a scene is unpublished, renamed, or removed, its viewers receive the -end of each track as a clean finish, never an `Unroutable` error, and -`a_published_scene_has_its_own_broadcast` passes every time. - -## Plan - -From [moq-dev/studio#81](https://github.com/moq-dev/studio/pull/81), picked -2026-11-07 (maintainer away under /afk, recommended option), m0 by the rule -that fixes join m0. - -The studio closes a scene's broadcast as soon as its encoders finish their -tracks locally, so the relay can unroute it before a track's end reaches the -viewer. The test above failed 3 times in about 33 runs on a loaded host from -this race. Quitting already waits until the relay has what it was sent -(`Output::close` in `rs/moq-studio/src/engine.rs`). - -- ✅ **Unpublishing waits the same way** before it closes the broadcast. - Rejected for now: changing moq-net to deliver a finished track's end after - its broadcast closes, which would land upstream first and is only worth it - if the studio's wait cannot cover every case. diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index c413eaba..6a0fb55c 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -3456,6 +3456,20 @@ mod tests { (broadcast, catalogs, catalog) } + /// A viewer of `broadcast`'s `video`, once the relay has answered its + /// subscription and handed it a group. The relay refuses one still + /// unanswered when the scene's broadcast goes, since the program's + /// broadcast still covers its path there. + async fn served(broadcast: &moq_net::broadcast::Consumer, video: &str) -> moq_net::track::Subscriber { + let mut track = broadcast.track(video).unwrap().subscribe(None).await.unwrap(); + kio::clock::timeout(TIMEOUT, track.recv_group()) + .await + .expect("the relay serves the video") + .expect("the video") + .expect("a group"); + track + } + /// Read a published scene's `video` and `catalogs` to a clean end. async fn ended(mut catalogs: moq_mux::catalog::Consumer<()>, mut video: moq_net::track::Subscriber) { kio::clock::timeout(TIMEOUT, async { @@ -3473,8 +3487,9 @@ mod tests { /// more encode session. /// What only it shows is shown but not on program. Health grades its /// video beside the program, with its own viewers. Unpublishing - /// it, or quitting, ends its video and then its catalog cleanly, then its - /// broadcast; unpublishing gives its session back. + /// it, or quitting, ends its video and then its catalog cleanly for a + /// viewer the relay serves, then its broadcast; unpublishing gives its + /// session back. #[tokio::test(flavor = "multi_thread")] #[ignore = "needs a Vulkan device and an H.264 encoder; run by just rs render-sw"] async fn a_published_scene_has_its_own_broadcast() { @@ -3594,7 +3609,7 @@ mod tests { .await .expect("health grades the published scene"); - let track = broadcast.track(&video).unwrap().subscribe(None).await.unwrap(); + let track = served(&broadcast, &video).await; { let mut state = engine.state.lock().unwrap(); let scene = state.collection().scenes[1].clone(); @@ -3624,7 +3639,7 @@ mod tests { engine.state.lock().unwrap().undo().unwrap(); let (broadcast, catalogs, catalog) = routed(&relay, "studio/tall").await; let (video, _) = canvas_size(&catalog); - let track = broadcast.track(&video).unwrap().subscribe(None).await.unwrap(); + let track = served(&broadcast, &video).await; let stopping = engine.clone(); tokio::task::spawn_blocking(move || stopping.stop()).await.unwrap(); kio::clock::timeout(TIMEOUT, engine.close()) From 0439d67445951820084eaf0ff7a8622e728da96a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 7 Nov 2026 06:47:29 -0800 Subject: [PATCH 3/3] test(studio): the scene test's viewer reads its first group to the end too `served` waited for the first group and dropped it, so `ended` never read that group's frames or saw it cut. On a quit straight after it can be the only group. `served` hands the group back and `ended` reads it first. Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/engine.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/rs/moq-studio/src/engine.rs b/rs/moq-studio/src/engine.rs index 4da6a186..ea0813b7 100644 --- a/rs/moq-studio/src/engine.rs +++ b/rs/moq-studio/src/engine.rs @@ -3463,25 +3463,34 @@ mod tests { (broadcast, catalogs, catalog) } - /// A viewer of `broadcast`'s `video`, once the relay has answered its - /// subscription and handed it a group. The relay refuses one still - /// unanswered when the scene's broadcast goes, since the program's + /// A viewer of `broadcast`'s `video`, and the first group the relay + /// handed it once it answered the subscription. The relay refuses one + /// still unanswered when the scene's broadcast goes, since the program's /// broadcast still covers its path there. - async fn served(broadcast: &moq_net::broadcast::Consumer, video: &str) -> moq_net::track::Subscriber { + async fn served( + broadcast: &moq_net::broadcast::Consumer, + video: &str, + ) -> (moq_net::track::Subscriber, moq_net::group::Consumer) { let mut track = broadcast.track(video).unwrap().subscribe(None).await.unwrap(); - kio::clock::timeout(TIMEOUT, track.recv_group()) + let first = kio::clock::timeout(TIMEOUT, track.recv_group()) .await .expect("the relay serves the video") .expect("the video") .expect("a group"); - track + (track, first) } - /// Read a published scene's `video` and `catalogs` to a clean end. - async fn ended(mut catalogs: moq_mux::catalog::Consumer<()>, mut video: moq_net::track::Subscriber) { + /// Read a published scene's `video`, from the first group it was served, + /// and its `catalogs` to a clean end. + async fn ended( + mut catalogs: moq_mux::catalog::Consumer<()>, + (mut video, first): (moq_net::track::Subscriber, moq_net::group::Consumer), + ) { kio::clock::timeout(TIMEOUT, async { - while let Some(mut group) = video.recv_group().await.expect("the video finishes") { - while group.read_frame().await.expect("the video finishes").is_some() {} + let mut group = Some(first); + while let Some(mut current) = group { + while current.read_frame().await.expect("the video finishes").is_some() {} + group = video.recv_group().await.expect("the video finishes"); } while catalogs.next().await.expect("the catalog finishes").is_some() {} })