diff --git a/quest/m1/README.md b/quest/m1/README.md index 492daef22a..33600b39a3 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -99,7 +99,6 @@ folded into Dropped sources. - [Identity and recovery guide](/quest/m1/identity-guide.md) - explain pinned catalogs, lazy jobs, takeover, restart and version compatibility - [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - when the maintainer cuts it, lite-07 negotiates as `moq-lite-07` and the next release ships it - [Dropped sources](/quest/m1/dropped-sources.md) - track consumers see the producer's real error on every end path, never `Dropped`; a dead route ends Unroutable, and the TS follower lingers on announcements instead of matching errors -- [Release warm FETCH](/quest/m1/release-warm-fetch.md) - on the 0.17 line, a FETCH of a group a subscription delivered in part gets every frame instead of stalling on a parked open group - [Malformed moq-lite input](/quest/m1/lite-malformed-close.md) - a lite peer that breaks a control-stream rule, including SETUP's client-only parameters, gets PROTOCOL_VIOLATION, and a Report-level publisher answers PROBE targets, in Rust and js/net - [Client settings parity](/quest/m1/obs-client-config.md) - moq-ffi offers moq-c's client knobs, and the OBS Advanced settings get back the ones the C++ migration dropped - [Session report parity](/quest/m1/obs-session-report.md) - a session reports its negotiated draft and reconnect failures, so the OBS dock shows them again diff --git a/quest/m1/release-warm-fetch.md b/quest/m1/release-warm-fetch.md deleted file mode 100644 index e5da0415e8..0000000000 --- a/quest/m1/release-warm-fetch.md +++ /dev/null @@ -1,47 +0,0 @@ -# [S] A FETCH on release never stalls on a parked open group - -## Goal - -On the 0.17 `release` line, a FETCH of a group that a live subscription -delivered only in part, after that subscription ended, receives every frame -the publisher writes and ends with the group. Release only: `main` deleted the -warm copy in #4741 and is unaffected. Lands with the next scheduled 0.17.x; no -dedicated cut. - -## Plan - -Reported in #5348 and bisected to b425d430f (the #4922 backport). Traced -2026-10-12; the pin check the commit message mentions is not the cause. - -- Mechanism: the backport puts every viewer session on one front, so viewer - B's FETCH joins viewer A's parked front. When A's subscription ends - mid-group, `Action::Park` runs `warm_copy`, and `warm_rebuild` copies the - open live-edge group into a new group that it only finishes if the live one - finished. The upstream subscription is already cancelled, so nothing ever - writes to that group, and it is not aborted until `TRACK_IDLE_LINGER`. - `resume::Consumer::cached_group` then reaches `track::covering_group`, which - accepts any group that isn't aborted, finished or not. The FETCH gets the - cached frames and then waits forever. -- Decided 2026-10-12: an unfinished group in a warm segment is a cache miss - for FETCH. `cached_group` (`model/resume.rs`) skips it, and - `Fetching::poll_latch` does not latch onto a warm newest segment. The - pending fetch then marks the track used, the front re-splices, and the - FETCH goes upstream on the new copy (`pending_fetch_follows_a_newer_segment` - already covers that last hop). Rejected: a spliced `resume::Group` that - continues the open warm group, since it is more code on a line `main` already - deleted; and aborting open groups at park, since it breaks a returning - subscriber continuing an open warm group. -- Regression in release `model/origin.rs` tests, modelled on - `returning_reader_continues_an_open_warm_group` with `served_front()`: - subscribe, write two frames into open group 0, read one, drop the - subscriber and wait for the park, then `fetch_group(0, None)`. Expect a new - upstream track request that serves the whole group, and the fetch ends with - every frame. -- The PR targets `release` directly (CONTRIBUTING's backport flow), since - there is nothing to land on `main` first. - -Public API: none. Wire: none. - -## Closes - -- [#5348](https://github.com/moq-dev/moq/issues/5348) - FETCH of a partly delivered group stalls on 0.17.3 diff --git a/rs/moq-net/tests/idle_cancel_refetch.rs b/rs/moq-net/tests/idle_cancel_refetch.rs new file mode 100644 index 0000000000..c07914c05c --- /dev/null +++ b/rs/moq-net/tests/idle_cancel_refetch.rs @@ -0,0 +1,98 @@ +//! A relay whose last reader leaves mid-group cancels the idle upstream subscription. A later +//! fetch of that group still gets every frame (#5348, a release-line regression). + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Version}; +use support::harness::{MockConnectOptions, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(5); + +fn produce_origin(hop: u64) -> moq_net::origin::Producer { + let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +#[moq_net_sim::test] +async fn a_group_cut_by_an_idle_cancel_is_refetched_whole() { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast + .create_track("audio", moq_net::track::Info::default().with_timescale(None)) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let mut g0 = track.create_group(moq_net::group::Info { sequence: 0 }).unwrap(); + g0.write_frame(None, &b"0a"[..]).unwrap(); + g0.finish().unwrap(); + let mut g1 = track.create_group(moq_net::group::Info { sequence: 1 }).unwrap(); + g1.write_frame(None, &b"1a"[..]).unwrap(); + + let client = produce_origin(2); + let mut options = MockConnectOptions::new("moq-lite-06".parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(client.clone()); + let _pair = connect_mock(options).await; + + let consumer = client.consume(); + moq_net_sim::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .unwrap() + .unwrap(); + let remote = moq_net_sim::timeout(TIMEOUT, consumer.request_broadcast("bcast", None)) + .await + .unwrap() + .unwrap(); + + { + let mut subscriber = moq_net_sim::timeout(TIMEOUT, remote.track("audio").unwrap().subscribe(None)) + .await + .unwrap() + .unwrap(); + let mut group = moq_net_sim::timeout(TIMEOUT, subscriber.recv_group()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(group.sequence, 1); + let frame = moq_net_sim::timeout(TIMEOUT, group.read_frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(&frame.payload[..], b"1a"); + } + + // The relay cancels the idle upstream subscription. + moq_net_sim::timeout(TIMEOUT, track.demand().unused()) + .await + .unwrap() + .unwrap(); + + g1.write_frame(None, &b"1b"[..]).unwrap(); + g1.finish().unwrap(); + let mut g2 = track.create_group(moq_net::group::Info { sequence: 2 }).unwrap(); + g2.write_frame(None, &b"2a"[..]).unwrap(); + + let track = remote.track("audio").unwrap(); + let _feed = track.subscribe(None); + let mut group = moq_net_sim::timeout(TIMEOUT, track.fetch_group(1, None)) + .await + .unwrap() + .unwrap(); + let mut frames = Vec::new(); + loop { + match moq_net_sim::timeout(TIMEOUT, group.read_frame()) + .await + .expect("group 1 hangs") + { + Ok(Some(frame)) => frames.push(frame.payload), + Ok(None) => break, + Err(err) => panic!("group 1 failed: {err}"), + } + } + assert_eq!(frames, vec![&b"1a"[..], &b"1b"[..]]); +}