From ff1a485ddcc7a73e770e60e1bdf16acaf3d7c8b1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 23:56:56 -0700 Subject: [PATCH 01/30] chore(quest): claim quest/m1/api-audit/lifecycle.md Quest-Claim: ff53cd07c6928daf From 3e883543f0f88348c6fb259b8b227ab708e1cde6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 00:09:35 -0700 Subject: [PATCH 02/30] refactor(net)!: fold track::Producer::close into abort, rename end to finished Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/publish.rs | 2 +- rs/moq-ffi/src/producer.rs | 2 +- rs/moq-json/src/snapshot/producer.rs | 2 +- rs/moq-net/MIGRATE.md | 4 +- rs/moq-net/src/ietf/subscriber.rs | 10 +- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/src/model/origin.rs | 7 +- rs/moq-net/src/model/resume.rs | 6 +- rs/moq-net/src/model/track.rs | 137 +++--------------- .../tests/integration/empty_replacement.rs | 2 +- 10 files changed, 42 insertions(+), 132 deletions(-) diff --git a/rs/moq-c/src/publish.rs b/rs/moq-c/src/publish.rs index aac4d2e2c0e..a6e93c8041e 100644 --- a/rs/moq-c/src/publish.rs +++ b/rs/moq-c/src/publish.rs @@ -934,7 +934,7 @@ impl Publish { /// boundary and only releases the handle; the broadcast keeps the track. pub fn track_finish(&mut self, track: Id) -> Result<(), Error> { let track = self.tracks.remove(track).ok_or(Error::TrackNotFound)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } track.keep(); diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 1ee3187f961..8367206fb4e 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -714,7 +714,7 @@ impl MoqTrackProducer { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); let track = guard.as_mut().ok_or(MoqError::Closed)?; - if track.end().is_none() { + if track.finished().is_none() { track.finish()?; } if let Some(finished) = self.finished.lock().unwrap().take() { diff --git a/rs/moq-json/src/snapshot/producer.rs b/rs/moq-json/src/snapshot/producer.rs index 408b91a933e..872ba84f782 100644 --- a/rs/moq-json/src/snapshot/producer.rs +++ b/rs/moq-json/src/snapshot/producer.rs @@ -321,7 +321,7 @@ impl Inner { let next = track.largest().map_or(0, |largest| largest.group.saturating_add(1)); if track.is_closed() || track - .end() + .finished() .is_some_and(|end| moq_net::track::Position::group(next) >= end) { return Err(moq_net::Error::Closed.into()); diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 9dea3a51305..4c6429b1d5e 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -126,8 +126,8 @@ A group followed by a frameless one is judged against the next group that has a ### Ending a track aborts its open groups (#6111) Aborting a track (`abort` or `abort_unused`) now aborts its unfinished groups with the same error, so their readers end with it and their writers fail. -Dropping the last `track::Producer` without a finish aborts them with `Error::Dropped`, and `close` with `Error::Stream(StreamError::Session(SessionError::Cancel))`. -`track::Producer::close`, `track::Producer::received`, and `group::Producer::info` are now public. +Dropping the last `track::Producer` without a finish aborts them with `Error::Dropped`. +`track::Producer::received` and `group::Producer::info` are now public. ### Priorities are signed, with 0 unset (#6142) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e58bd3dc828..62997e3c9d4 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -242,11 +242,11 @@ impl State { request.reject(Error::Cancel); } if let Some(producer) = track.producer { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } for (_, producer) in self.lingering.drain() { - let _ = producer.close(); + let _ = producer.abort(Error::Stream(crate::StreamError::Session(SessionError::Cancel))); } } } @@ -14675,7 +14675,7 @@ mod stitch_tests { h.subscriber.clone().recv_group(&mut stream).await.unwrap(); assert_eq!( - h.track.end(), + h.track.finished(), Some(track::Position { group: SEQUENCE, frame: 1 @@ -14697,7 +14697,7 @@ mod stitch_tests { let h = Harness::new(Fill::Done, vec![marker]); let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position { group: 5, frame: 5 })); + assert_eq!(h.track.finished(), Some(track::Position { group: 5, frame: 5 })); assert_eq!( h.track.latest_sequence(), None, @@ -14734,7 +14734,7 @@ mod stitch_tests { let mut stream = h.stream().await; h.subscriber.clone().recv_group(&mut stream).await.unwrap(); - assert_eq!(h.track.end(), Some(track::Position::group(SEQUENCE))); + assert_eq!(h.track.finished(), Some(track::Position::group(SEQUENCE))); assert_eq!(h.track.latest_sequence(), None, "no group was created"); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index ba0a93f6c9b..1ab43e86d9b 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -565,7 +565,7 @@ impl SubscriptionCleanup { for (_, entry) in self.0.lock().drain() { let _ = match entry.tail.read().is_ended() { true => entry.producer.finish(), - false => entry.producer.close(), + false => entry.producer.abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), }; } } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7bb068b1879..a195c6c49bd 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -18,7 +18,7 @@ use super::{ front::{Action, Candidate, Event, Front, Refusal}, }; use crate::{ - AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, + AsPath, Epoch, Error, InvalidPattern, Path, PathOwned, Pattern, Patterns, SessionError, StreamError, coding::{BoundsExceeded, Decode, DecodeError, Decoder, Encode, EncodeError, Encoder, Form, varint}, path::Segment, time::{Deadlines, Instant}, @@ -3880,7 +3880,10 @@ async fn serve_front(task: FrontTask) -> Vec { let Some(io) = tracks.get_mut(&name) else { continue }; io.copy = None; io.held = None; - io.closed = matches!(result, Err(Error::Closed)); + io.closed = matches!( + result, + Err(Error::Stream(StreamError::Session(SessionError::Cancel))) + ); Event::TrackEnded { track: name, source, diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 2db144d166c..f868b9e5b09 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -35,7 +35,7 @@ use std::task::{Poll, ready}; use std::time::Duration; use crate::time::Instant; -use crate::{Error, Result, StreamError, group, stats, track}; +use crate::{Error, Result, SessionError, StreamError, group, stats, track}; use super::delivered::Delivered; use super::subscription::{Anchor, Position, Resolved, Subscription, Unheld, Window}; @@ -1397,7 +1397,9 @@ impl Reader { // ending retains its session error. Some(None) => match serving.map(&done) { None => return Some(Poll::Ready(Err(Error::Unroutable))), - Some(Some(Ok(()) | Err(Error::Closed))) => return Some(Poll::Ready(Ok(None))), + Some(Some(Ok(()) | Err(Error::Stream(StreamError::Session(SessionError::Cancel))))) => { + return Some(Poll::Ready(Ok(None))); + } Some(Some(Err(err))) => return Some(Poll::Ready(Err(err))), Some(None) => {} }, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 206482615fe..3267f8f9e37 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -14,7 +14,7 @@ //! The track is closed with [Error] when all writers or readers are dropped. use crate::sync::Mutex; -use crate::{Error, Result, SessionError, StreamError, Timescale, Timestamp, coding}; +use crate::{Error, Result, StreamError, Timescale, Timestamp, coding}; use crate::{broadcast, cache, group, stats}; use super::Datagram; @@ -442,8 +442,7 @@ pub(crate) struct TrackState { // cache, so an evicted last group does not lose where the track ended. end: Option, - // Nothing more can arrive: the last producer dropped after a declared boundary, - // or the receiving session closed locally without declaring an end. + // Nothing more can arrive: the last producer dropped after a declared boundary. sealed: bool, // Set once a received copy's end is declared, ahead of group streams below it that QUIC @@ -2310,8 +2309,8 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - // `sealed` also ends a locally closed receive track without a declared end. - // An abort still wins unless that end had already settled: a group below it was still open. + // `sealed` is a declared end the last producer dropped short of. An abort still wins + // unless that end had already settled: a group below it was still open. let reached = self.sealed || self .final_sequence() @@ -2546,8 +2545,6 @@ impl TrackState { Poll::Ready(Ok(end)) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) - } else if self.sealed { - Poll::Ready(Err(Error::Closed)) } else { Poll::Pending } @@ -3036,22 +3033,6 @@ impl Producer { Ok(()) } - /// End the track without declaring where it ends, as a closed session does. - /// - /// Consumers drain the groups that finished and then end cleanly. The open groups are - /// aborted with [`StreamError::Session`], so a consumer that already pulled one ends - /// with it and their writers fail. A reader following the track's routes resumes from - /// another one where this copy stopped. Use [`Self::finish`] or [`Self::finish_at`] - /// when the end is known. - pub fn close(self) -> Result<()> { - let mut state = self.modify()?; - state.sealed = true; - let open = state.tear_down(Error::Stream(StreamError::Session(SessionError::Cancel))); - state.close(); - open.abort(); - Ok(()) - } - /// Mark the track as finished after the last appended group. /// /// Ends the track at the head of the group after the largest one written to its name, @@ -3251,7 +3232,7 @@ impl Producer { /// /// `None` while the track is still open ended. Both methods reject a second end, so /// callers that may have already declared one check here first. - pub fn end(&self) -> Option { + pub fn finished(&self) -> Option { self.state.read().end } @@ -4748,15 +4729,15 @@ impl Consumer { /// Poll for the track reaching a terminal state: `Ok(())` once it is complete /// (the final group was produced), `Err` once it closed or aborted before /// completing. This tells a track that truly ended from one whose serving route - /// died mid-stream. A session closed locally, on purpose, is [`Error::Closed`]. + /// died mid-stream. A session closed locally, on purpose, aborts its tracks with + /// [`StreamError::Session`] carrying [`crate::SessionError::Cancel`]. pub(super) fn poll_complete(&self, waiter: &kio::Waiter) -> Poll> { self.with_state(|state| { let Some(state) = state else { return Poll::Ready(Err(Error::Dropped)); }; match ready!(state.poll(waiter, |state| { - // A local close without a declared end is a dead route, not finished content. - if state.end.is_some() && state.is_complete() { + if state.is_complete() { Poll::Ready(()) } else { state.watch_tail(waiter); @@ -4766,11 +4747,7 @@ impl Consumer { Ok(_) => Poll::Ready(Ok(())), // Closed before completing. Read through the returned guard: it holds // the lock, so re-locking the channel here would deadlock. - Err(closed) => Poll::Ready(Err(match (&closed.abort, closed.sealed) { - (Some(err), _) => err.clone(), - (None, true) => Error::Closed, - (None, false) => Error::Dropped, - })), + Err(closed) => Poll::Ready(Err(closed.abort.clone().unwrap_or(Error::Dropped))), } }) } @@ -12222,33 +12199,6 @@ mod test { assert!(matches!(write, Err(Error::Cancel)), "{write:?}"); } - /// Closing a track aborts its open groups like [`Producer::abort`], with - /// [`StreamError::Session`]. - #[test] - #[cfg(feature = "async")] - fn close_ends_a_pulled_open_group() { - let producer = track_producer("test", Info::timed()); - let mut consumer = producer.subscribe(None); - let mut writer = producer.append_group().unwrap(); - writer - .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - let mut group = consumer.assert_group(); - assert!(group.read_frame().now_or_never().unwrap().unwrap().is_some()); - - producer.close().unwrap(); - let read = group.read_frame().now_or_never().expect("should not block"); - assert!( - matches!(read, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{read:?}" - ); - let write = writer.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"y")); - assert!( - matches!(write, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))), - "{write:?}" - ); - } - /// Aborting an unused track aborts an open group whose reader outlived the track's /// consumers. #[test] @@ -12451,7 +12401,7 @@ mod test { assert!(producer.finish_at(Position::group(5)).is_err()); assert!(producer.finish_at(Position::group(6)).is_ok()); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); // Repeating the end is a no-op, moving it is rejected, and no group at or above the // boundary can be created. @@ -12494,7 +12444,7 @@ mod test { producer.finish_at(Position::group(6)).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); producer.finish_at(Position { group: 5, frame: 5 }).unwrap(); - assert_eq!(producer.end(), Some(Position { group: 5, frame: 5 })); + assert_eq!(producer.finished(), Some(Position { group: 5, frame: 5 })); for moved in [Position { group: 5, frame: 4 }, Position::group(6), Position::group(5)] { assert!(matches!(producer.finish_at(moved), Err(Error::ProtocolViolation))); } @@ -12513,7 +12463,7 @@ mod test { producer.finish_at(Position { group: 5, frame: 5 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None, "a refused end declares nothing"); + assert_eq!(producer.finished(), None, "a refused end declares nothing"); producer.finish_at(Position { group: 5, frame: 6 }).unwrap(); } @@ -12559,13 +12509,13 @@ mod test { #[test] fn end_reports_the_declared_end() { let mut producer = track_producer("test", Info::timed()); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); - assert_eq!(producer.end(), None, "a group does not declare an end"); + assert_eq!(producer.finished(), None, "a group does not declare an end"); producer.finish_at(Position::group(9)).unwrap(); - assert_eq!(producer.end(), Some(Position::group(9))); + assert_eq!(producer.finished(), Some(Position::group(9))); // finish() would try to declare a second boundary, so callers check first. assert!(producer.finish().is_err()); @@ -12576,7 +12526,7 @@ mod test { let producer = track_producer("test", Info::timed()); let _open = producer.create_group(group::Info { sequence: 5 }).unwrap(); producer.finish().unwrap(); - assert_eq!(producer.end(), Some(Position::group(6))); + assert_eq!(producer.finished(), Some(Position::group(6))); } #[test] @@ -13045,7 +12995,7 @@ mod test { matches!(producer.finish_at(Position::group(2)), Err(Error::ProtocolViolation)), "a second end is refused" ); - assert_eq!(producer.end(), Some(Position::group(3)), "the first end stands"); + assert_eq!(producer.finished(), Some(Position::group(3)), "the first end stands"); let mut _low = Vec::new(); for sequence in [0, 1] { @@ -13074,55 +13024,10 @@ mod test { assert!(matches!(res, Err(Error::Timeout))); } - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_keeps_finished_groups_without_declaring_an_end() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - producer.clone().close().unwrap(); - assert!(producer.end().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - assert!(consumer.recv_group().await.unwrap().is_some()); - assert!(consumer.recv_group().await.unwrap().is_none()); - } - - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_cannot_mask_an_abort_before_declared_end_settles() { - let mut producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - let _group = producer.append_group().unwrap(); - producer.finish_at(Position::group(2)).unwrap(); - producer.clone().abort(Error::Timeout).unwrap(); - assert!(producer.close().is_err()); - assert!(matches!(consumer.recv_group().await, Err(Error::Timeout))); - } - - /// A close drops a group still open that no reader pulled: readers drain the finished - /// groups, then end without a declared end. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn local_close_drops_an_unpulled_open_group() { - let producer = track_producer("local", Info::timed()); - let mut consumer = producer.consume().subscribe(None).await.unwrap(); - producer.append_group().unwrap().finish().unwrap(); - let mut open = producer.append_group().unwrap(); - open.write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"x")) - .unwrap(); - producer.clone().close().unwrap(); - assert_eq!( - consumer.recv_group().await.unwrap().map(|group| group.sequence), - Some(0) - ); - assert!(consumer.recv_group().await.unwrap().is_none()); - assert!(matches!(consumer.finished().await, Err(Error::Closed))); - } - - /// A local close is a dead route to the origin, not finished content, so a + /// A local session closing is a dead route to the origin, not finished content, so a /// replacement route can take over where it left off. #[test] - fn local_close_is_not_completion_and_keeps_the_resume_position() { + fn session_close_is_not_completion_and_keeps_the_resume_position() { let producer = track_producer("local", Info::timed()); let consumer = producer.consume(); producer.append_group().unwrap().finish().unwrap(); @@ -13133,7 +13038,7 @@ mod test { group .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"b")) .unwrap(); - producer.close().unwrap(); + producer.abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))).unwrap(); assert!(matches!( consumer.poll_complete(&kio::Waiter::noop()), Poll::Ready(Err(_)) @@ -14745,7 +14650,7 @@ mod test { producer.finish_at(Position { group: 0, frame: 2 }), Err(Error::ProtocolViolation) )); - assert_eq!(producer.end(), None); + assert_eq!(producer.finished(), None); live.write_frame(Timestamp::ZERO, b"delta".as_slice()).unwrap(); } diff --git a/rs/moq-net/tests/integration/empty_replacement.rs b/rs/moq-net/tests/integration/empty_replacement.rs index 8c34a501ea8..14bd2057cd5 100644 --- a/rs/moq-net/tests/integration/empty_replacement.rs +++ b/rs/moq-net/tests/integration/empty_replacement.rs @@ -88,7 +88,7 @@ async fn empty_replacement_ends_after_the_cached_group(version: &str) { // before writing anything. let (second, mut subscriber) = serve(&remote, &mut dynamic, "feed").await; second.finish().unwrap(); - assert_eq!(second.end(), Some(track::Position::group(1)), "{version:?}"); + assert_eq!(second.finished(), Some(track::Position::group(1)), "{version:?}"); // Its own readers end at once: the earlier producer's groups never arrive here. let mut local = second.subscribe(None); From b353951cf8b62603db4458b974e9ec56e332fe2d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 01:53:21 -0700 Subject: [PATCH 03/30] feat(net)!: group closed() settles on finish; abort after finish only frees cache Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/subscriber.rs | 6 +- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/src/model/group.rs | 114 +++++++++++++++++++++++++----- rs/moq-net/src/model/track.rs | 5 +- 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 62997e3c9d4..4d0f7b20c0b 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -4482,7 +4482,7 @@ impl Subscriber { if let Poll::Ready(err) = track.poll_closed(waiter) { return Poll::Ready(Err(err)); } - if let Poll::Ready(err) = observing.poll_closed(waiter) { + if let Poll::Ready(err) = observing.poll_aborted(waiter) { return Poll::Ready(Err(err)); } ingest.poll(stream, &mut producer, waiter) @@ -5633,7 +5633,7 @@ impl Subscriber { if let Poll::Ready(res) = waiter.poll_future(receive.as_mut()) { return Poll::Ready(res); } - closed.poll_closed(waiter).map(Err) + closed.poll_aborted(waiter).map(Err) }) .await }; @@ -16518,7 +16518,7 @@ mod joining_fetch_tests { } if !complete { assert!( - matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Error::Cancel)), + matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Err(Error::Cancel))), "partial group must abort" ); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1ab43e86d9b..b53289c27f1 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1058,7 +1058,7 @@ impl GroupRecv { if let Poll::Ready(err) = track.poll_closed(waiter) { break 'serve Err(err); } - if let Poll::Ready(err) = group.handle().poll_closed(waiter) { + if let Poll::Ready(err) = group.handle().poll_aborted(waiter) { break 'serve Err(err); } match ingest.poll(&mut self.reader, group, waiter) { diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 02cae7143af..6559583386d 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -721,6 +721,16 @@ impl GroupState { } } + /// The group's outcome once final: `Ok` once finished, which a later abort only frees + /// the cache of, or the abort error. + fn poll_outcome(&self) -> Poll> { + match (self.fin, &self.abort) { + (Some(_), _) => Poll::Ready(Ok(())), + (None, Some(err)) => Poll::Ready(Err(err.clone())), + (None, None) => Poll::Pending, + } + } + /// Resolve whether a reader at `index` can still make progress, answering the same /// question as a read without consuming anything. fn poll_end(&self, index: usize) -> Poll> { @@ -1643,6 +1653,9 @@ impl Producer { /// Consumes the writer. Drops the cached frames so a stale [`Consumer`] can't pin /// their buffers in memory forever; consumers that haven't drained yet surface the /// abort error instead of the leftover cache. + /// + /// After [`finish`](Self::finish) the outcome is already settled, so this only releases + /// the cache: the group stays finished, and a reader that drained it ends cleanly. pub fn abort(self, err: Error) -> Result<()> { self.handle.close_aborted(err) } @@ -1654,9 +1667,10 @@ impl Producer { Error::GroupTooLarge } - /// Block until the group is aborted, including by eviction. Finishing does not close it. + /// Block until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.handle.poll_closed(waiter)).await } /// The non-writing observer used by the cache and built-in sessions. @@ -1701,9 +1715,9 @@ impl Producer { self.handle.consume() } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error, + /// including eviction. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { self.handle.poll_closed(waiter) } @@ -1865,13 +1879,17 @@ impl Handle { fn commit_abort(&self, mut guard: kio::Mut<'_, GroupState>, err: Error) { let policy = matches!(err, Error::Old | Error::Timeout | Error::Evicted); + // A finished group's outcome is settled: an abort only releases its cache, so it + // neither failed nor was cut. + let settled = guard.fin.is_some(); // Cache policy frees the frames, as does an oversized group whichever write found // it; a copy that failed or was given up lends them on. let salvage = !policy && !matches!(err, Error::GroupTooLarge); // A reset carrying a policy code is the upstream's cache policy, a cancel is a reader // no longer wanting the group, and a fill refused as unfetchable abandons backfill: // deliberate holes, never a cut readers see. The publisher giving the group up is. - let cut = !policy + let cut = !settled + && !policy && !matches!( err, Error::Cancel @@ -1887,7 +1905,7 @@ impl Handle { if cut { let _ = self.alive.cut.set(err.clone()); } - if !policy { + if !settled && !policy { self.alive.failed.store(true, Ordering::Release); } guard.abort = Some(err); @@ -2148,10 +2166,16 @@ impl Handle { } } - /// Poll until the group is aborted, including by eviction; ready with the cause. - /// Finishing does not close it. + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. // Sessions observe a group while its sole writer belongs to a frame. - pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.poll_ref(waiter, |state| state.poll_outcome())) + } + + /// Poll until the group is aborted, including by eviction and after it finished; ready + /// with the cause. Finishing alone does not end it. + // Sessions and track scans watch for an abort that frees a group, finished or not. + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { self.state.poll_closed(waiter).map(|()| self.abort_reason()) } @@ -2176,10 +2200,22 @@ impl Handle { } } +/// The outcome of a group's state polled from a handle: the channel closing short of one +/// (every writer gone without a finish or abort) reads as [`Error::Dropped`]. +fn settle(poll: Poll, kio::Ref<'_, GroupState>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) => match closed.poll_outcome() { + Poll::Ready(outcome) => outcome, + Poll::Pending => Err(Error::Dropped), + }, + }) +} + /// A cloneable, watch-only handle to a group's readers. /// -/// A finished or dropped group ends waits with [`Error::Dropped`]; an aborted one keeps -/// its abort reason. +/// Once every handle to a finished group is gone, demand waits end with +/// [`Error::Dropped`]; an aborted group's keep its abort reason. #[derive(Clone)] pub struct Demand { sequence: u64, @@ -2219,11 +2255,15 @@ impl Demand { self.state.poll_unused(waiter).map_err(|_| self.abort_reason()) } - /// Wait until the group closes, returning its cause. + /// Wait until the group's outcome is final: `Ok` once finished, or the abort error. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| self.poll_closed(waiter)).await + } + + /// Poll until the group's outcome is final: `Ok` once finished, or the abort error. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + settle(self.state.consume_weak().poll(waiter, |state| state.poll_outcome())) } fn abort_reason(&self) -> Error { @@ -4585,10 +4625,50 @@ mod test { )); assert!(matches!( handle.poll_closed(&kio::Waiter::noop()), - Poll::Ready(Error::FrameTooLarge) + Poll::Ready(Err(Error::FrameTooLarge)) )); } + /// `closed` settles on a finish, and an abort after it only frees the cache: the + /// outcome stays clean, a drained reader ends cleanly, and nothing records a cut. + #[test] + fn abort_after_finish_keeps_the_clean_outcome() { + let mut producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + let mut consumer = producer.consume(); + let waiter = kio::Waiter::noop(); + assert!(producer.poll_closed(&waiter).is_pending()); + assert!(demand.poll_closed(&waiter).is_pending()); + + producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap(); + producer.finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing"); + assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); + + producer.abort(Error::Cancel).unwrap(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(handle.poll_aborted(&waiter), Poll::Ready(Error::Cancel))); + assert!(handle.cut_error().is_none()); + assert!(!handle.fate().failed()); + assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none()); + } + + /// An abort before the finish is the outcome, on every observer. + #[test] + fn abort_before_finish_is_the_outcome() { + let producer = Info { sequence: 0 }.produce(); + let handle = producer.handle(); + let demand = producer.demand(); + producer.abort(Error::Timeout).unwrap(); + let waiter = kio::Waiter::noop(); + assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + } + /// Completing a frame charges whatever it wrote since the last notify. #[test] fn frame_commit_charges_the_tail() { diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 3267f8f9e37..7be13bf2839 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -1336,7 +1336,7 @@ impl TrackState { ) -> Option<&'a Slot> { loop { match select(self) { - Some(slot) if slot.group.poll_closed(waiter).is_ready() && slot.group.is_aborted() => continue, + Some(slot) if slot.group.poll_aborted(waiter).is_ready() => continue, slot => return slot, } } @@ -1494,8 +1494,7 @@ impl TrackState { // edge again, so each pass resolves a lower one. if let Some(live) = edge.presentation && let Some(slot) = self.lookup.get(&live.sequence) - && slot.group.poll_closed(waiter).is_ready() - && slot.group.is_aborted() + && slot.group.poll_aborted(waiter).is_ready() { continue; } From ba5721ceb85c7092307cc8088316a636674954c5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:02:48 -0700 Subject: [PATCH 04/30] feat(net)!: track closed() returns the outcome and settles on a reached end Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/src/encode/producer.rs | 2 +- rs/moq-e2ee/src/tests.rs | 2 +- rs/moq-flate/src/stream/producer.rs | 4 +- rs/moq-net/src/ietf/subscriber.rs | 26 ++++---- rs/moq-net/src/lite/subscriber.rs | 4 +- rs/moq-net/src/model/track.rs | 97 +++++++++++++++++++++++++---- rs/moq-net/src/model/track/fill.rs | 2 +- rs/moq-transcode/src/rung.rs | 2 +- 8 files changed, 107 insertions(+), 32 deletions(-) diff --git a/rs/moq-audio/src/encode/producer.rs b/rs/moq-audio/src/encode/producer.rs index 97e0f6d1a83..6b067bc9a74 100644 --- a/rs/moq-audio/src/encode/producer.rs +++ b/rs/moq-audio/src/encode/producer.rs @@ -634,7 +634,7 @@ mod tests { let closed = tokio::time::timeout(Duration::from_secs(1), demand.closed()) .await .expect("producer closed"); - assert!(matches!(closed, moq_net::Error::Dropped)); + assert!(closed.is_ok(), "{closed:?}"); } /// A finished publication may live as long as its broadcast; the codec and input diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index 33fcd9b14f9..ee925dd21e1 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -521,7 +521,7 @@ fn refused_track_is_aborted() { ); assert!(matches!( net.poll_closed(&kio::Waiter::noop()), - Poll::Ready(moq_net::Error::Cancel) + Poll::Ready(Err(moq_net::Error::Cancel)) )); } diff --git a/rs/moq-flate/src/stream/producer.rs b/rs/moq-flate/src/stream/producer.rs index 678a0035c5a..4b74dd82a34 100644 --- a/rs/moq-flate/src/stream/producer.rs +++ b/rs/moq-flate/src/stream/producer.rs @@ -98,8 +98,8 @@ impl Inner { // A closed track refuses every append, so say so before the budget: `GroupTooLarge` promises // the log is still writable. - if let Poll::Ready(err) = self.track.poll_closed(&kio::Waiter::noop()) { - return Err(err.into()); + if let Poll::Ready(outcome) = self.track.poll_closed(&kio::Waiter::noop()) { + return Err(outcome.err().unwrap_or(moq_net::Error::Closed).into()); } // A timestamp that doesn't match the track is refused before anything changes: found by diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4d0f7b20c0b..815595cccfe 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3780,7 +3780,7 @@ impl Subscriber { } let resume = loop { let step = kio::wait(|waiter| { - if idle.track.poll_closed(waiter).is_ready() { + if idle.track.poll_aborted(waiter).is_ready() { return Poll::Ready(Step::Closed); } if gate.poll_denied(waiter).is_ready() { @@ -3994,7 +3994,7 @@ impl Subscriber { let mut opening = this.session.clone(); let mut stream = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } Stream::poll_open_request(&mut opening, this.version, &mut waiter.context()) @@ -4004,7 +4004,7 @@ impl Subscriber { let mut timeout = crate::time::Deadline::after(&this.runtime, Control::REQUEST_ID_TIMEOUT); let fetch_id = kio::wait(|waiter| { validity.check(waiter)?; - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } this.control.poll_credit(waiter, &mut timeout) @@ -4425,7 +4425,7 @@ impl Subscriber { &mut reading ))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(open.as_mut()) @@ -4479,7 +4479,7 @@ impl Subscriber { let mut ingest = GroupIngest::new(self, &group, timescale); let res = { kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } if let Poll::Ready(err) = observing.poll_aborted(waiter) { @@ -4829,7 +4829,7 @@ impl Subscriber { ) -> Result, Error> { reading.park(); let settled = kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } @@ -4995,7 +4995,7 @@ impl Subscriber { let mut serving = track.clone(); let mut serve = std::pin::pin!(crate::util::sendable(self.run_fill(stream, &mut serving, timescale))); kio::wait(|waiter| { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { return Poll::Ready(Err(err)); } waiter.poll_future(serve.as_mut()) @@ -5492,7 +5492,7 @@ impl Subscriber { Poll::Pending => {} } } - if let Poll::Ready(err) = track::TrackState::poll_closed(&owner, waiter) { + if let Poll::Ready(err) = track::TrackState::poll_aborted(&owner, waiter) { // Completed history stays cached. Only this FETCH's unfinished group is // ours to abort; closing a track never cascades into cached siblings. if !matches!(*slot.read(), GroupFetch::Done) { @@ -13629,7 +13629,7 @@ mod stitch_tests { assert!( matches!( futures::FutureExt::now_or_never(h.track.closed()), - Some(Error::Unsupported) + Some(Err(Error::Unsupported)) ), "{objects:?}: the subscription must end with the refusal" ); @@ -14631,7 +14631,7 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_fill(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); } /// A subgroup object without a Timestamp on a timed track makes the whole track @@ -14660,7 +14660,7 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_group(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Error::MalformedTrack))); + assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); } /// END_OF_TRACK after a group's last object ends the track right after that group. @@ -14719,7 +14719,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); assert!(matches!( h.track.closed().now_or_never(), - Some(Error::ProtocolViolation) + Some(Err(Error::ProtocolViolation)) )); } @@ -15403,7 +15403,7 @@ mod stitch_tests { assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); assert!(matches!( futures::FutureExt::now_or_never(track.closed()), - Some(Error::MalformedTrack) + Some(Err(Error::MalformedTrack)) )); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index b53289c27f1..f710438e36c 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1055,7 +1055,7 @@ impl GroupRecv { // The track or group dying cancels the stream; the peer's own close // arrives through the ingest's reads. let res = 'serve: { - if let Poll::Ready(err) = track.poll_closed(waiter) { + if let Poll::Ready(err) = track.poll_aborted(waiter) { break 'serve Err(err); } if let Poll::Ready(err) = group.handle().poll_aborted(waiter) { @@ -2986,7 +2986,7 @@ mod tests { } _ => { assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - assert!(matches!(track.closed().now_or_never(), Some(Error::ProtocolViolation))); + assert!(matches!(track.closed().now_or_never(), Some(Err(Error::ProtocolViolation)))); } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 7be13bf2839..a3b1c9952ff 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2553,14 +2553,38 @@ impl TrackState { producer.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped)) } - /// Observe a shared track's terminal cause without extending its publisher lifetime. + /// Observe a shared track's abort, or every producer leaving, without extending its + /// publisher lifetime. A finish alone does not end it: lower groups may still arrive. // Sessions retain shared state while a FETCH owns its group writer. - pub(crate) fn poll_closed(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { + pub(crate) fn poll_aborted(producer: &kio::Producer, waiter: &kio::Waiter) -> Poll { producer .poll_closed(waiter) .map(|()| producer.read().abort.clone().unwrap_or(Error::Dropped)) } + /// The track's outcome once final: `Ok` once its declared end is reached, which a later + /// abort keeps, or the abort error. + fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { + if self.is_complete() { + Poll::Ready(Ok(())) + } else if let Some(err) = &self.abort { + Poll::Ready(Err(err.clone())) + } else { + self.watch_tail(waiter); + Poll::Pending + } + } + + /// [`Self::poll_outcome`] read through a handle: the channel closing short of an + /// outcome (every producer gone without an end or abort) reads as [`Error::Dropped`]. + fn settle(poll: Poll, kio::Ref<'_, Self>>>) -> Poll> { + Poll::Ready(match ready!(poll) { + Ok(outcome) => outcome, + Err(closed) if closed.is_complete() => Ok(()), + Err(closed) => Err(closed.abort.clone().unwrap_or(Error::Dropped)), + }) + } + /// Abort shared track state without extending its publisher lifetime. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn abort(producer: &kio::Producer, err: Error) -> Result<()> { @@ -3292,15 +3316,22 @@ impl Producer { Ok(()) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { + pub async fn closed(&self) -> Result<()> { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the track is closed or aborted; ready with the cause. - pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.state, waiter) + /// Poll until the track's outcome is final; see [`Self::closed`]. + pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { + TrackState::settle(self.state.poll_ref(waiter, |state| state.poll_outcome(waiter))) + } + + /// Poll until the track is aborted, or every producer left; ready with the cause. + // Sessions stop serving on an abort, but keep taking lower groups past a finish. + pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { + TrackState::poll_aborted(&self.state, waiter) } /// Return true if the track has been closed. @@ -3942,11 +3973,17 @@ impl Demand { Poll::Ready(Ok(next)) } - /// Block until the track is closed or aborted, returning the cause. + /// Block until the track's outcome is final; see [`Producer::closed`]. #[cfg(feature = "async")] - pub async fn closed(&self) -> Error { - self.state.closed().await; - self.abort_reason() + pub async fn closed(&self) -> Result<()> { + kio::wait(|waiter| { + TrackState::settle( + self.state + .consume_weak() + .poll(waiter, |state| state.poll_outcome(waiter)), + ) + }) + .await } /// The publisher's tie-break priority, as set in [`Info::priority`]. @@ -13045,6 +13082,44 @@ mod test { assert_eq!(consumer.resume_position(), Some(Position { group: 1, frame: 2 })); } + /// `closed` settles once the declared end is reached, not when it is declared, and an + /// abort after that keeps the clean outcome. + #[test] + #[cfg(feature = "async")] + fn closed_settles_on_a_reached_end() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer.append_group().unwrap().finish().unwrap(); + producer.finish_at(Position::group(2)).unwrap(); + assert!(producer.poll_closed(&waiter).is_pending(), "group 1 is still to come"); + assert!(demand.closed().now_or_never().is_none()); + + producer.append_group().unwrap().finish().unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + assert!(producer.poll_aborted(&waiter).is_pending(), "lower groups may still arrive"); + + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + + /// An abort short of the end, or the last producer leaving without one, is the outcome. + #[test] + #[cfg(feature = "async")] + fn closed_reports_an_unfinished_end() { + let waiter = kio::Waiter::noop(); + let aborted = track_producer("aborted", Info::timed()); + aborted.clone().abort(Error::Timeout).unwrap(); + assert!(matches!(aborted.poll_closed(&waiter), Poll::Ready(Err(Error::Timeout)))); + + let dropped = track_producer("dropped", Info::timed()); + let demand = dropped.demand(); + drop(dropped); + assert!(matches!(demand.closed().now_or_never(), Some(Err(Error::Dropped)))); + } + /// An abort short of the end keeps the groups that finished for a reader that has not /// pulled them yet: it gets them, then the abort. The open group nobody will finish /// is gone, on both cursors. diff --git a/rs/moq-net/src/model/track/fill.rs b/rs/moq-net/src/model/track/fill.rs index 392e62d9db2..2ff54b1399d 100644 --- a/rs/moq-net/src/model/track/fill.rs +++ b/rs/moq-net/src/model/track/fill.rs @@ -980,7 +980,7 @@ impl GroupFill { /// Poll until the track closes, with its abort error. pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { - TrackState::poll_closed(&self.inner.state, waiter) + TrackState::poll_aborted(&self.inner.state, waiter) } /// Whether a reader still waits on the fill. diff --git a/rs/moq-transcode/src/rung.rs b/rs/moq-transcode/src/rung.rs index 92d8e38898c..2ec3e166f56 100644 --- a/rs/moq-transcode/src/rung.rs +++ b/rs/moq-transcode/src/rung.rs @@ -1036,7 +1036,7 @@ mod tests { ); source.close(); assert!(matches!(running.await, Ok(Ended::Closed))); - assert!(matches!(observed.closed().await, moq_net::Error::Cancel)); + assert!(matches!(observed.closed().await, Err(moq_net::Error::Cancel))); } /// The track is admitted before the serving future is spawned, so it also owns cleanup From 314f20a7d9896b905479b87a57ee227a139a511c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:05:54 -0700 Subject: [PATCH 05/30] feat(net)!: split JS Group.Producer.close into finish and abort Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/e2ee.test.ts | 2 +- js/e2ee/src/group.ts | 4 +- js/flate/src/snapshot/producer.ts | 2 +- js/flate/src/snapshot/snapshot.test.ts | 4 +- js/flate/src/stream/producer.ts | 4 +- js/flate/src/stream/stream.test.ts | 2 +- js/hang/src/container/consumer.holes.test.ts | 10 +- .../container/consumer.nonsequential.test.ts | 2 +- .../src/container/consumer.outoforder.test.ts | 6 +- js/hang/src/container/consumer.rewind.test.ts | 2 +- .../src/container/consumer.startup.test.ts | 12 +- js/hang/src/container/consumer.test.ts | 82 +++++----- js/hang/src/container/legacy.ts | 7 +- js/json/src/snapshot/producer.ts | 11 +- js/json/src/snapshot/snapshot.test.ts | 2 +- js/json/src/stream/producer.ts | 4 +- js/json/src/window/producer.ts | 9 +- js/json/src/window/window.test.ts | 4 +- js/net/bench/compression.ts | 2 +- js/net/bench/drift.ts | 2 +- js/net/bench/frames.ts | 2 +- js/net/bench/refresh.ts | 6 +- js/net/bench/resume.ts | 8 +- js/net/bench/serve-browser-entry.ts | 2 +- js/net/bench/subscribe-drop.ts | 2 +- js/net/bench/track.ts | 4 +- js/net/src/broadcast.test.ts | 20 +-- js/net/src/connection/reload.test.ts | 4 +- js/net/src/consume.test.ts | 2 +- js/net/src/flate/group.test.ts | 14 +- js/net/src/group.test.ts | 34 ++-- js/net/src/group.ts | 22 ++- js/net/src/ietf/publisher.test.ts | 30 ++-- js/net/src/ietf/subscribe_tracks.test.ts | 6 +- js/net/src/ietf/subscriber.ts | 10 +- js/net/src/integration.test.ts | 34 ++-- js/net/src/lite/compression.test.ts | 2 +- js/net/src/lite/connection.test.ts | 8 +- js/net/src/lite/group.test.ts | 6 +- js/net/src/lite/publisher.test.ts | 59 +++---- js/net/src/lite/refresh.test.ts | 2 +- js/net/src/lite/subscriber.ts | 20 +-- js/net/src/order.test.ts | 10 +- js/net/src/origin.test.ts | 6 +- js/net/src/parked-expiry.test.ts | 4 +- js/net/src/resume.test-util.ts | 2 +- js/net/src/resume.test.ts | 70 ++++---- js/net/src/retention.test.ts | 2 +- js/net/src/track.test.ts | 152 +++++++++--------- js/net/src/track.ts | 13 +- js/net/src/untimed.test.ts | 8 +- js/net/src/zod.test.ts | 4 +- js/publish/src/broadcast.test.ts | 6 +- js/watch/src/audio/decoder.test.ts | 2 +- js/watch/src/audio/playout.test.ts | 2 +- js/watch/src/audio/replay.ts | 2 +- js/watch/src/video/decoder.test.ts | 12 +- 57 files changed, 392 insertions(+), 373 deletions(-) diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index a14ad142370..9b028fc2649 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -388,7 +388,7 @@ test("grouped authentication failure ends the track", async () => { const bad = new NetGroup.Producer(1); p.net.writeGroup(bad); bad.writeFrame({ timestamp: ms(2), payload: await sealed(p, GROUP, 1, 0, "ok", true) }); - bad.close(); + bad.finish(); expect(await readText(await recvGroup(p.consumer))).toBe("ok"); const forged = await recvGroup(p.consumer); diff --git a/js/e2ee/src/group.ts b/js/e2ee/src/group.ts index a08466d085a..395dd11fbb7 100644 --- a/js/e2ee/src/group.ts +++ b/js/e2ee/src/group.ts @@ -96,14 +96,14 @@ export class Producer { } this.#closed = true; void this.#writer.pump.run(() => { - if (!this.#aborted) this.#inner.close(); + if (!this.#aborted) this.#inner.finish(); }); } #abort(error: Error): void { this.#closed = true; this.#aborted ??= error; - this.#inner.close(error); + this.#inner.abort(error); } } diff --git a/js/flate/src/snapshot/producer.ts b/js/flate/src/snapshot/producer.ts index c9c8e6250c5..7b6a1997453 100644 --- a/js/flate/src/snapshot/producer.ts +++ b/js/flate/src/snapshot/producer.ts @@ -61,7 +61,7 @@ export class Producer { } finally { // The group is already visible on the track, so leaving it open on a failed write would // strand a subscriber that advanced into it with nothing to read and no end. - group.close(); + group.finish(); } } diff --git a/js/flate/src/snapshot/snapshot.test.ts b/js/flate/src/snapshot/snapshot.test.ts index 8e94be4bd12..71309dcf65e 100644 --- a/js/flate/src/snapshot/snapshot.test.ts +++ b/js/flate/src/snapshot/snapshot.test.ts @@ -151,11 +151,11 @@ test("latest lets a newer group preempt an open one", async () => { // A complete newer value arrives while the previous group is still open. const fresh = track.appendGroup(); fresh.writeFrame({ payload: bytes(2), timestamp: Time.Timestamp.now() }); - fresh.close(); + fresh.finish(); expect((await consumer.latest())?.value).toEqual(bytes(2)); - stale.close(); + stale.finish(); track.close(); }); diff --git a/js/flate/src/stream/producer.ts b/js/flate/src/stream/producer.ts index fb023d26d98..b8ca9a6d532 100644 --- a/js/flate/src/stream/producer.ts +++ b/js/flate/src/stream/producer.ts @@ -88,7 +88,7 @@ export class Producer { // end. The group reaches a reader already inside it, which keeps its own handle and would // otherwise get a generic error when ours is dropped. const abort = err instanceof Error ? err : new Error(String(err)); - this.#group?.close(abort); + this.#group?.abort(abort); this.#group = undefined; this.#track.close(abort); throw err; @@ -100,7 +100,7 @@ export class Producer { /** Finish the track, closing the group. */ finish(): void { - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; this.#track.close(); } diff --git a/js/flate/src/stream/stream.test.ts b/js/flate/src/stream/stream.test.ts index e3983a7e07e..6140647e128 100644 --- a/js/flate/src/stream/stream.test.ts +++ b/js/flate/src/stream/stream.test.ts @@ -75,7 +75,7 @@ test("a second group is a rolled log, not a continuation", async () => { for (const pair of [payloads(2), payloads(2)]) { const group = track.appendGroup(); for (const payload of pair) group.writeFrame({ payload, timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); } track.close(); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index fe77724c73d..25044194521 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -26,13 +26,13 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms const first = new Group.Producer(0); track.writeGroup(first); first.writeFrame({ payload: encodeFrame(new Uint8Array([1]), Micro(10_000)), timestamp: Time.Timestamp.now() }); - first.close(); + first.finish(); await flush(); expect(results).toHaveLength(1); const marker = new Group.Producer(2); track.writeGroup(marker); marker.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.now() }); - marker.close(); + marker.finish(); await flush(); if (budget > 0) { expect(results).toHaveLength(1); @@ -75,7 +75,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as payload: timestamp === undefined ? new Uint8Array() : encodeFrame(new Uint8Array([1]), Micro(timestamp)), timestamp: Time.Timestamp.fromMicros(timestamp ?? 20_000), }); - group.close(); + group.finish(); }; try { write(0, 0); @@ -126,7 +126,7 @@ test("the holes a stalled source left behind share one budget", async () => { payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), }); - group.close(); + group.finish(); }; try { write(0); @@ -167,7 +167,7 @@ test("a hole is late from the earliest arrival past it", async () => { payload: encodeFrame(new Uint8Array([1]), Micro(sequence * 10_000)), timestamp: Time.Timestamp.fromMicros(sequence * 10_000), }); - group.close(); + group.finish(); }; try { write(0); diff --git a/js/hang/src/container/consumer.nonsequential.test.ts b/js/hang/src/container/consumer.nonsequential.test.ts index 492a8b03a80..5544943625d 100644 --- a/js/hang/src/container/consumer.nonsequential.test.ts +++ b/js/hang/src/container/consumer.nonsequential.test.ts @@ -46,7 +46,7 @@ test("Consumer waits on a non-sequential hang group because it has no per-frame payload: encodeLegacyFrame(0 as Micro, new Uint8Array([0x01])), timestamp: Time.Timestamp.now(), }); - a.close(); + a.finish(); track.writeGroup(a); // Drain A's frame. diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index 927c61caf46..fd6b00a0b98 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -35,7 +35,7 @@ function publish(track: Track.Producer, sequence: number, millis: number) { const group = new Group.Producer(sequence); track.writeGroup(group); publishFrame(group, millis, sequence); - group.close(); + group.finish(); } /** Every frame the consumer will hand over right now, as [group, payload] pairs. */ @@ -111,13 +111,13 @@ test("a below-cursor group still downloading is not truncated", async () => { // rather than shift the group out, so the second frame still arrives. const pending = nextFrame(consumer); publishFrame(backlog, 1500, 11); - backlog.close(); + backlog.finish(); const second = await pending; expect(second?.group).toBe(1); expect(second?.frame.payload[0]).toBe(11); - live.close(); + live.finish(); track.close(); consumer.close(); }); diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 358c9b2d701..67f01adfc2b 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -33,7 +33,7 @@ function group(track: Track.Producer, timestamps: number[], end?: number) { payload: encodeFrame(new Uint8Array(), Micro(end)), }); } - group.close(); + group.finish(); } async function frame(consumer: Consumer) { diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 5bb4417e4e2..3a46b7c3ae7 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -67,10 +67,10 @@ for (const emptySuccessor of [false, true]) { if (emptySuccessor) { const empty = new Group.Producer(17); track.writeGroup(empty); - empty.close(); + empty.finish(); await flush(); } - stale.close(new NetError.Stream(StreamCode.Old)); + stale.abort(new NetError.Stream(StreamCode.Old)); await flush(); expect(results).toEqual([]); @@ -97,7 +97,7 @@ test("Consumer preserves a declared marker before its first media across a hole" const marker = new Group.Producer(16); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["end 16@0"]); @@ -117,7 +117,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const first = new Group.Producer(16); track.writeGroup(first); write(first, 0); - first.close(); + first.finish(); await flush(); expect(items(results)).toEqual(["16@0"]); @@ -125,7 +125,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const marker = new Group.Producer(17); track.writeGroup(marker); write(marker, 0, true); - marker.close(); + marker.finish(); await flush(); expect(items(results)).toEqual(["16@0", "end 17@0"]); } @@ -141,7 +141,7 @@ test.each([false, true])("Consumer still waits for a gap after media (playhead r const bridge = new Group.Producer(sequence - 1); track.writeGroup(bridge); write(bridge, 0); - bridge.close(); + bridge.finish(); await flush(); // The bridge closes the hole, so nothing reads as a gap. expect(items(results)).toEqual(["16@0", ...(reset ? ["end 17@0"] : []), `${sequence - 1}@0`, `${sequence}@50000`]); diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 5c70cffa5b2..18535ee70aa 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -48,7 +48,7 @@ function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { timestamp: Time.Timestamp.fromMicros(timestamp), }); } - group.close(); + group.finish(); } track.close(); } @@ -395,7 +395,7 @@ test("Consumer refuses a data marker that shares its group", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array(), timestamp: Time.Timestamp.fromMicros(0) }); group.writeFrame({ payload: second, timestamp: Time.Timestamp.fromMicros(10_000) }); - group.close(); + group.finish(); // A finished track resolves the read instead of hanging when the reader fails to end. track.close(); await expect(nextFrame(consumer)).rejects.toThrow("shares its group"); @@ -507,7 +507,7 @@ function writeGroupWithLegacyFrames( for (const ts of timestamps) { group.writeFrame({ payload: encodeLegacy(ts), timestamp: stamp(ts) }); } - group.close(); + group.finish(); track.writeGroup(group); } @@ -517,7 +517,7 @@ function writeMarkerGroup(track: Track.Producer, sequence: number, timestamp: Mi payload: encodeLegacyFrame(timestamp, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); track.writeGroup(group); } @@ -598,7 +598,7 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // first MoQ frame → 3 samples group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -631,13 +631,13 @@ for (const [where, payloads] of [ const g0 = new Group.Producer(0); for (const byte of payloads) g0.writeFrame({ payload: new Uint8Array([byte]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); track.writeGroup(g0); // A later group that decodes cleanly is never reached. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g1); track.close(); @@ -665,7 +665,7 @@ test("Consumer delivers everything ahead of a decode error", async () => { const malformed = new Group.Producer(3); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); track.close(); @@ -692,7 +692,7 @@ test("Consumer keeps the first of several decode errors", async () => { for (const sequence of [0, 1]) { const group = new Group.Producer(sequence); group.writeFrame({ payload: new Uint8Array([sequence]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); } track.close(); @@ -722,7 +722,7 @@ test("Consumer releases its open groups when an error ends it", async () => { const malformed = new Group.Producer(0); malformed.writeFrame({ payload: new Uint8Array([0xff]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); const later = new Group.Producer(1); later.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); @@ -763,7 +763,7 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async } track.writeGroup(g0); await decoded; - g0.close(new NetError.Stream(StreamCode.Cancel)); + g0.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [4 as Micro]); track.close(); @@ -957,7 +957,7 @@ test("Consumer keeps a trailing duration marker internal", async () => { payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); await settle(); @@ -987,7 +987,7 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra payload: encodeLegacyFrame(20_000 as Micro, new Uint8Array([0xde, 0xad])), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1101,7 +1101,7 @@ test("Consumer throws the first error in delivery order", async () => { const malformed = new Group.Producer(1); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: wallClock() }); - malformed.close(); + malformed.finish(); track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); track.close(); @@ -1140,7 +1140,7 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const track = new Track.Producer("test"); const consumer = new Consumer(replay(track), { format: batchFormat, maxDelay: 500 as Milli }); - write(0, [1_000_000]).close(); + write(0, [1_000_000]).finish(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(1_000_000 as Micro); // The newer group arrives first and stays open, so the older one is the oldest buffered group @@ -1148,10 +1148,10 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn const newest = write(3, [3_000_000]); await later; // Its first sample is fine; the second rewinds below the delivered group's start. - write(2, [2_000_000, 0]).close(); + write(2, [2_000_000, 0]).finish(); // Read only once the skip has dropped it, before the newer group's frame is delivered. await rewound; - newest.close(); + newest.finish(); track.close(); const read = async () => { @@ -1246,7 +1246,7 @@ test("Consumer reads no gap when retiring a group holding only its endpoint", as payload: encodeLegacyFrame(33_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.fromMicros(33_000), }); - group.close(); + group.finish(); track.writeGroup(group); await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); @@ -1272,7 +1272,7 @@ test("Consumer reads a gap when retiring a truncated group", async () => { await settle(); expect(frameOf(await consumer.next()).timestamp).toBe(0 as Micro); - group.close(new NetError.Stream(StreamCode.Cancel)); + group.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); await settle(); @@ -1303,7 +1303,7 @@ test("Consumer reads a gap when retiring an open group", async () => { expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 100_000 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(100_000 as Micro); - group.close(); + group.finish(); consumer.close(); }); @@ -1368,7 +1368,7 @@ test("Consumer handles empty decode result without deadlock", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([0x01]), timestamp: Time.Timestamp.now() }); // empty decode group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1392,7 +1392,7 @@ test("Consumer preserves empty media from formats without endpoint markers", asy const consumer = new Consumer(track.subscribe(), { format, maxDelay: 500 as Milli }); const group = new Group.Producer(0); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1418,7 +1418,7 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { payload: fragment("delta"), timestamp: at(3000), }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1441,7 +1441,7 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { const group = new Group.Producer(0); group.writeFrame({ payload: fragment("untimedKey") }); group.writeFrame({ payload: fragment("untimedDelta") }); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1487,7 +1487,7 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { // Group.Producer 1: closed, starts exactly where group 0's frame ends. const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); @@ -1524,7 +1524,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => const g1 = new Group.Producer(1); g1.writeFrame({ payload: new Uint8Array([33]), timestamp: Time.Timestamp.now() }); - g1.close(); + g1.finish(); track.writeGroup(g0); track.writeGroup(g1); @@ -1532,7 +1532,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => // Let the consumer settle on group 0, then extend it before closing. await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); - g0.close(); + g0.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1559,7 +1559,7 @@ test("Consumer delivers a PTS-contiguous next group whose sequence jumped (Legac payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1604,7 +1604,7 @@ test("Consumer waits on a PTS gap instead of skipping to a later buffered group payload: encodeLegacyFrame(Math.round((0 / TIMESCALE) * 1_000_000) as Micro, new Uint8Array([0x01])), timestamp: at(0), }); - a.close(); + a.finish(); track.writeGroup(a); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1668,10 +1668,10 @@ test("Consumer delivers a contiguous group after one that completed out of order const b = new Group.Producer(1_045_000); track.writeGroup(b); b.writeFrame(timedFrame(0x03, 6000, 2)); - b.close(); + b.finish(); await settle(); - a.close(); + a.finish(); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x02])); expect(frameOf(await consumer.next()).payload).toEqual(new Uint8Array([0x03])); @@ -1703,7 +1703,7 @@ test("Consumer plays the head once a waited-out gap exceeds the budget (Legacy)" track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // The reader is parked on the missing 1001, as a decoder is while it waits for the next group. const pending = consumer.next(); @@ -1735,7 +1735,7 @@ test("Consumer waits on a gap within the budget, then plays the head once it exc track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); - a.close(); + a.finish(); // B (seq 2000) starts at 6000 ticks (66_667µs): a gap, but only 33ms past A's end. const b = new Group.Producer(2000); @@ -1805,7 +1805,7 @@ test("Consumer empty groups mean nothing", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); const empty = new Group.Producer(1); track.writeGroup(empty); - empty.close(); + empty.finish(); writeGroupWithLegacyFrames(track, 2, [1_000 as Micro]); await settle(); @@ -1873,7 +1873,7 @@ test("Consumer reads a shed marker group as a gap", async () => { // The zero budget sheds the marker unread, and what it declared is lost with it. expect(await consumer.next()).toEqual({ kind: "gap", timestamp: 0 as Micro, duration: 500 as Micro }); expect(frameOf(await consumer.next()).timestamp).toBe(500 as Micro); - group0.close(); + group0.finish(); consumer.close(); }); @@ -1889,7 +1889,7 @@ test("Consumer yields no gap across a PTS-contiguous group id jump (Legacy)", as const a = new Group.Producer(1_000_000); track.writeGroup(a); a.writeFrame(timedFrame(0x01, 0, 0)); - a.close(); + a.finish(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([0x01])); @@ -1926,7 +1926,7 @@ test("a live video frame is delivered before its duration marker arrives", async payload: encodeLegacyFrame(15_000 as Micro, new Uint8Array()), timestamp: Time.Timestamp.now(), }); - group.close(); + group.finish(); track.close(); // The marker is the frame's duration, not an item. expect(await consumer.next()).toBeUndefined(); @@ -1958,7 +1958,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre timestamp: Time.Timestamp.now(), }); await settle(); - group.close(new NetError.Stream(code)); + group.abort(new NetError.Stream(code)); await settle(); expect(frameOf(await nextFrame(consumer)).payload).toEqual(new Uint8Array([1])); const next = track.appendGroup(); @@ -1966,7 +1966,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre payload: encodeLegacyFrame(1 as Micro, new Uint8Array([2])), timestamp: Time.Timestamp.now(), }); - next.close(); + next.finish(); // The reset cut the group's tail. expect(await consumer.next()).toEqual({ kind: "gap", @@ -2011,12 +2011,12 @@ test("a group reset does not hide a later container decode failure", async () => try { const reset = track.appendGroup(); await settle(); - reset.close(new NetError.Stream(StreamCode.Cancel)); + reset.abort(new NetError.Stream(StreamCode.Cancel)); await settle(); const malformed = track.appendGroup(); // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.now() }); - malformed.close(); + malformed.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("buffer too short"); } finally { diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index a4dc2d2326b..5916111b02e 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -146,7 +146,7 @@ export class Producer { payload: this.#format.kind === "data" ? new Uint8Array() : encodeFrame(new Uint8Array(), timestamp), timestamp: Time.Timestamp.fromMicros(timestamp), }); - group.close(); + group.finish(); this.#liveEdge = this.#liveEdge === undefined ? timestamp : (Math.max(this.#liveEdge, timestamp) as Micro); this.#marked = true; } @@ -169,7 +169,7 @@ export class Producer { : undefined; // A presentation endpoint cannot bound the decode-order tail after reordering. this.#format.finishGroup(this.#group, this.#reordered ? undefined : end); - this.#group.close(); + this.#group.finish(); this.#group = undefined; if (this.#end !== undefined) { this.#liveEdge = this.#liveEdge === undefined ? this.#end : (Math.max(this.#liveEdge, this.#end) as Micro); @@ -182,7 +182,8 @@ export class Producer { /** Close the track and current group, optionally with an error. */ close(err?: Error) { if (!err) this.#close(); - this.#group?.close(err); + if (err) this.#group?.abort(err); + else this.#group?.finish(); this.#track.close(err); } } diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index 638c40e59ad..f25254a82e0 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -80,7 +80,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** @@ -111,7 +111,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -122,7 +122,7 @@ export class Producer { // The group carries no frames, so close it rather than leaving it open on the track. A // rejected frame doesn't close the track, and a consumer that already advanced into this // group would otherwise block until some later update opened a newer one. - group.close(); + group.finish(); throw err; } @@ -131,7 +131,7 @@ export class Producer { this.#group = group; } else { // Deltas disabled: one frame per group, identical to a plain JSON track. - group.close(); + group.finish(); this.#group = undefined; } return; @@ -186,7 +186,8 @@ export class Producer { #close(abort?: Error): void { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; // The open group goes with the track, so the encoder must not keep emitting deltas into it. diff --git a/js/json/src/snapshot/snapshot.test.ts b/js/json/src/snapshot/snapshot.test.ts index cbe6ba971e7..1b08fdcb545 100644 --- a/js/json/src/snapshot/snapshot.test.ts +++ b/js/json/src/snapshot/snapshot.test.ts @@ -634,7 +634,7 @@ test("snapshot consumer propagates a non-gap frame failure", async () => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode('{"ok":true}'), timestamp: Time.Timestamp.now() }); expect((await consumer.next())?.value).toEqual({ ok: true }); - group.close(new NetError.Stream(StreamCode.Internal)); + group.abort(new NetError.Stream(StreamCode.Internal)); await expect(consumer.next()).rejects.toMatchObject({ code: StreamCode.Internal }); }); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index be17354c7df..5e5c7612ce1 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -87,14 +87,14 @@ export class Producer { // Close the group with the same error first. A consumer that already pulled it holds its own // handle, which the track's close no longer reaches, so dropping ours would leave a reader // sitting in the group with a generic error instead of the failure that ended the log. - this.#group?.close(abort); + this.#group?.abort(abort); this.#group = undefined; this.#track.close(abort); } /** Finish the track, closing the group. */ finish(): void { - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; this.#track.close(); } diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index fdecbd3e6bd..de269416096 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -114,7 +114,7 @@ export class Producer { if (encoded.keyframe) { // The previous group is complete; no more frames will be appended to it. Drop the handle // before opening the next one, so a failure below doesn't leave a closed group behind. - this.#group?.close(); + this.#group?.finish(); this.#group = undefined; const group = this.#track.appendGroup(); @@ -124,7 +124,7 @@ export class Producer { } catch (err) { // The group carries no frames, so close it rather than leaving it open on the track. A // consumer that already advanced into it would otherwise block with nothing to read. - group.close(); + group.finish(); throw err; } @@ -153,7 +153,7 @@ export class Producer { const group = this.#group; this.#group = undefined; - group.close(); + group.finish(); } /** Finish the track, closing any open group. */ @@ -167,7 +167,8 @@ export class Producer { this.#unwatch?.(); this.#unwatch = undefined; - this.#group?.close(abort); + if (abort) this.#group?.abort(abort); + else this.#group?.finish(); this.#group = undefined; this.#track.close(abort); diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index b5a08fb9c39..78187201e1f 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -403,13 +403,13 @@ test("consumer resumes at a checkpoint after losing a group", async () => { group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); expect((await consumer.next())?.value).toEqual({ push: { index: 0, value: { n: 0 } } }); - group.close(error); + group.abort(error); frame = encoder.push({ n: 1 }); group = track.appendGroup(); group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); - group.close(); + group.finish(); track.close(); expect((await consumer.next())?.value).toEqual({ push: { index: 1, value: { n: 1 } } }); diff --git a/js/net/bench/compression.ts b/js/net/bench/compression.ts index 8c5cbe91ea4..42d1b882b97 100644 --- a/js/net/bench/compression.ts +++ b/js/net/bench/compression.ts @@ -22,7 +22,7 @@ for (const publishers of [1, 4, 16]) { let started = performance.now(); for (const group of groups) { for (let index = 0; index < frames; index++) group.writeFrame({ payload }); - group.close(); + group.finish(); } publish += performance.now() - started; stored = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0); diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index a4b74268e2e..efc820f3300 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -20,7 +20,7 @@ export async function run(): Promise { for (let sequence = 0; sequence < retained; sequence++) { const group = track.appendGroup(); group.writeFrame({ payload }); - group.close(); + group.finish(); } const subscribers = Array.from({ length: readers }, () => track.subscribe({ maxDelay: Milli(60_000) }), diff --git a/js/net/bench/frames.ts b/js/net/bench/frames.ts index 22aa863798b..0ba69284490 100644 --- a/js/net/bench/frames.ts +++ b/js/net/bench/frames.ts @@ -54,7 +54,7 @@ const lite: Protocol = { const producer = new Producer(0); const done = readFrames(new Reader(stream, undefined, LiteVersion.DRAFT_06), producer, { scale: 1_000_000, - }).then(() => producer.close()); + }).then(() => producer.finish()); return { group: Promise.resolve(producer.consume()), done }; }; return { open, close: () => {} }; diff --git a/js/net/bench/refresh.ts b/js/net/bench/refresh.ts index 106f3f3b89d..4e3842f7cce 100644 --- a/js/net/bench/refresh.ts +++ b/js/net/bench/refresh.ts @@ -39,7 +39,7 @@ for (const retained of [1, 64, 1024]) for (let i = 0; i < tracks; i++) { const group = producers[i].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[i].recvGroup(); if (!received) throw new Error("baseline group missing"); await received.readFrame(); @@ -50,7 +50,7 @@ for (const retained of [1, 64, 1024]) for (let old = 1; old < retained; old++) { const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("historical group missing"); await received.readFrame(); @@ -62,7 +62,7 @@ for (const retained of [1, 64, 1024]) const group = producers[0].appendGroup(); group.writeFrame({ payload: new Uint8Array(3) }); group.writeFrame({ payload: new Uint8Array(4) }); - group.close(); + group.finish(); const received = await baseline[0].recvGroup(); if (!received) throw new Error("measured group missing"); await received.readFrame(); diff --git a/js/net/bench/resume.ts b/js/net/bench/resume.ts index 59b93166500..8e68cd64e48 100644 --- a/js/net/bench/resume.ts +++ b/js/net/bench/resume.ts @@ -51,7 +51,7 @@ async function measure(readers: number, swaps: number | undefined, fork = false) const group = new GroupProducer(sequence); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); }; sources.push({ broadcast, write }); return broadcast; @@ -171,7 +171,7 @@ async function measureFlaps(readers: number, flaps: number): Promise { for (const group of held) group.close(); for (const subscriber of subscribers) subscriber.close(); front.consumer.close(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); for (const handle of handles) handle.close(); for (const source of sources) source.close(); await flush(); @@ -215,7 +215,7 @@ async function measureReplay(readers: number, replayed: number): Promise }), }); } - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); const start = performance.now(); await Promise.all(consumers.map((consumer) => consumer.readable())); const elapsed = performance.now() - start; @@ -225,7 +225,7 @@ async function measureReplay(readers: number, replayed: number): Promise checksum += frame.payload.byteLength; consumer.close(); } - replacement.close(); + replacement.finish(); await flush(); return (elapsed * 1000) / readers; } diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index b511b53453f..aa8accfa877 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -98,7 +98,7 @@ async function measure(protocol: string, groups: number, viewers: number) { payload: new Uint8Array(32), timestamp: Timestamp.fromMicros(sequence * 2500 + Math.floor((frame * 2500) / 128)), }); - group.close(); + group.finish(); } let deadline: ReturnType | undefined; try { diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index b7bf27d3294..8f59a911f59 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -30,7 +30,7 @@ try { for (let sequence = 0; sequence < groups; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); } track.close(); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 2a2e00adbc2..356e1e28ca3 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -65,7 +65,7 @@ async function measure( const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload, timestamp: Timestamp.fromMillis(sequence++) }); - if (close) group.close(); + if (close) group.finish(); return group; }; @@ -101,7 +101,7 @@ async function measure( const elapsed = now() - start; for (const done of opened) done(); - for (const group of open) group.close(); + for (const group of open) group.finish(); await Promise.all(reads); // Completion wakes every subscriber independently of producer closure. const ending = subscribers.map((subscriber) => subscriber.recvGroup()); diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index a3024baaabc..103ed74d353 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -126,7 +126,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { test("an empty replacement ends past its predecessor's groups", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - first.appendGroup().close(); + first.appendGroup().finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -141,7 +141,7 @@ test("an empty replacement ends past its predecessor's groups", async () => { test("a removed producer writes nothing once its replacement claims the name", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - first.appendGroup().close(); + first.appendGroup().finish(); broadcast.removeTrack("media"); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -162,7 +162,7 @@ test("a removed producer's abort ignores its replacement's groups", async () => broadcast.removeTrack("media"); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); - for (let i = 0; i < 3; i++) second.appendGroup().close(); + for (let i = 0; i < 3; i++) second.appendGroup().finish(); const abort = new Error("cut off"); first.close(abort); expect(first.closed.peek()).toBe(abort); @@ -325,7 +325,7 @@ test("names that never wrote leave nothing behind", () => { test("a written name refuses to reuse its sequences after many other names", () => { const broadcast = new BroadcastProducer(); const written = broadcast.createTrack("written"); - written.appendGroup().close(); + written.appendGroup().finish(); written.close(); for (let i = 0; i < 1000; i++) broadcast.createTrack(`unused-${i}`).close(); @@ -606,7 +606,7 @@ test("subscribe serves a statically inserted track without a request", async () const broadcast = new BroadcastProducer(); const track1 = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); - track1.appendGroup().close(); + track1.appendGroup().finish(); // The track already exists, so subscribe resolves immediately (no requested()). const sub1 = broadcast.track("track1").subscribe().ordered(); @@ -619,7 +619,7 @@ test("subscribe serves a statically inserted track without a request", async () const track2 = broadcast.createTrack("track2", { timescale: Timescale.MILLI }); const sub2 = broadcast.track("track2").subscribe().ordered(); - track2.appendGroup().close(); + track2.appendGroup().finish(); expect((await sub2.nextGroup())?.sequence).toBe(0); }); @@ -677,7 +677,7 @@ test("a read throws GroupTooLarge on an overflow, then resyncs to the next group // Group 1 is clean. const g1 = producer.appendGroup(); g1.writeFrame({ payload: new TextEncoder().encode("ok"), timestamp: Timestamp.now() }); - g1.close(); + g1.finish(); // The reader hits the abort in group 0 (error, not a silent skip), then the next // read resyncs from group 1. @@ -812,11 +812,11 @@ test("a fetch waits for a group still to come", async () => { const first = track.appendGroup(); first.writeFrame({ payload: new TextEncoder().encode("0"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const second = track.appendGroup(); second.writeFrame({ payload: new TextEncoder().encode("1"), timestamp: Timestamp.now() }); - second.close(); + second.finish(); const group = await pending; expect(group.sequence).toBe(1); @@ -835,7 +835,7 @@ test("fetch maxDelay tightens publisher retention without changing omission", as payload: new TextEncoder().encode(String(millis)), timestamp: Timestamp.fromMillis(millis), }); - group.close(); + group.finish(); } const omitted = await wireOf(broadcast).fetchGroup("video", 0); diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index e3f144b38ed..3ec036869ee 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -686,7 +686,7 @@ for (const [name, teardown] of Object.entries(teardowns)) { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); teardown.end(handle, win); await clock.advance(Nano.fromMilli(teardown.linger)); @@ -903,7 +903,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { fin.hold?.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); reload.close(); diff --git a/js/net/src/consume.test.ts b/js/net/src/consume.test.ts index a3efb9b3503..ecea5231327 100644 --- a/js/net/src/consume.test.ts +++ b/js/net/src/consume.test.ts @@ -31,7 +31,7 @@ test("cache identity follows the serving announcement, preserving held tracks", const shared = cache.get(job, cache.instance(job)); expect(shared?.closed).toBe(fresh.closed); // Cache replacement never transfers or closes the track an old handle is reading. - track.appendGroup().close(); + track.appendGroup().finish(); expect((await ordered.nextGroup())?.sequence).toBe(0); cache.withdraw(job, specific); expect(cache.get(job, cache.instance(job))).toBeUndefined(); diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 59dd9d40cb0..58da8efaf4c 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -16,7 +16,7 @@ test("synchronous writers store slices; plain readers share one charged inflated const raw = group.mirror(); group.writeFrame({ payload }); group.writeFrame({ payload }); - group.close(); + group.finish(); const stored = hooks.groupCacheBytes(group); expect(stored).toBeLessThan(payload.length); const inflating = spyOn(Decoder.prototype, "frame"); @@ -44,7 +44,7 @@ test("received slices are forwarded byte-for-byte and exact declarations fail al hooks.bindGroupCompression(group, true); const slice = new Encoder().frame(payload); hooks.writeGroupFrame(group, { payload: slice }, payload.length + 1); - group.close(); + group.finish(); const raw = await hooks.readGroupFrame(group.mirror(), true); expect(raw?.frame.payload).toBe(slice); const bad = group.mirror(); @@ -65,7 +65,7 @@ test("declared plain bytes consume the group budget before any inflation", () => expect(hooks.groupCacheBytes(group)).toBe(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); } }); @@ -77,7 +77,7 @@ test("empty frames preserve the window and prewritten groups bind without duplic const track = new Track("compressed").accept({ compress: true }); track.writeGroup(group); group.writeFrame({ payload }); - group.close(); + group.finish(); for (const expected of [payload, new Uint8Array(), payload]) expect((await reader.readFrame())?.payload).toEqual(expected); track.close(); @@ -89,7 +89,7 @@ test("a cut compressed group preserves its committed frames before returning its const reader = group.mirror(); group.writeFrame({ payload }); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); expect((await reader.readFrame())?.payload).toEqual(payload); await expect(reader.readFrame()).rejects.toThrow(cut); track.close(); @@ -103,7 +103,7 @@ test("a shared plain read cannot change a later synchronous write's admission", expect((await reader.readFrame())?.payload.length).toBe(MAX_GROUP_CACHE_BYTES); // The plain-byte budget is full, but another empty frame still fits, regardless of readers. expect(() => group.writeFrame({ payload: new Uint8Array() })).not.toThrow(); - group.close(); + group.finish(); expect((await reader.readFrame())?.payload.length).toBe(0); track.close(); }); @@ -148,7 +148,7 @@ test("refresh measures stored slices without inflating a compressed group", () = expect(inflating).toHaveBeenCalledTimes(0); } finally { inflating.mockRestore(); - group.close(); + group.finish(); track.close(); } }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index fcfa1c5b3cd..56979bfd8d4 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -91,7 +91,7 @@ test("a closed group rejects a further write without discarding committed frames for (let i = 0; i < MAX_GROUP_FRAMES; i++) { producer.writeFrame({ payload: new Uint8Array([i & 0xff]), timestamp: Timestamp.now() }); } - producer.close(); + producer.finish(); expect(() => producer.writeFrame({ payload: new Uint8Array([0]), timestamp: Timestamp.now() })).toThrow( "group is closed", @@ -118,7 +118,7 @@ test("a group with no eviction reads every frame without error", async () => { const { producer, consumer } = pair(0); producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload[0]).toBe(1); expect((await consumer.readFrame())?.payload[0]).toBe(2); @@ -163,7 +163,7 @@ test("done distinguishes a finished group from one that is merely empty", () => producer.writeFrame(textFrame("a")); // Buffered but closed: still not done until the frame is drained. - producer.close(); + producer.finish(); expect(consumer.done).toBe(false); consumer.tryReadFrame(); @@ -176,7 +176,7 @@ test("a previously obtained closed handle peeks source closure synchronously", ( const { producer, consumer } = pair(0); const closed = consumer.closed; - producer.close(); + producer.finish(); expect(closed.peek()).toBeNull(); }); @@ -201,7 +201,7 @@ test("readable resolves once a frame is buffered", async () => { test("readable resolves once the group closes, even with nothing buffered", async () => { const { producer, consumer } = pair(0); const readable = consumer.readable(); - producer.close(); + producer.finish(); await readable; // resolves on close so callers don't wait forever expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -246,8 +246,8 @@ test("readable waits past replayed frames after adopting an open replacement", a await readable; expect(consumer.tryReadFrameSequence()).toMatchObject({ sequence: 1, payload: textFrame("one").payload }); consumer.close(); - producer.close(); - replacement.close(); + producer.finish(); + replacement.finish(); }); test("done ignores replayed frames an adopted copy buffers", async () => { @@ -279,17 +279,17 @@ test("done ignores replayed frames an adopted copy buffers", async () => { await new Promise((resolve) => setImmediate(resolve)); // The adopted copy ends having repeated only the frame already read. replacement.writeFrame(textFrame("zero")); - replacement.close(); + replacement.finish(); expect(consumer.done).toBe(true); await readable; expect(consumer.tryReadFrame()).toBeUndefined(); - producer.close(); + producer.finish(); }); test("buffered frames are still readable after the group closes", async () => { const { producer, consumer } = pair(0); producer.writeFrame(textFrame("a")); - producer.close(); + producer.finish(); // Closing doesn't discard buffered frames; the blocking reader drains them before ending. expect(await consumer.readString()).toBe("a"); @@ -306,7 +306,7 @@ test("a frame larger than the cache is rejected rather than silently dropped", ( // Nothing was appended, so the group is still empty rather than holding a phantom frame. const consumer = producer.consume(); - producer.close(); + producer.finish(); expect(consumer.tryReadFrame()).toBeUndefined(); }); @@ -332,7 +332,7 @@ test("a group that ends on its own abandons the replacement it asked for", async await new Promise((resolve) => setTimeout(resolve, 0)); expect(signal?.aborted).toBe(false); - producer.close(); + producer.finish(); expect(await read).toBeUndefined(); expect(signal?.aborted).toBe(true); }); @@ -381,7 +381,7 @@ test.each([false, true])("a group ending beside its replacement discards it (con consumer.close(); expect(released).toBe(1); } else { - producer.close(); + producer.finish(); } expect(await read).toBeUndefined(); expect(adopted).toBe(false); @@ -409,13 +409,13 @@ test("a group recovered twice reads on and settles", async () => { await consumer.readFrame(); // Two failures in a row: each moves the group to the next copy. - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); replacements[0].writeFrame({ payload: new Uint8Array([0]) }); replacements[0].writeFrame({ payload: new Uint8Array([1]) }); expect((await consumer.readFrame())?.payload[0]).toBe(1); - replacements[0].close(new Error("route failed")); + replacements[0].abort(new Error("route failed")); for (const payload of [0, 1, 2]) replacements[1].writeFrame({ payload: new Uint8Array([payload]) }); - replacements[1].close(); + replacements[1].finish(); expect((await consumer.readFrame())?.payload[0]).toBe(2); expect(await consumer.readFrame()).toBeUndefined(); expect(await consumer.closed).toBeNull(); @@ -438,7 +438,7 @@ test("a stalled group whose copy then fails asks again with the failure", async route.set(1); const read = consumer.readFrame(); await new Promise((resolve) => setTimeout(resolve, 0)); - producer.close(new Error("route failed")); + producer.abort(new Error("route failed")); await expect(read).rejects.toBe(verdict); }); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 61edb18538a..d9e34e2c251 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -385,7 +385,7 @@ export class Producer { mirror.frames.set([]); mirror.cacheBytes = 0; } - this.close(error); + this.abort(error); throw error; } @@ -394,15 +394,27 @@ export class Producer { return this.#state.closed.peek() !== undefined; } - /** Closes the group, optionally with an error to abort readers. */ - close(abort?: Error) { + /** Marks the group complete: no more frames will be written. Idempotent. */ + finish() { + this.#end(null); + } + + /** + * Ends the group with `error`, which readers that have not drained it see. A no-op once the + * group finished or aborted: the outcome is already settled. + */ + abort(error: Error) { + this.#end(error); + } + + #end(outcome: Error | null) { if (this.#state.closed.peek() !== undefined) return; this.#state.flate?.finish(); - this.#state.closed.set(abort ?? null); + this.#state.closed.set(outcome); if (this.#mirrors) { for (const mirror of this.#mirrors) { - if (mirror.closed.peek() === undefined) mirror.closed.set(abort ?? null); + if (mirror.closed.peek() === undefined) mirror.closed.set(outcome); } this.#mirrors.clear(); } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index d125d687742..c1236f09bb4 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -799,13 +799,13 @@ test("a blocked group header is reset when the group expires", async () => { const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(10_000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); // A group beyond the edge, so group 0's reach (10s, where group 1 begins) is @@ -813,7 +813,7 @@ test("a blocked group header is reset when the group expires", async () => { // the successor alone never convicts it: nothing yet proves group 0 ends sooner. const later = new GroupProducer(2); later.writeFrame({ payload: new TextEncoder().encode("later"), timestamp: Timestamp.fromMillis(20_000) }); - later.close(); + later.finish(); track.writeGroup(later); const resetBeforeRelease = await Promise.race([ @@ -885,7 +885,7 @@ test("a group stream whose FIN never completes is reset when the group expires", const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -969,7 +969,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const write = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -1054,7 +1054,7 @@ test("a group that goes stale while its stream opens writes nothing", async () = const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2858,7 +2858,7 @@ function writeGroup(track: TrackProducer, frames: number): void { for (let i = 0; i < frames; i++) { group.writeFrame({ payload: new TextEncoder().encode(`${group.sequence}.${i}`), timestamp: Timestamp.now() }); } - group.close(); + group.finish(); } /** Send `msg` on a fresh subscribe stream and read the publisher's SUBSCRIBE_OK. */ @@ -3143,7 +3143,7 @@ test("draft-20: a fill serves the current group's head on a fetch stream", async // Everything past the snapshot belongs to the subscription, not the fill. group.writeFrame({ payload: new TextEncoder().encode("0.2"), timestamp: Timestamp.now() }); - group.close(); + group.finish(); const live = await nextUni(fx.uni); if (!live) throw new Error("the subscription never served the live tail"); @@ -3309,7 +3309,7 @@ async function subscribeOkLargest(version: IetfVersion): Promise Frame.decode(c, header.flags, undefined)); @@ -3702,7 +3702,7 @@ test("draft-20: the subscriber leaving ends a fill still reading its group", asy const served = await reading; expect(served.reset?.message).toContain("unsubscribed"); } finally { - open.close(); + open.finish(); fx.close(); client.close(); } @@ -3757,7 +3757,7 @@ test("draft-20: a fill works on a dynamically requested track", async () => { reset: undefined, }); } finally { - group.close(); + group.finish(); fx.close(); client.close(); } @@ -4172,7 +4172,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async const write = (text: string) => { const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode(text), timestamp: Timestamp.now() }); - group.close(); + group.finish(); }; const { client } = await runSubscribe( fx, diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index 6786fbd2ea5..a2e3140f685 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -150,7 +150,7 @@ for (const version of drafts) { const track = broadcast.createTrack("video"); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const listing = await fx.list(); await ok(listing, version); const { stream, publish } = await fx.offered(); @@ -499,7 +499,7 @@ for (const version of [Version.DRAFT_20, Version.DRAFT_22]) { for (let i = 0; i < 2; i++) { const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); } await ok(await fx.list(), version); const { stream } = await fx.offered(); @@ -518,7 +518,7 @@ for (const version of [Version.DRAFT_20, Version.DRAFT_22]) { await update(stream, version, 4n, true); const fresh = track.appendGroup(); fresh.writeFrame({ payload: new Uint8Array([2]) }); - fresh.close(); + fresh.finish(); const uni = fx.pair.client.incomingUnidirectionalStreams.getReader(); const next = await uni.read(); if (!next.value) throw new Error("missing filtered group"); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 62147a9ccbd..bf458da1aff 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1597,7 +1597,7 @@ export class Subscriber { // the track ends with it, and at object 0 it ends before it. if (producer && frame.idDelta !== 0) throw new ObjectIdGap(frame.idDelta); const end = { group: group.groupId, frame: nextObject + frame.idDelta }; - producer?.close(); + producer?.finish(); try { hooks.endTrack(track, end); } catch (err: unknown) { @@ -1620,7 +1620,7 @@ export class Subscriber { } // A group with no objects still exists. - open().close(); + open().finish(); } catch (err: unknown) { if (err instanceof ProtocolViolation) this.#fail(err.code, err.message); const e = await sessionCause(this.#quic, err); @@ -1647,20 +1647,20 @@ export class Subscriber { if (e instanceof ObjectIdGap) console.warn(`unsupported object gap: ${reason(e)}`); if (e instanceof EndOfTrackProperties) { // The group before the marker is complete; only the track's end can't be carried. - producer?.close(); + producer?.finish(); track.close(e); } else if ( e instanceof ProtocolViolation || (e instanceof StreamError && e.code === StreamCode.MalformedTrack) ) { // The publisher broke the track's end or its content, which no later group can repair. - producer?.close(e); + producer?.abort(e); track.close(e); } else { // A stream that fails before its first object still names a group, which the // reader sees fail rather than silently go missing. try { - open().close(e); + open().abort(e); } catch { // The track has already closed or ended below this group. } diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 16a5681dd6d..f72d19eb684 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -350,7 +350,7 @@ test("integration: lite applies initial and updated group bounds", async () => { const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const subscriber = remote @@ -401,7 +401,7 @@ test("integration: lite refuses an empty requested range on open and on update", const broadcast = publish(origin, Path.from("test")); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); - for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().close(); + for (let sequence = 0; sequence < GROUP_COUNT; sequence++) producer.appendGroup().finish(); const remote = wireOf(client).consume(Path.from("test")); const video = remote.track("video"); @@ -871,7 +871,7 @@ test("integration: a group reset carries the peer's code to the subscriber", asy expect(await consumer.readString()).toBe("frame"); // Reset the group stream the way a peer that dropped the group does. - group.close(new Reset(2)); + group.abort(new Reset(2)); // The subscriber reads the code back off a typed error rather than whichever shape the // transport produced, so nothing has to feature-detect a browser global. @@ -912,7 +912,7 @@ test("integration: a locally raised group error reaches the peer as its own code // The publisher's own cache dropped the rest of the group. Nothing hand-builds a transport // error here, which is the point: the condition is raised the way the library raises it. - group.close(new TooFarBehind()); + group.abort(new TooFarBehind()); const err = await consumer.readFrame().then( () => undefined, @@ -1006,11 +1006,11 @@ test("integration: lite draft-07 fetches a cached group", async () => { const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const group1 = producer.appendGroup(); group1.writeFrame({ payload: enc.encode("newer"), timestamp: Timestamp.fromMillis(20) }); - group1.close(); + group1.finish(); // Fetch group 0 without holding a live subscription; the timestamps round-trip. const remote = wireOf(client).consume(Path.from("test")); @@ -1045,7 +1045,7 @@ test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at % const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(1); producer.writeGroup(group); - group.close(); + group.finish(); } else { serving = (async () => { for (;;) { @@ -1105,14 +1105,14 @@ test.each(["frame", "FIN"])("integration: lite fetch waits for the publisher's f await sleep(0); expect(settled).toBe(0); if (answer === "frame") group.writeFrame(textFrame("accepted")); - else group.close(); + else group.finish(); const consumers = await Promise.all([a, b]); for (const consumer of consumers) { expect(await consumer.readString()).toBe(answer === "frame" ? "accepted" : undefined); consumer.close(); } } finally { - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1138,7 +1138,7 @@ test("integration: lite draft-05 coalesces concurrent fetches of one group", asy const group0 = producer.appendGroup(); group0.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); group0.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMillis(15) }); - group0.close(); + group0.finish(); const remote = wireOf(client).consume(Path.from("test")); const trackConsumer = remote.track("video"); @@ -1195,7 +1195,7 @@ test("integration: lite draft-07 fetches an in-progress group", async () => { expect(dec.decode(second?.payload)).toBe("beta"); expect(second?.timestamp?.asMillis()).toBe(15); - group0.close(); + group0.finish(); expect(await fetched.readFrame()).toBeUndefined(); broadcast.close(); @@ -1260,7 +1260,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const firstTrack = first.createTrack("video", { timescale: Timescale.MILLI }); const firstGroup = firstTrack.appendGroup(); firstGroup.writeFrame({ payload: enc.encode("alpha"), timestamp: Timestamp.fromMillis(10) }); - firstGroup.close(); + firstGroup.finish(); // Prime the publisher's TRACK_INFO cache against the predecessor. const remote = wireOf(client).consume(path); @@ -1270,7 +1270,7 @@ test("integration: lite draft-07 fetch uses the republished track's timescale", const secondTrack = second.createTrack("video", { timescale: Timescale.MICRO }); const secondGroup = secondTrack.appendGroup(); secondGroup.writeFrame({ payload: enc.encode("beta"), timestamp: Timestamp.fromMicros(1234) }); - secondGroup.close(); + secondGroup.finish(); // A millisecond timescale would round this to 1000us. const fetched = await remote.track("video").fetchGroup(0); @@ -1535,7 +1535,7 @@ test("integration: lite fetch teardown when the reader abandons an open group", fetched.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1648,7 +1648,7 @@ test("integration: lite coalesced fetch stays until every reader abandons the op f2.close(); await waitUntil(() => !group.demand().used.peek()); - group.close(); + group.finish(); broadcast.close(); remote.close(); client.abort(); @@ -1690,7 +1690,7 @@ test("integration: lite fetch re-armed by a late reader keeps every frame", asyn expect(`${delay}: ${more}`).toBe(`${delay}: more`); f2.close(); - group.close(); + group.finish(); } broadcast.close(); @@ -1714,7 +1714,7 @@ test("integration: lite fetch delivers every frame of a finite multi-frame group const group = video.appendGroup(); const count = 50; for (let i = 0; i < count; i++) group.writeFrame(textFrame(`f${i}`)); - group.close(); // finite + group.finish(); // finite const remote = wireOf(client).consume(Path.from("test")); const fetched = await remote.track("video").fetchGroup(group.sequence); diff --git a/js/net/src/lite/compression.test.ts b/js/net/src/lite/compression.test.ts index e24a9f0978a..3a41af7f540 100644 --- a/js/net/src/lite/compression.test.ts +++ b/js/net/src/lite/compression.test.ts @@ -45,7 +45,7 @@ for (const downstream of [ALPN_06, ALPN_07_WIP]) { const group = track.appendGroup(); const payloads = [new Uint8Array(4096).fill(7), new Uint8Array(), new Uint8Array(4096).fill(7)]; for (const payload of payloads) group.writeFrame({ payload }); - group.close(); + group.finish(); const received = await reader.recvGroup(); if (!received) throw new Error("missing group"); expect(received.sequence).toBe(0); diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 700d86adf71..26ef77d27ec 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -244,7 +244,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { fin.enable(); const last = producer.appendGroup(); last.writeFrame(textFrame("last")); - last.close(); + last.finish(); producer.close(); const closing = server.close(); @@ -285,7 +285,7 @@ async function servedRawSubscription() { const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const subscriber = await Stream.open(pair.client, { version }); await subscriber.writer.u53(StreamId.Subscribe); @@ -386,7 +386,7 @@ test("close waits for a served FETCH to be acknowledged", async () => { const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const server = new Connection({ url: new URL("https://relay.example/"), quic: pair.server, @@ -438,7 +438,7 @@ test("close waits for a request served while withdrawals are in flight", async ( const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = producer.appendGroup(); group.writeFrame(textFrame("last")); - group.close(); + group.finish(); const url = new URL("https://localhost/test"); const [client, server] = await Promise.all([ connect({ url, transport: pair.client, consume: destination }), diff --git a/js/net/src/lite/group.test.ts b/js/net/src/lite/group.test.ts index b0f36716873..cf5019c0cd6 100644 --- a/js/net/src/lite/group.test.ts +++ b/js/net/src/lite/group.test.ts @@ -58,7 +58,7 @@ test("frames split at every byte keep their payloads and timestamps", async () = const chunks = Array.from(bytes, (byte) => new Uint8Array([byte])); await readFrames(streamOf(chunks), producer, { scale: SCALE }); - producer.close(); + producer.finish(); let ts = 0; for (const { delta, payload } of frames) { @@ -74,7 +74,7 @@ test("frames without a timescale carry no timestamp prefix", async () => { const producer = new Producer(0); const consumer = producer.consume(); await readFrames(streamOf([encode([{ payload: [7] }, { payload: [8, 9] }])]), producer, { scale: 0 }); - producer.close(); + producer.finish(); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([7])); expect((await consumer.readFrame())?.payload).toEqual(new Uint8Array([8, 9])); @@ -94,6 +94,6 @@ test("stops once the group closes", async () => { // Never ends: only the close can stop the read. const stream = new Reader(new ReadableStream(), undefined, VERSION); const done = readFrames(stream, producer, { scale: SCALE }); - producer.close(); + producer.finish(); await done; }); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index d827efb477b..f76c909fbbf 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -209,7 +209,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF for (const sequence of sequences) { const group = new GroupProducer(sequence); group.writeFrame(textFrame("hello", Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain this group before the next, so arrival order is the @@ -302,7 +302,7 @@ async function groupSendOrders(options: { }); } finally { opened.releaseLock(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -394,7 +394,7 @@ test("lite draft-05: a subscribe update re-ranks a group already on the wire", a expect(stream.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -453,7 +453,7 @@ test("lite draft-05: a subscribe update during the stream open still ranks the g expect(pair.server.sendStreams.uni[0]?.sendOrder).toBe(sendOrder({ priority: 9 })); } finally { - group.close(); + group.finish(); publisher.close(); client.close(); } @@ -508,7 +508,7 @@ test("lite draft-05: many concurrent groups share one subscription listener", as expect(pair.server.sendStreams.uni.map((stream) => stream.sendOrder)).toEqual(expected); } finally { - for (const group of groups) group.close(); + for (const group of groups) group.finish(); publisher.close(); client.close(); } @@ -526,7 +526,7 @@ test("lite draft-05: the fetch response ranks the publisher's own writes", async const group = new GroupProducer(7); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_05 }); @@ -562,7 +562,7 @@ test("lite draft-06: an unchecked track's fetched frames carry their send time", const group = new GroupProducer(0); group.writeFrame(textFrame("hello", Timestamp.fromMillis(36_000_000))); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); @@ -699,7 +699,7 @@ async function servedSubscription( serve(sequence: number) { const group = new GroupProducer(sequence); for (const frame of frames) group.writeFrame(textFrame(frame, Timestamp.fromMillis(sequence))); - group.close(); + group.finish(); track.writeGroup(group); }, // Makes the next group stream withhold its FIN from the client, the way a stream the @@ -812,7 +812,7 @@ test("lite draft-06: a cut group is dropped early, once", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); @@ -838,7 +838,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new StreamError(StreamCode(70), { message: "cut" })); + cut.abort(new StreamError(StreamCode(70), { message: "cut" })); const resp = await decodeSubscribeResponse(sub.client.reader, version); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end, resp.drop.error]).toEqual([ @@ -946,7 +946,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { const cut = new GroupProducer(1); sub.track.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); const resp = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); if (!("drop" in resp)) throw new Error("expected SUBSCRIBE_DROP"); expect([resp.drop.start, resp.drop.end]).toEqual([1, 1]); @@ -1159,7 +1159,8 @@ for (const skipped of ["failed", "stale"] as const) { const group = new GroupProducer(5); group.writeFrame(textFrame("old", Timestamp.fromMillis(5))); sub.track.writeGroup(group); - group.close(skipped === "failed" ? new Error("cut") : undefined); + if (skipped === "failed") group.abort(new Error("cut")); + else group.finish(); if (skipped === "failed") sub.track.dropGroups(7, 8, new Error("cut")); sub.serve(6); const response = await decodeSubscribeResponse(sub.client.reader, Version.DRAFT_06); @@ -1619,7 +1620,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy try { const first = new GroupProducer(0); first.writeFrame(textFrame("hello")); - first.close(); + first.finish(); track.writeGroup(first); track.finishAt({ group: 2, frame: 0 }); @@ -1631,7 +1632,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy const second = new GroupProducer(1); second.writeFrame(textFrame("hello")); - second.close(); + second.finish(); track.writeGroup(second); track.close(); @@ -1673,7 +1674,7 @@ async function heldOpenEnd() { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1755,7 +1756,7 @@ test("lite draft-07: a group whose close fails is dropped", async () => { try { const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); track.close(); @@ -1805,7 +1806,7 @@ test("blocked DROP responses stop opening later groups", async () => { const append = (sequence: number) => { const group = new GroupProducer(sequence); group.writeFrame(textFrame("frame")); - group.close(); + group.finish(); track.writeGroup(group); }; for (let sequence = 0; sequence < 8; sequence++) append(sequence); @@ -1881,7 +1882,7 @@ async function serve( for (const [sequence, frames] of Object.entries(groups)) { const group = new GroupProducer(Number(sequence)); for (const frame of frames) group.writeFrame(textFrame(frame)); - group.close(); + group.finish(); track.writeGroup(group); // Let the publisher drain each group before the next, so the streams arrive in @@ -2061,7 +2062,7 @@ async function saturatedGroup() { // A finished group still has frames to send, so its own close must not drop it. const group = new GroupProducer(0); group.writeFrame(textFrame("hello")); - group.close(); + group.finish(); track.writeGroup(group); // The group is queued inside the open from here on. @@ -2142,13 +2143,13 @@ test("lite draft-05: a blocked group header is reset when the group expires", as const old = new GroupProducer(0); old.writeFrame({ payload: new TextEncoder().encode("old"), timestamp: Timestamp.fromMillis(0) }); - old.close(); + old.finish(); track.writeGroup(old); await headerStarted; const edge = new GroupProducer(1); edge.writeFrame({ payload: new TextEncoder().encode("edge"), timestamp: Timestamp.fromMillis(1000) }); - edge.close(); + edge.finish(); track.writeGroup(edge); const resetBeforeRelease = await Promise.race([ @@ -2285,7 +2286,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } @@ -2296,7 +2297,7 @@ test("lite draft-05: a group parked at its tail resets as too old when it expire expect(end).toMatchObject({ code: StreamCode.Old }); } finally { opened.releaseLock(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2356,7 +2357,7 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi const write = (sequence: number, ms: number) => { const group = new GroupProducer(sequence); group.writeFrame({ payload: new TextEncoder().encode("frame"), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); track.writeGroup(group); }; write(0, 0); @@ -2657,7 +2658,7 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () expect(unauthorizedResets(resets)).toContain("unauthorized: live"); } finally { resets.mockRestore(); - group.close(); + group.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2697,7 +2698,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a for (const ms of [1000, 2000]) { const group = new GroupProducer(ms / 1000); group.writeFrame(frame(ms)); - group.close(); + group.finish(); track.writeGroup(group); } await serving; @@ -2705,7 +2706,7 @@ test("lite draft-07: a fetch whose budget gives up partway resets as too old", a expect(resets.mock.calls.map(([reason]) => toStreamCode(reason as Error))).toContain(StreamCode.Old); } finally { resets.mockRestore(); - open.close(); + open.finish(); publisher.close(); client.close(); broadcast.close(); @@ -2722,7 +2723,7 @@ test.each([0, 1])("lite draft-07 reports the cached largest position when starti const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const group = new GroupProducer(0); group.writeFrame(textFrame("cached")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); @@ -2767,7 +2768,7 @@ test("lite draft-07 reports the source's largest position past what a relay hold hooks.reportedLargest(track, { group: 4, frame: 0 }); const group = new GroupProducer(0); group.writeFrame(textFrame("oldest")); - group.close(); + group.finish(); track.writeGroup(group); const client = await Stream.open(pair.client, { version }); const server = await Stream.accept(pair.server, version); diff --git a/js/net/src/lite/refresh.test.ts b/js/net/src/lite/refresh.test.ts index 64091a24c69..60ae9bcd80b 100644 --- a/js/net/src/lite/refresh.test.ts +++ b/js/net/src/lite/refresh.test.ts @@ -73,7 +73,7 @@ test.each([ALPN_06, ALPN_07_WIP])("refresh joins reach a browser publisher on %s for (const join of joins) join.close(); received.close(); baseline.close(); - group.close(); + group.finish(); } finally { spy.mockRestore(); for (const origin of [b, source]) origin.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index c7a1cb8e1e7..9f7c1a02beb 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1247,7 +1247,7 @@ export class Subscriber { // Arm after admission, before checking: another unanswered token may still // widen the union while we wait, which is not a revocation of this fetch. const disposeGrant = this.#grant?.subscribe(() => { - if (this.#denied(broadcast)) group.close(refused); + if (this.#denied(broadcast)) group.abort(refused); }); void group.closed.then(() => disposeGrant?.()); if (this.#denied(broadcast)) throw refused; @@ -1274,7 +1274,7 @@ export class Subscriber { hooks.bindGroupCompression(group, accepted.info.compress); void this.#runFetchResponse(accepted.stream, group, accepted.info); } catch (err: unknown) { - group.close(error(err)); + group.abort(error(err)); throw err; } } @@ -1375,11 +1375,11 @@ export class Subscriber { hooks.writeGroupFrame(group, frame, frame.inflated); } - group.close(); + group.finish(); stream.close(); } catch (err: unknown) { const e = error(err); - group.close(e); + group.abort(e); stream.abort(e); } } @@ -1616,7 +1616,7 @@ export class Subscriber { while (scale === undefined) { if (track.closed.peek() !== undefined) { // Subscription ended before the scale resolved; nothing to decode. - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); return; } @@ -1626,17 +1626,17 @@ export class Subscriber { await readFrames(stream, producer, { scale, timed: hasMediaTime(this.version), compress: entry.compress }); - producer.close(); + producer.finish(); stream.stop(StreamCode.Cancel); } catch (err: unknown) { // A violation ends the session, which the connection does once it escapes; anything // else ends only this group. if (err instanceof ProtocolViolation) { - producer.close(err); + producer.abort(err); throw err; } const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); stream.stop(e); } finally { read(); @@ -1807,7 +1807,7 @@ export class Subscriber { // This also releases callers still awaiting acceptance. for (const { group } of this.#fetches.values()) { - group.close(cut); + group.abort(cut); } } } @@ -1841,7 +1841,7 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr // Close here rather than where the error lands, so no fetch coalesces onto the group // in between only to fail with it. const err = new StreamError(StreamCode.Cancel, { message: "cancel" }); - group.close(err); + group.abort(err); throw err; } } diff --git a/js/net/src/order.test.ts b/js/net/src/order.test.ts index b29247db139..df249ce4fb0 100644 --- a/js/net/src/order.test.ts +++ b/js/net/src/order.test.ts @@ -105,7 +105,7 @@ async function backlog(protocol: string, order: Order, update?: Order) { const result = { upstream: video.subscription.peek()?.order, orders: sendOrders(transport) }; - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -153,7 +153,7 @@ test.each(["ascending", "descending"] as const)("ietf serves GROUP_ORDER %s", as } expect(sendOrders(transport)).toEqual(expected(order, 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -217,12 +217,12 @@ test("ietf keeps a closing group stream ranked until it settles", async () => { } // The newest group, ranked first, finishes while its FIN is held. - groups[2]?.close(); + groups[2]?.finish(); await fins.closing; expect(sendOrders(transport)).toEqual(expected("descending", 3)); fins.release(); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); subscriber.close(); remote.close(); broadcast.close(); @@ -264,7 +264,7 @@ test("a shared track aggregates descending and serves each subscriber in its own expect(sendOrders(ascending.transport)).toEqual(expected("ascending", 3)); expect(sendOrders(descending.transport)).toEqual(expected("descending", 3)); - for (const group of groups) group.close(); + for (const group of groups) group.finish(); ascendingTrack.close(); descendingTrack.close(); ascendingRemote.close(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 6717f94274b..7f157c7520d 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -2417,7 +2417,7 @@ test.each([ expect(resolved?.epoch).toBe(from); const sticky = resolved?.track("video").subscribe(); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(0); // A re-price at the same epoch keeps every handle, and the handler is not asked again. @@ -2439,7 +2439,7 @@ test.each([ expect(pinned.active.peek()?.epoch).toBe(from); // The subscription already open stays on the old answer. - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await sticky?.recvGroup())?.sequence).toBe(1); // A re-request, local or from a session, asks the handler again and never reads the @@ -2460,7 +2460,7 @@ test.each([ // Existing request handles can still subscribe, but never join the new answer. const retained = held.active.peek()?.track("video").subscribe(); expect((await retained?.recvGroup())?.sequence).toBe(1); - oldTrack.appendGroup().close(); + oldTrack.appendGroup().finish(); expect((await retained?.recvGroup())?.sequence).toBe(2); expect(held.active.peek()).toBe(resolved); diff --git a/js/net/src/parked-expiry.test.ts b/js/net/src/parked-expiry.test.ts index a30b7862e54..707df50e215 100644 --- a/js/net/src/parked-expiry.test.ts +++ b/js/net/src/parked-expiry.test.ts @@ -37,7 +37,7 @@ for (const protocol of protocols) { const write = (sequence: number) => { const group = new Group(sequence); group.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); track.writeGroup(group); }; @@ -162,7 +162,7 @@ for (const protocol of protocols) { for (let frame = 0; frame < frames; frame++) { group.writeFrame({ payload: new Uint8Array([frame]), timestamp: Timestamp.fromMillis(0) }); } - group.close(); + group.finish(); track.writeGroup(group); }; diff --git a/js/net/src/resume.test-util.ts b/js/net/src/resume.test-util.ts index 01bca3d581a..fd3a1a15fd1 100644 --- a/js/net/src/resume.test-util.ts +++ b/js/net/src/resume.test-util.ts @@ -9,7 +9,7 @@ export function readsFrom(active: BroadcastConsumer | undefined, source: Broadca const track = source.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const sub = active.track(name).subscribe(); try { return sub.tryRecvGroup() !== undefined; diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 5270b05224b..5dac619b351 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -55,7 +55,7 @@ function copy(source: BroadcastProducer) { producer.writeGroup(group); for (let frame = 0; frame < frames; frame++) group.writeFrame({ payload: enc.encode(`${sequence}.${frame}`) }); - if (close) group.close(); + if (close) group.finish(); return group; }, }; @@ -137,7 +137,7 @@ test("returning to a route reuses its subscription and held group", async () => held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -176,7 +176,7 @@ test("a returning route resumes where a fresh copy would", async () => { held.close(); } finally { mirrored.mockRestore(); - open.close(); + open.finish(); sub.close(); front.consumer.close(); for (const handle of handles) handle.close(); @@ -228,7 +228,7 @@ test("a fork continues its open group across a route failure after its parent cl const next = group.readFrame(); tb.write(0, 3); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await next)?.payload)).toBe("0.1"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.2"); expect(await group.readFrame()).toBeUndefined(); @@ -357,7 +357,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on if (lagging && group.sequence === 1 && index === 2) await release.promise; copy.writeFrame(frame); } - copy.close(); + copy.finish(); } finally { group.close(); } @@ -397,7 +397,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on sub = active.track("seq").subscribe({ maxDelay: Milli(60_000) }); const first = source.appendGroup(); for (let frame = 0; frame < 4; frame++) first.writeFrame({ payload: enc.encode(`0.${frame}`) }); - first.close(); + first.finish(); const seen: string[] = []; const initial = await sub.recvGroup(); if (!initial) throw new Error("expected group 0"); @@ -413,10 +413,10 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on expect(interrupted.sequence).toBe(1); for (let frame = 0; frame < 2; frame++) seen.push(dec.decode((await interrupted.readFrame())?.payload)); for (let frame = 2; frame < 4; frame++) open.writeFrame({ payload: enc.encode(`1.${frame}`) }); - open.close(); + open.finish(); const last = source.appendGroup(); for (let frame = 0; frame < 4; frame++) last.writeFrame({ payload: enc.encode(`2.${frame}`) }); - last.close(); + last.finish(); // The standby holds the complete original while the incumbent cannot pass 1.1. for (let sequence = 0; sequence < 3; sequence++) { @@ -528,7 +528,7 @@ test("a group whose route fails continues from the replacement", async () => { tb.write(0, 2); await swap(); // The old route's session goes, failing what it was still delivering. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -550,7 +550,7 @@ test("a group no route continues is given up once a newer one supersedes it", as // The new route never had group 0, and its group 1 is past a zero budget's reach for it. tb.write(1); await swap(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toBeInstanceOf(StreamError); expect(drain(sub)).toEqual([1]); @@ -617,10 +617,10 @@ test("a fetched group continued from an empty, already-closed copy releases that const read = group.readFrame(); const whole = new GroupProducer(5); b.createTrack("fetch").writeGroup(whole); - whole.close(); + whole.finish(); front.serve(b.consume()); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(await read).toBeUndefined(); group.close(); await settle(); @@ -660,7 +660,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn failing.writeFrame({ payload: enc.encode("5.0") }); const fetching = front.consumer.track("fetch").fetchGroup(5); for (let turn = 0; turn < turns; turn++) await Promise.resolve(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); await settle(); // The next route holds the fetched group whole; the old one still feeds the reader a @@ -670,7 +670,7 @@ test("a fetched group failing as it is handed out releases its route once", asyn fb.writeGroup(whole); whole.writeFrame({ payload: enc.encode("5.0") }); whole.writeFrame({ payload: enc.encode("5.1") }); - whole.close(); + whole.finish(); b.createTrack("seq"); front.serve(b.consume()); const group = await fetching; @@ -879,7 +879,7 @@ test("a fetched group continues from the next route once its own fails", async ( tb.write(5, 2); front.serve(b.consume()); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("5.1"); expect(await group.readFrame()).toBeUndefined(); @@ -978,7 +978,7 @@ test("a recovery fetch a later route supersedes is abandoned", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const fetching = tb.producer.demand().wanted.peek() !== undefined; @@ -1013,7 +1013,7 @@ for (const fails of [true, false]) { const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); - if (fails) stalled.close(new StreamError(StreamCode.SessionClosed)); + if (fails) stalled.abort(new StreamError(StreamCode.SessionClosed)); front.serve(b.consume()); const read = group.readFrame(); @@ -1106,7 +1106,7 @@ test("a failed group on a route whose replacement was refused ends with the refu front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); await expect(group.readFrame()).rejects.toThrow("changed its properties"); sub.close(); @@ -1152,7 +1152,7 @@ test("a failed group whose replacement cannot fetch ends with the failure, or co front.serve(Unfetchable.of(b.consume())); await settle(); const failure = new StreamError(StreamCode.Cancel); - stalled.close(failure); + stalled.abort(failure); if (held) { expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1217,7 +1217,7 @@ test("a fetch never reads a replacement refused for its track properties", async const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); await front.consumer.track("seq").info(); front.serve(b.consume()); @@ -1240,7 +1240,7 @@ test("the first fetch of a track says what the track is for later routes", async const group = new GroupProducer(0); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${priority}`) }); - group.close(); + group.finish(); } front.serve(a.consume()); (await front.consumer.track("seq").fetchGroup(0)).close(); @@ -1414,7 +1414,7 @@ test.each([ // Both routes hold group 1: the old one whole, the serving one failing after a frame. ta.write(1, 2); const failing = tb.write(1, 1, false); - const fail = () => failing.close(new StreamError(StreamCode.SessionClosed)); + const fail = () => failing.abort(new StreamError(StreamCode.SessionClosed)); if (early) fail(); const group = await next(); if (group?.sequence !== 1) throw new Error("expected group 1"); @@ -1464,7 +1464,7 @@ test.each([true, false])( ta.producer.close(new StreamError(StreamCode.SessionClosed)); const later = next(); await settle(); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1491,7 +1491,7 @@ test("a fetched group that fails on the route still serving waits for the next r expect(dec.decode((await group.readFrame())?.payload)).toBe("5.0"); // Its route stays current, so its only copy of group 5 is the failed one. - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); // Timers still run: recovery does not spin on the failed copy. await settle(); @@ -1517,7 +1517,7 @@ test("a group handed out already failed with its route still continues on the ne const sub = front.consumer.track("seq").subscribe(); // The old route fails group 0 before the reader takes it. const failed = ta.write(0, 1, false); - failed.close(new StreamError(StreamCode.SessionClosed)); + failed.abort(new StreamError(StreamCode.SessionClosed)); const group = sub.tryRecvGroup(); if (!group) throw new Error("expected group 0"); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.0"); @@ -1639,7 +1639,7 @@ test("a fetch waits for the copy being read to say what the track is", async () const group = new GroupProducer(0); tb.writeGroup(group); group.writeFrame({ payload: enc.encode("b") }); - group.close(); + group.finish(); front.serve(a.consume()); const sub = front.consumer.track("seq").subscribe(); front.serve(b.consume()); @@ -1934,7 +1934,7 @@ test.each([true, false])( // The old route finishes group 1 only now, and the new route's copy of it fails. ta.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -1979,7 +1979,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival // b's first group lands only now, and c's copy of it fails. tb.write(1, 2); - failing.close(new StreamError(StreamCode.SessionClosed)); + failing.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); @@ -2285,7 +2285,7 @@ test("closing with an abort settles a recovery in flight with it", async () => { // The next route does not have the group yet: recovery fetches it there and waits. front.serve(b.consume()); await settle(); - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); const read = group.readFrame(); await settle(); const abort = new Error("aborted"); @@ -2391,7 +2391,7 @@ test("a clean end keeps the serving copy while a group read from a replaced one expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("1.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2463,7 +2463,7 @@ test("a clean end keeps the serving copy while a group handed out before the swa expect(sub.closed.peek()).toBeNull(); // The old route fails: the group continues from the copy that ended. - stalled.close(new StreamError(StreamCode.SessionClosed)); + stalled.abort(new StreamError(StreamCode.SessionClosed)); expect(dec.decode((await group.readFrame())?.payload)).toBe("0.1"); expect(await group.readFrame()).toBeUndefined(); group.close(); @@ -2681,7 +2681,7 @@ for (const fetched of [false, true]) { if (!hostile) return undefined; const copy = new GroupProducer(sequence); copy.writeFrame({ payload: enc.encode(`${sequence}.0`) }); - setTimeout(() => copy.close(malformed), 0); + setTimeout(() => copy.abort(malformed), 0); return copy.consume(); }); const sub = front.consumer.track("seq").subscribe(); @@ -2692,7 +2692,7 @@ for (const fetched of [false, true]) { asked.length = 0; hostile = true; - stalled.close(malformed); + stalled.abort(malformed); await expect(group.readFrame()).rejects.toBe(malformed); await settle(); expect(asked).toEqual([]); @@ -2720,10 +2720,10 @@ test("a verdict every route repeats moves to the newer route once, then ends the front.serve(routeB.route); await settle(); - stalled.close(malformed); + stalled.abort(malformed); const read = group.readFrame(); expect(await isPending(read)).toBe(true); - repeat.close(malformed); + repeat.abort(malformed); await expect(read).rejects.toBe(malformed); await expect(group.readFrame()).rejects.toBe(malformed); expect([routeA.asked, routeB.asked]).toEqual([[], []]); diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts index 30b0d771a6c..fbedb2f6296 100644 --- a/js/net/src/retention.test.ts +++ b/js/net/src/retention.test.ts @@ -140,7 +140,7 @@ for (const [name, alpn] of [ for (let i = 0; i < count; i++) { const group = producer.appendGroup(); group.writeFrame(frame(offset + i)); - group.close(); + group.finish(); const consumer = await track.nextGroup(); if (!consumer) throw new Error("no group"); await readFrames(consumer, 1); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 98712920dd3..24a365b6f46 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -106,7 +106,7 @@ function refreshGroup(producer: TrackProducer, delta: number): void { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("key") }); if (delta > 0) group.writeFrame({ payload: new Uint8Array(delta) }); - group.close(); + group.finish(); } test("the first subscriber of a track with no group wants group 0", () => { @@ -602,7 +602,7 @@ test("a long group is not stale while its tail reaches the edge", async () => { for (const ms of [0, 500, 1000, 1500, 2000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); expect((await sub.recvGroup())?.sequence).toBe(0); @@ -617,7 +617,7 @@ test("a long group is stale once its successor falls behind", async () => { for (const ms of [0, 500, 1000]) { long.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } - long.close(); + long.finish(); // Group 0 reaches at most 3s (where group 1 starts), a second behind the 4s edge and // so past the budget. A frame's duration is not on the wire, so group 0's own last // timestamp proves nothing about where it ends; only group 1's start bounds it. @@ -693,7 +693,7 @@ test("a late-arriving successor bounds a group's reach", async () => { ]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -732,17 +732,17 @@ for (const take of ["nextGroup", "tryNextGroup"] as const) { const zero = new GroupProducer(0); zero.writeFrame(textFrame("0.0")); zero.writeFrame(textFrame("0.1")); - zero.close(); + zero.finish(); producer.writeGroup(zero); const one = new GroupProducer(1); one.writeFrame(textFrame("1.0")); - one.close(); + one.finish(); producer.writeGroup(one); const two = new GroupProducer(2); two.writeFrame(textFrame("2.0")); - two.close(); + two.finish(); producer.writeGroup(two); // The frame path enters group 0; a direct take then claims group 1. @@ -764,18 +764,18 @@ test("ordered frame reads skip a late lower-sequence group", async () => { const five = new GroupProducer(5); five.writeFrame(textFrame("five", Timestamp.fromMillis(5))); - five.close(); + five.finish(); producer.writeGroup(five); expect(await track.readString()).toBe("five"); const three = new GroupProducer(3); three.writeFrame(textFrame("three", Timestamp.fromMillis(3))); - three.close(); + three.finish(); producer.writeGroup(three); const six = new GroupProducer(6); six.writeFrame(textFrame("six", Timestamp.fromMillis(6))); - six.close(); + six.finish(); producer.writeGroup(six); expect(await track.readString()).toBe("six"); @@ -819,7 +819,7 @@ test("a late lower group within the budget is delivered", async () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${timestamp}`), timestamp: Timestamp.fromMillis(timestamp) }); - group.close(); + group.finish(); } const track = producer.subscribe({ maxDelay: Milli(5000) }); @@ -833,7 +833,7 @@ test("a late lower group within the budget is delivered", async () => { const late = new GroupProducer(4); producer.writeGroup(late); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(500) }); - late.close(); + late.finish(); expect((await track.recvGroup())?.sequence).toBe(4); producer.close(); @@ -957,7 +957,7 @@ test("real time reads a live stream without truncating it", async () => { const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("key"), timestamp: Timestamp.fromMillis(n * 2000) }); await settle(); - open.close(); + open.finish(); next.writeFrame({ payload: enc.encode("tail"), timestamp: Timestamp.fromMillis(n * 2000 + 1900) }); expect(await end).toMatchObject({ code: StreamCode.Old }); @@ -1094,7 +1094,7 @@ test("a fully read group still reports its expiry", async () => { const track = producer.subscribe({ maxDelay: Milli(1000) }); const source = producer.appendGroup(); source.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.fromMillis(0) }); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1191,7 +1191,7 @@ test("a group shared by two tracks ages each from its own arrival", () => { first.writeFrame({ payload: enc.encode("0") }); const shared = new GroupProducer(1); shared.writeFrame({ payload: enc.encode("1") }); - shared.close(); + shared.finish(); first.writeGroup(shared); clock.set(11_000); second.writeGroup(shared); @@ -1271,7 +1271,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn const write = () => { const group = producer.appendGroup(); group.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); }; write(); const group = track.tryRecvGroup(); @@ -1311,7 +1311,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy const track = producer.subscribe({ maxDelay: Milli(budget) }); const held = producer.appendGroup(); held.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); - held.close(); + held.finish(); const group = track.tryRecvGroup(); if (!group) throw new Error("missing group"); expect(await group.readString()).toBe("x"); @@ -1327,10 +1327,10 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy // Past the cache window, so the next write evicts the failed successor at once. const replacement = 10_000 + CACHE_WINDOW_MS + 10_000; await clock.advance(replacement); - failed.close(new Error("upstream failed")); + failed.abort(new Error("upstream failed")); const live = producer.appendGroup(); live.writeFrame({ payload: enc.encode("y"), timestamp: Timestamp.fromMillis(0) }); - live.close(); + live.finish(); await clock.advance(10_000 + budget + 1); expect(stale).toBe(false); @@ -1357,7 +1357,7 @@ test("a run of empty groups ages out by wall clock", () => { producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(0) }); for (let sequence = 1; sequence <= RUN; sequence++) { const group = producer.appendGroup(); - if (sequence % 2 === 0) group.close(); + if (sequence % 2 === 0) group.finish(); } producer.writeFrame({ payload: enc.encode("x"), timestamp: Timestamp.fromMillis(10) }); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toHaveLength(RUN + 2); @@ -1379,7 +1379,7 @@ test("a live track retains its newest closed group through idle expiry", async ( for (const sequence of [2, 1]) { const group = new GroupProducer(sequence); group.writeFrame({ payload: enc.encode("snapshot"), timestamp: Timestamp.fromMillis(0) }); - group.close(); + group.finish(); producer.writeGroup(group); } await clock.advance(10_000 + CACHE_WINDOW_MS + 200); @@ -1405,7 +1405,7 @@ test("retention eviction surfaces as a gap for a handed-out group", async () => const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1428,7 +1428,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { const source = producer.appendGroup(); source.writeFrame(textFrame("first")); source.writeFrame(textFrame("tail")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1549,7 +1549,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { const track = producer.subscribe({ maxDelay: Milli(100) }); const source = producer.appendGroup(); source.writeFrame(textFrame("only")); - source.close(); + source.finish(); const group = await track.recvGroup(); if (!group) throw new Error("missing group"); @@ -1606,13 +1606,13 @@ test("latency budget retains a consumed live-edge anchor", async () => { const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(); + edge.finish(); producer.writeGroup(edge); expect((await track.recvGroup())?.sequence).toBe(2); const late = new GroupProducer(1); late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); - late.close(); + late.finish(); producer.writeGroup(late); producer.close(); @@ -1625,12 +1625,12 @@ test("an aborted live-edge anchor does not make older content stale", async () = const edge = new GroupProducer(2); edge.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(2000) }); - edge.close(new Error("aborted")); + edge.abort(new Error("aborted")); producer.writeGroup(edge); const older = new GroupProducer(1); older.writeFrame({ payload: enc.encode("older"), timestamp: Timestamp.fromMillis(0) }); - older.close(); + older.finish(); producer.writeGroup(older); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1904,7 +1904,7 @@ test("writeGroup rejects a duplicate live sequence", async () => { const first = new GroupProducer(7); first.writeFrame(textFrame("first")); - first.close(); + first.finish(); producer.writeGroup(first); // A peer re-sending a live sequence is rejected, not fanned out again. @@ -1914,11 +1914,11 @@ test("writeGroup rejects a duplicate live sequence", async () => { // An aborted incarnation is replaceable: the retry serves the sequence fresh. const aborted = new GroupProducer(8); producer.writeGroup(aborted); - aborted.close(new Error("upstream reset")); + aborted.abort(new Error("upstream reset")); const retry = new GroupProducer(8); retry.writeFrame(textFrame("retry")); - retry.close(); + retry.finish(); producer.writeGroup(retry); const got = await track.recvGroup(); @@ -2032,7 +2032,7 @@ test("a closed track still delivers a group parked above the cap once the cap is for (let sequence = 0; sequence < 3; sequence++) { const group = new GroupProducer(sequence); group.writeFrame(textFrame(`frame-${sequence}`)); - group.close(); + group.finish(); producer.writeGroup(group); } @@ -2091,13 +2091,13 @@ test("readFrame does not livelock when a sole group finishes before the next arr // readable() resolves immediately, so the reader must not busy-wait on it (which would starve the // macrotask queue and never observe the next group). const g0 = producer.appendGroup(); - g0.close(); + g0.finish(); // The next group arrives via a macrotask; if the reader livelocks on microtasks it never runs. setTimeout(() => { const g1 = producer.appendGroup(); g1.writeFrame(textFrame("hello")); - g1.close(); + g1.finish(); producer.close(); }, 10); @@ -2122,7 +2122,7 @@ test("largest names the newest frame written", async () => { // The producer's getter holds no cursor, so every caller reads the same edge. expect(producer.largest()).toEqual({ group: 0, frame: 1 }); expect(producer.largest()).toEqual({ group: 0, frame: 1 }); - first.close(); + first.finish(); // A new group raises the largest at its head before any frame, so its first frame names // nothing newer. @@ -2139,7 +2139,7 @@ test("largest names the newest frame written", async () => { expect(advanced).toBe(false); second.writeFrame({ payload: enc.encode("d"), timestamp: Timestamp.now() }); expect(await next).toEqual({ group: 1, frame: 1 }); - second.close(); + second.finish(); // Reading a group does not move the edge: it is what the track produced, not what is left. await subscriber.recvGroup(); @@ -2163,7 +2163,7 @@ test("largest ends at the declared end while the producer stays open", async () const ended = subscriber.largest(); expect(await Promise.race([ended.then(() => "ready"), Promise.resolve("pending")])).toBe("pending"); - group.close(); + group.finish(); expect(await ended).toBeUndefined(); producer.close(); }); @@ -2177,10 +2177,10 @@ test("a late group below the largest changes nothing", async () => { const framed = new GroupProducer(2); producer.writeGroup(framed); framed.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - framed.close(); + framed.finish(); const empty = new GroupProducer(5); producer.writeGroup(empty); - empty.close(); + empty.finish(); producer.finishAt({ group: 6, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 5, frame: 0 }); @@ -2205,7 +2205,7 @@ test("a recreated largest group is reported before the end", async () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("a"), timestamp }); - first.close(new Error("cut")); + first.abort(new Error("cut")); producer.finishAt({ group: 1, frame: 0 }); expect(await subscriber.largest()).toEqual({ group: 0, frame: 0 }); @@ -2221,7 +2221,7 @@ test("a recreated largest group is reported before the end", async () => { producer.writeGroup(again); for (const payload of ["a", "b"]) again.writeFrame({ payload: enc.encode(payload), timestamp }); expect(await next).toEqual({ group: 0, frame: 1 }); - again.close(); + again.finish(); expect(await subscriber.largest()).toBeUndefined(); producer.close(); }); @@ -2229,7 +2229,7 @@ test("a recreated largest group is reported before the end", async () => { test("a datagram raises the largest", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); - producer.appendGroup().close(); + producer.appendGroup().finish(); expect(producer.appendDatagram(Timestamp.now(), enc.encode("d"))).toBe(1); expect(await subscriber.largest()).toEqual({ group: 1, frame: 0 }); producer.close(); @@ -2312,7 +2312,7 @@ test("largest follows the bounds as they move", async () => { // it back. test("largest stays ended when the bounds widen", async () => { const producer = new TrackProducer("test").accept({}); - framedGroup(producer, 5, 1).close(); + framedGroup(producer, 5, 1).finish(); producer.close(); const reader = producer.subscribe().withRange({ end: { excluded: 3 } }); @@ -2327,7 +2327,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const first = producer.appendGroup(); first.writeFrame({ payload: enc.encode("a"), timestamp: Timestamp.now() }); - first.close(); + first.finish(); const empty = producer.appendGroup(); const reader = producer.subscribe({ maxDelay: Milli(30_000) }); @@ -2335,7 +2335,7 @@ test("a live-edge reader starts on an empty largest group", async () => { expect(edge).toEqual({ group: 1, frame: 0 }); reader.withRange({ start: { included: edge?.group ?? 0 } }); - empty.close(); + empty.finish(); const next = producer.appendGroup(); next.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); @@ -2405,7 +2405,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { const cut = new GroupProducer(0); producer.writeGroup(cut); const error = new StreamError(StreamCode(70), { message: "cut" }); - cut.close(error); + cut.abort(error); producer.writeGroup(new GroupProducer(1)); expect(track.tryRecvGroup()?.sequence).toBe(1); @@ -2483,10 +2483,10 @@ test("finishAt declares an end ahead of the live edge without ending the track", // Not terminal: groups below the end still arrive, including a straggler. const late = new GroupProducer(7); - late.close(); + late.finish(); producer.writeGroup(late); const straggler = new GroupProducer(6); - straggler.close(); + straggler.finish(); producer.writeGroup(straggler); expect((await track.recvGroup())?.sequence).toBe(6); expect((await track.recvGroup())?.sequence).toBe(7); @@ -2550,7 +2550,7 @@ for (const channel of ["group", "datagram"]) { const datagramRead = track.recvDatagram(); if (channel === "group") { const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); } else { producer.insertDatagram(2, Timestamp.fromMillis(0), enc.encode("tail")); @@ -2581,7 +2581,7 @@ test("finished rejects when the track aborts or closes without an end", async () // A clean close is an end, so it resolves. const clean = new TrackProducer("test"); const reader = clean.subscribe(); - clean.appendGroup().close(); + clean.appendGroup().finish(); clean.close(); expect(await reader.finished()).toEqual({ group: 1, frame: 0 }); }); @@ -2594,7 +2594,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { try { const source = producer.appendGroup(); source.writeFrame(textFrame("old")); - source.close(); + source.finish(); clock.set(10_000 + CACHE_WINDOW_MS - 1_000); producer.appendGroup(); const within = producer.subscribe({ maxDelay: Milli(100_000) }); @@ -2619,10 +2619,10 @@ test("a live track does not pin an aborted newest group", () => { try { const good = producer.appendGroup(); good.writeFrame(textFrame("snapshot")); - good.close(); + good.finish(); const reset = producer.appendGroup(); reset.writeFrame(textFrame("partial")); - reset.close(new Error("reset")); + reset.abort(new Error("reset")); clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); const late = producer.subscribe({ maxDelay: Milli(100_000) }); @@ -2641,7 +2641,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn const subscriber = producer.subscribe({ maxDelay: Milli(100_000) }); const snapshot = producer.appendGroup(); snapshot.writeFrame(textFrame("snapshot")); - snapshot.close(); + snapshot.finish(); // Idle well past the window, but protected while the track is live. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); @@ -2665,7 +2665,7 @@ test("a late subscriber to a closed unlimited track is released once the cache a const producer = new TrackProducer("unlimited").accept({ timescale: Timescale.MILLI }); const source = producer.appendGroup(); source.writeFrame(textFrame("last")); - source.close(); + source.finish(); producer.close(); // Counts the map entries subscribing leaves behind, rather than the heap: any entry @@ -2704,7 +2704,7 @@ test("an abort keeps finished groups for a slow reader, then reports it", async for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.appendGroup().writeFrame(textFrame("open")); const boom = new Error("boom"); @@ -2728,7 +2728,7 @@ test("an abort after the declared end settles ends clean", async () => { for (let i = 0; i < 2; i++) { const group = producer.appendGroup(); group.writeFrame(textFrame(`g${i}`)); - group.close(); + group.finish(); } producer.finishAt({ group: 2, frame: 0 }); producer.close(new Error("boom")); @@ -2756,7 +2756,7 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const stale = producer.subscribe({ maxDelay: Milli(30) }); const group = producer.appendGroup(); group.writeFrame(textFrame("x")); - group.close(); + group.finish(); producer.close(abort); // Nothing writes after close; the scheduled cleanup can lag by one scan slice. @@ -2826,7 +2826,7 @@ test("a frame whose timedness disagrees with its track is refused", async () => TimestampMismatch, ); other.writeFrame({ payload: enc.encode("y") }); - other.close(); + other.finish(); // The untimed frame reaches the subscriber as it was written: no time filled in. const frame = await (await subscriber.recvGroup())?.readFrame(); @@ -2969,7 +2969,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () const group = new GroupProducer(4); producer.writeGroup(group); const cut = new Error("cut"); - group.close(cut); + group.abort(cut); await settle(); expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 4, end: 5, error: cut }); @@ -2990,7 +2990,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () for (const [sequence, hole] of holes.entries()) { const group = new GroupProducer(sequence); producer.writeGroup(group); - group.close(hole); + group.abort(hole); } await settle(); for (const sequence of holes.keys()) expect(hooks.givenUp(reader, sequence).error).toBeUndefined(); @@ -3008,7 +3008,7 @@ test("an upstream drop ends a group still arriving with its error", async () => const finished = new GroupProducer(4); producer.writeGroup(finished); finished.writeFrame({ payload: enc.encode("4") }); - finished.close(); + finished.finish(); const group = await reader.recvGroup(); expect(group?.sequence).toBe(3); @@ -3043,7 +3043,7 @@ test("a re-created cut group is served, not dropped", async () => { const reader = producer.subscribe({ maxDelay: Milli(60_000) }); const cut = new GroupProducer(4); producer.writeGroup(cut); - cut.close(new Error("cut")); + cut.abort(new Error("cut")); await settle(); const retry = new GroupProducer(4); @@ -3101,7 +3101,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => const group = new GroupProducer(5); producer.writeGroup(group); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); - group.close(); + group.finish(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); @@ -3162,7 +3162,7 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) // A group handed over already past the end is refused before it is cached. const overfull = new GroupProducer(5); for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); - group.close(new Error("replaced")); + group.abort(new Error("replaced")); expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); producer.close(); }); @@ -3175,10 +3175,10 @@ function stampGroup(producer: TrackProducer, sequence: number, ms: number): Grou try { group.writeFrame({ payload: enc.encode(`${ms}`), timestamp: Timestamp.fromMillis(ms) }); } catch (err) { - group.close(err as Error); + group.abort(err as Error); throw err; } - group.close(); + group.finish(); return group; } @@ -3197,7 +3197,7 @@ test("a re-created group is handed out once", () => { producer.writeGroup(aborted); expect(drainGroups(subscriber)).toEqual([0, 1]); - aborted.close(new Error("aborted")); + aborted.abort(new Error("aborted")); stampGroup(producer, 1, 1); stampGroup(producer, 2, 2); expect(drainGroups(subscriber)).toEqual([2]); @@ -3301,12 +3301,12 @@ test("a replaced aborted group still bounds later ones", () => { const first = new GroupProducer(0); producer.writeGroup(first); first.writeFrame({ payload: enc.encode("1000"), timestamp: Timestamp.fromMillis(1000) }); - first.close(new Error("aborted")); + first.abort(new Error("aborted")); const replacement = new GroupProducer(0); replacement.writeFrame({ payload: enc.encode("500"), timestamp: Timestamp.fromMillis(500) }); producer.writeGroup(replacement); - replacement.close(); + replacement.finish(); expect(() => stampGroup(producer, 1, 600)).toThrow(TimestampMismatch); stampGroup(producer, 1, 1000); }); @@ -3381,7 +3381,7 @@ test("an untimed track takes groups in any order", () => { const group = new GroupProducer(sequence); producer.writeGroup(group); group.writeFrame({ payload: enc.encode(`${sequence}`) }); - group.close(); + group.finish(); } }); @@ -3394,7 +3394,7 @@ function pendingTail(end: number, maxDelay = Milli(60_000)) { hooks.endTrack(producer, { group: end, frame: 0 }); const write = (sequence: number) => { const group = new GroupProducer(sequence); - group.close(); + group.finish(); producer.writeGroup(group); }; return { producer, reader, write }; @@ -3447,7 +3447,7 @@ test("without a declared start, the first arrival stands in for it", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.endTrack(producer, { group: 3, frame: 0 }); const group = new GroupProducer(2); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); @@ -3488,7 +3488,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" hooks.startTrack(producer, { start: 3, requested: 1 }); hooks.endTrack(producer, { group: 4, frame: 0 }); const group = new GroupProducer(3); - group.close(); + group.finish(); producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(3); expect(await reader.recvGroup()).toBeUndefined(); @@ -3507,7 +3507,7 @@ test("an end after the highest group holds the reader at the hole", async () => const reader = producer.subscribe({ maxDelay: Milli(60_000) }); hooks.startTrack(producer, { start: 0 }); const high = new GroupProducer(2); - high.close(); + high.finish(); producer.writeGroup(high); expect((await reader.recvGroup())?.sequence).toBe(2); const next = reader.recvGroup(); @@ -3516,7 +3516,7 @@ test("an end after the highest group holds the reader at the hole", async () => expect(await settled(next)).toBe(false); for (const sequence of [0, 1]) { const low = new GroupProducer(sequence); - low.close(); + low.finish(); producer.writeGroup(low); } expect((await next)?.sequence).toBe(0); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 6f5062b84f9..57301876f14 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -917,7 +917,7 @@ export class Producer { if (open) { for (const [sequence, entry] of this.#cached) { if (sequence >= start && sequence < end && entry.group.closed.peek() === undefined) { - entry.group.close(error); + entry.group.abort(error); } } } @@ -1162,7 +1162,7 @@ export class Producer { if (!entry.group.isClosed) { const evicted = new TooFarBehind(); EVICTED.add(evicted); - entry.group.close(evicted); + entry.group.abort(evicted); } const mirrors = [...entry.mirrors.values()]; for (const mirror of mirrors) hooks.evictGroup(mirror); @@ -1758,7 +1758,10 @@ export class Producer { closeTrackState(this.#state, abort); this.#sequence.holders--; if (this.#sequence.owner === this) this.#sequence.owner = undefined; - for (const { group } of this.#cache) group.close(abort); + for (const { group } of this.#cache) { + if (abort) group.abort(abort); + else group.finish(); + } for (const entry of open) { this.#unlink(entry); this.#uncache(entry); @@ -1791,10 +1794,10 @@ export class Producer { group.writeFrame(frame); } catch (err) { // Abort the group so no subscriber waits on a group that will never get its frame. - group.close(err instanceof Error ? err : new Error(String(err))); + group.abort(err instanceof Error ? err : new Error(String(err))); throw err; } - group.close(); + group.finish(); } } diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 3946c2f11b0..9170bec2045 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -56,7 +56,7 @@ async function roundTrip(wire: Wire, timescale: Timescale | undefined, timestamp for (const [i, timestamp] of timestamps.entries()) { group.writeFrame({ payload: new Uint8Array([i]), timestamp }); } - group.close(); + group.finish(); } })(); @@ -86,7 +86,7 @@ async function republish(input: BroadcastConsumer): Promise { for (let group = await groups.nextGroup(); group; group = await groups.nextGroup()) { const out = video.appendGroup(); for (let frame = await group.readFrame(); frame; frame = await group.readFrame()) out.writeFrame(frame); - out.close(); + out.finish(); } // Closing the broadcast does not end a track inserted into it. video.close(); @@ -122,7 +122,7 @@ test("a forwarded untimed track stays untimed", async () => { if (!request) break; const group = request.accept({ timescale: Timescale.MICRO }).appendGroup(); group.writeFrame({ payload: new Uint8Array([7]), timestamp: Timestamp.fromMicros(1_234) }); - group.close(); + group.finish(); } })(); @@ -197,7 +197,7 @@ for (const wire of WIRES.filter((wire) => wire.name === "lite-06" || wire.name = // Datagrams aren't cached, so a group read back first proves the subscription is live. const group = producer.appendGroup(); group.writeFrame({ payload: new Uint8Array([0]) }); - group.close(); + group.finish(); expect(await subscriber.recvGroup()).toBeDefined(); producer.appendDatagram(undefined, new Uint8Array([7])); diff --git a/js/net/src/zod.test.ts b/js/net/src/zod.test.ts index ddc8cee2798..95a3cf48b51 100644 --- a/js/net/src/zod.test.ts +++ b/js/net/src/zod.test.ts @@ -9,7 +9,7 @@ test("read validates each frame and ends only at the end of the group", async () const consumer = producer.consume(); // Falsy values are frames like any other, not the end of the stream. for (const json of ["0", "false", "null", '"ok"']) producer.writeFrame(textFrame(json)); - producer.close(); + producer.finish(); const schema = z.union([z.number(), z.boolean(), z.null(), z.string()]); expect(await Zod.read(consumer, schema)).toBe(0); @@ -23,7 +23,7 @@ test("read rejects a frame the schema refuses", async () => { const producer = new Producer(0); const consumer = producer.consume(); producer.writeFrame(textFrame('{"count":"three"}')); - producer.close(); + producer.finish(); await expect(Zod.read(consumer, z.object({ count: z.number() }))).rejects.toThrow(); }); diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index 9631e92b857..f9d90dad24d 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -279,7 +279,7 @@ test("a rendition keeps its track across rendition changes", async () => { await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Registering another rendition leaves this one's track alone. const audio = broadcast.audio("audio"); @@ -338,7 +338,7 @@ test("replacing a closed rendition track keeps the broadcast and publishes a fre if (!track) throw new Error("expected the demanded track"); // The encoder wrote, then failed. - track.appendGroup().close(); + track.appendGroup().finish(); track.close(new Error("encoder failed")); video.close(); await settle(); @@ -474,7 +474,7 @@ test("a served decoder identity change moves the rendition to a fresh track name await settle(); const track = video.track.peek(); if (!track) throw new Error("expected the demanded track"); - track.appendGroup().close(); + track.appendGroup().finish(); // Bitrate, frame rate, and coded size move either way on the served track. for (const update of [ diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 1c449b169a0..4abb76e2486 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -965,7 +965,7 @@ async function livePlayback(burst: number) { for (const timestamp of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(timestamp)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index b97c17ef421..adc680ac30b 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -100,7 +100,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< const media = Micro.fromMilli(t[next].media as Milli); const group = new Group.Producer(next); group.writeFrame({ payload: encode(media), timestamp: Time.Timestamp.fromMicros(media) }); - group.close(); + group.finish(); track.writeGroup(group); } diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 82a9b91d633..58eda7488ba 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -246,7 +246,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato timestamp: Time.Timestamp.fromMicros(timestamp), }); if (ends.get(arrival.group) === next) { - group.close(); + group.finish(); groups.delete(arrival.group); } next++; diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index e9927a0d5f7..dd4c12354a4 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -902,7 +902,7 @@ function liveVideo(clock: ReturnType, track: Moq.Track.Producer for (const media of held) { const group = new Moq.Group.Producer(sequence++); group.writeFrame(legacyFrame(Micro.fromMilli(Milli(media)))); - group.close(); + group.finish(); track.writeGroup(group); } held = []; @@ -1031,7 +1031,7 @@ it("buffered video holds its poster until its own span reaches the floor", async await clock.advance(40); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(40_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1055,7 +1055,7 @@ it("bounds startup decoding while another track is missing and releases it on cl expect(playback.pictures.every((frame) => frame.closed)).toBe(true); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1081,7 +1081,7 @@ it("resumes decoding the moment shared startup releases", async () => { expect(playback.submitted).toHaveLength(8); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1130,7 +1130,7 @@ it("continues filling when the shared floor rises while video waits on audio", a expect(playback.decoder.out.frame.peek()?.timestamp).toBeGreaterThanOrEqual(200_000); } finally { remove(); - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); @@ -1148,7 +1148,7 @@ it("keeps feeding a decoder that has not output its first picture yet", async () await clock.advance(160); expect(playback.decoder.out.frame.peek()?.timestamp).toBe(160_000); } finally { - group.close(); + group.finish(); playback.close(); await microtasks(); clock.installed.close(); From ec418e19c9260b767f1e805ef0bfcdac9ab731d1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:34:46 -0700 Subject: [PATCH 06/30] feat(net)!: split JS Track.Producer.close into finish, abort, and close finish() declares the end without closing, abort(err) is terminal, and close() releases the handle like a Rust drop: clean with a declared end, otherwise a Cancel abort. Producer.closed and Demand.closed now settle on the outcome, including a reached and settled end; the wire layer watches the release through private hooks. Co-Authored-By: Claude Opus 5.5 --- bench/browser/page/transport.ts | 3 +- demo/web/src/publish.ts | 5 +- js/e2ee/src/e2ee.test.ts | 2 +- js/e2ee/src/generation.ts | 2 +- js/e2ee/src/track.ts | 7 +- js/flate/src/snapshot/producer.ts | 1 + js/flate/src/snapshot/snapshot.test.ts | 3 +- js/flate/src/stream/producer.ts | 3 +- js/flate/src/stream/stream.test.ts | 1 + js/hang/src/container/consumer.holes.test.ts | 4 + .../src/container/consumer.outoforder.test.ts | 2 + js/hang/src/container/consumer.rewind.test.ts | 3 + js/hang/src/container/consumer.skips.test.ts | 1 + .../src/container/consumer.startup.test.ts | 1 + js/hang/src/container/consumer.test.ts | 47 +++++- js/hang/src/container/legacy.ts | 6 +- js/json/src/snapshot/producer.ts | 6 +- js/json/src/stream/codec.test.ts | 1 + js/json/src/stream/producer.ts | 3 +- js/json/src/stream/stream.test.ts | 2 +- js/json/src/window/producer.ts | 6 +- js/json/src/window/window.test.ts | 2 + js/net/bench/broadcasts.ts | 1 + js/net/bench/compression.ts | 2 + js/net/bench/drift.ts | 1 + js/net/bench/serve-browser-entry.ts | 1 + js/net/bench/subscribe-drop.ts | 1 + js/net/bench/subscribe-tracks.ts | 2 +- js/net/bench/track.ts | 1 + js/net/examples/publish.ts | 3 +- js/net/src/bandwidth.test.ts | 1 + js/net/src/bandwidth.ts | 6 +- js/net/src/broadcast.test.ts | 35 +++- js/net/src/broadcast.ts | 25 +-- js/net/src/connection/reload.test.ts | 4 + js/net/src/flate/group.test.ts | 7 + js/net/src/group.test.ts | 11 ++ js/net/src/ietf/publisher.test.ts | 25 ++- js/net/src/ietf/subscribe_tracks.test.ts | 4 + js/net/src/ietf/subscriber.ts | 25 +-- js/net/src/integration.test.ts | 6 +- js/net/src/internal.ts | 13 ++ js/net/src/listing.test.ts | 14 +- js/net/src/listing.ts | 3 +- js/net/src/lite/connection.test.ts | 3 + js/net/src/lite/publisher.test.ts | 15 ++ js/net/src/lite/subscriber.ts | 31 ++-- js/net/src/resume.test-util.ts | 1 + js/net/src/resume.test.ts | 30 +++- js/net/src/resume.ts | 2 +- js/net/src/tail.test.ts | 2 + js/net/src/track.test.ts | 158 ++++++++++++++++-- js/net/src/track.ts | 93 ++++++++--- js/net/src/untimed.test.ts | 3 +- js/net/src/util/demand.ts | 11 +- js/publish/src/audio/capture.test.ts | 1 + js/publish/src/audio/encoder.test.ts | 5 +- js/publish/src/audio/encoder.ts | 4 +- js/publish/src/broadcast.test.ts | 7 +- js/publish/src/broadcast.ts | 11 +- js/publish/src/video/capture.test.ts | 2 + js/publish/src/video/encoder-stall.test.ts | 10 +- js/publish/src/video/encoder.test.ts | 50 +++++- js/publish/src/video/encoder.ts | 2 +- js/room/src/chat.test.ts | 2 +- js/room/src/metadata.ts | 5 +- js/watch/src/audio/decoder.test.ts | 1 + js/watch/src/audio/playout.test.ts | 1 + js/watch/src/audio/replay.ts | 1 + js/watch/src/media.test.ts | 2 +- js/watch/src/video/decoder.test.ts | 1 + .../clients/js/src/capture.test.ts | 18 +- test/interop/clients/js-native/control.ts | 4 +- test/interop/clients/js-native/server.ts | 10 +- test/interop/clients/js-native/tail.ts | 5 +- test/interop/clients/js/open-gop/main.ts | 5 +- test/interop/clients/js/src/finish.ts | 3 +- test/p2p/src/main.ts | 1 + test/receive-credit/sender.ts | 8 +- test/video/src/captions.ts | 2 +- test/video/src/poster.ts | 4 +- test/wasm/baseline/publisher.ts | 3 +- test/wasm/src/main.ts | 7 +- 83 files changed, 653 insertions(+), 173 deletions(-) diff --git a/bench/browser/page/transport.ts b/bench/browser/page/transport.ts index 53f30ffea1a..cd98d3ded76 100644 --- a/bench/browser/page/transport.ts +++ b/bench/browser/page/transport.ts @@ -44,6 +44,7 @@ async function publish(config: Transport): Promise { }); if (connection.transport !== "webtransport") throw new Error(`publisher negotiated ${connection.transport}`); cleanup.push(() => { + track.finish(); track.close(); producer.close(); connection.abort(); @@ -57,7 +58,7 @@ async function publish(config: Transport): Promise { const group = track.appendGroup(); for (let frame = start; frame < Math.min(start + config.group, config.frames); frame++) group.writeFrame({ payload: fixtures[frame], timestamp: Moq.Time.Timestamp.fromMillis(frame) }); - group.close(); + group.finish(); } })(); } diff --git a/demo/web/src/publish.ts b/demo/web/src/publish.ts index b9f789188f6..dac12cefa54 100644 --- a/demo/web/src/publish.ts +++ b/demo/web/src/publish.ts @@ -408,7 +408,10 @@ meta.run((effect) => { timescale: Net.Time.Timescale.MILLI, maxAge: Milli(86_400_000), }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const producer = new Json.Snapshot.Producer({ track }); producer.update({ value: currentMeta, at: Net.Time.Timestamp.now() }); diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index 9b028fc2649..22c7df742f7 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -751,7 +751,7 @@ test("produce waits for an unaccepted track's timescale", async () => { const video = await generation.name("video"); const closed = new NetTrack.Producer(video); const refused = generation.produce(closed, "video"); - closed.close(new Error("gone")); + closed.abort(new Error("gone")); await expect(refused).rejects.toThrow("gone"); await generation.produce(netTrack(video), "video"); expect(await failure(generation.produce(netTrack(video), "video"))).toBe("reuse"); diff --git a/js/e2ee/src/generation.ts b/js/e2ee/src/generation.ts index 8abc89ab9bc..839c9b059e9 100644 --- a/js/e2ee/src/generation.ts +++ b/js/e2ee/src/generation.ts @@ -132,7 +132,7 @@ export class Producer { if (this.#claims.has(semantic)) throw new Failure("reuse"); } catch (err) { // Both refusals are Failures. - track.close(err as Failure); + track.abort(err as Failure); throw err; } this.#claims.add(semantic); diff --git a/js/e2ee/src/track.ts b/js/e2ee/src/track.ts index 9d3360fdf44..0a16898b9f8 100644 --- a/js/e2ee/src/track.ts +++ b/js/e2ee/src/track.ts @@ -107,11 +107,14 @@ export class Producer { this.#writer.closed = true; if (abort) { this.#writer.aborted = abort; - this.#inner.close(abort); + this.#inner.abort(abort); return; } void this.#writer.pump.run(() => { - if (!this.#writer.aborted) this.#inner.close(); + if (!this.#writer.aborted) { + this.#inner.finish(); + this.#inner.close(); + } }); } diff --git a/js/flate/src/snapshot/producer.ts b/js/flate/src/snapshot/producer.ts index 7b6a1997453..54f3f214731 100644 --- a/js/flate/src/snapshot/producer.ts +++ b/js/flate/src/snapshot/producer.ts @@ -67,6 +67,7 @@ export class Producer { /** Finish the track. */ finish(): void { + this.#track.finish(); this.#track.close(); } } diff --git a/js/flate/src/snapshot/snapshot.test.ts b/js/flate/src/snapshot/snapshot.test.ts index 71309dcf65e..d603f0ecacb 100644 --- a/js/flate/src/snapshot/snapshot.test.ts +++ b/js/flate/src/snapshot/snapshot.test.ts @@ -156,6 +156,7 @@ test("latest lets a newer group preempt an open one", async () => { expect((await consumer.latest())?.value).toEqual(bytes(2)); stale.finish(); + track.finish(); track.close(); }); @@ -181,7 +182,7 @@ test("an aborted track surfaces its error instead of spinning", async () => { const consumer = new Consumer({ track: track.subscribe() }); const boom = new Error("subscription aborted"); - track.close(boom); + track.abort(boom); await expect(consumer.next()).rejects.toThrow("subscription aborted"); }); diff --git a/js/flate/src/stream/producer.ts b/js/flate/src/stream/producer.ts index b8ca9a6d532..1ef3f8ed619 100644 --- a/js/flate/src/stream/producer.ts +++ b/js/flate/src/stream/producer.ts @@ -90,7 +90,7 @@ export class Producer { const abort = err instanceof Error ? err : new Error(String(err)); this.#group?.abort(abort); this.#group = undefined; - this.#track.close(abort); + this.#track.abort(abort); throw err; } @@ -102,6 +102,7 @@ export class Producer { finish(): void { this.#group?.finish(); this.#group = undefined; + this.#track.finish(); this.#track.close(); } } diff --git a/js/flate/src/stream/stream.test.ts b/js/flate/src/stream/stream.test.ts index 6140647e128..b4999ff9993 100644 --- a/js/flate/src/stream/stream.test.ts +++ b/js/flate/src/stream/stream.test.ts @@ -77,6 +77,7 @@ test("a second group is a rolled log, not a continuation", async () => { for (const payload of pair) group.writeFrame({ payload, timestamp: Time.Timestamp.now() }); group.finish(); } + track.finish(); track.close(); const consumer = new Consumer({ track: track.subscribe({ maxDelay: REPLAY_LATENCY }) }); diff --git a/js/hang/src/container/consumer.holes.test.ts b/js/hang/src/container/consumer.holes.test.ts index 25044194521..958d2e6550e 100644 --- a/js/hang/src/container/consumer.holes.test.ts +++ b/js/hang/src/container/consumer.holes.test.ts @@ -46,6 +46,7 @@ test.each([0, 100])("a sequence hole before a paused data marker expires at %sms ]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -97,6 +98,7 @@ test("a filled hole cancels its timer and the next hole gets a fresh budget", as ]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -138,6 +140,7 @@ test("the holes a stalled source left behind share one budget", async () => { expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame", "gap", "frame"]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); @@ -182,6 +185,7 @@ test("a hole is late from the earliest arrival past it", async () => { expect(results.map((read) => read.kind)).toEqual(["frame", "gap", "frame", "gap", "frame"]); } finally { consumer.close(); + track.finish(); track.close(); await reading; installed.close(); diff --git a/js/hang/src/container/consumer.outoforder.test.ts b/js/hang/src/container/consumer.outoforder.test.ts index fd6b00a0b98..736e53fcbb6 100644 --- a/js/hang/src/container/consumer.outoforder.test.ts +++ b/js/hang/src/container/consumer.outoforder.test.ts @@ -118,6 +118,7 @@ test("a below-cursor group still downloading is not truncated", async () => { expect(second?.frame.payload[0]).toBe(11); live.finish(); + track.finish(); track.close(); consumer.close(); }); @@ -150,6 +151,7 @@ test("delayed older groups survive a floor established by two newer groups", asy await expect(nextFrame(consumer)).rejects.toThrow("below the previous group start"); } finally { consumer.close(); + track.finish(); track.close(); clock.mockRestore(); } diff --git a/js/hang/src/container/consumer.rewind.test.ts b/js/hang/src/container/consumer.rewind.test.ts index 67f01adfc2b..4b3015d8676 100644 --- a/js/hang/src/container/consumer.rewind.test.ts +++ b/js/hang/src/container/consumer.rewind.test.ts @@ -55,6 +55,7 @@ test("Consumer accepts a resuming keyframe before the estimated group end", asyn expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(50_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -88,6 +89,7 @@ test("Consumer accepts leading pictures below the previous group's content", asy expect((await frame(consumer))?.frame?.timestamp).toBe(Micro(150_000)); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -110,6 +112,7 @@ test("Consumer rejects a later frame below the previous group start", async () = await expect(frame(consumer)).rejects.toThrow("below"); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/consumer.skips.test.ts b/js/hang/src/container/consumer.skips.test.ts index b5f6e1ef64f..02bb22e1e95 100644 --- a/js/hang/src/container/consumer.skips.test.ts +++ b/js/hang/src/container/consumer.skips.test.ts @@ -45,6 +45,7 @@ for (const count of [1, 16, 256]) { ); } finally { consumer.close(); + track.finish(); track.close(); warn.mockRestore(); } diff --git a/js/hang/src/container/consumer.startup.test.ts b/js/hang/src/container/consumer.startup.test.ts index 3a46b7c3ae7..c29b9f41daf 100644 --- a/js/hang/src/container/consumer.startup.test.ts +++ b/js/hang/src/container/consumer.startup.test.ts @@ -25,6 +25,7 @@ function setup(maxDelay = 100) { })(); onTestFinished(async () => { consumer.close(); + track.finish(); track.close(); try { await reading; diff --git a/js/hang/src/container/consumer.test.ts b/js/hang/src/container/consumer.test.ts index 18535ee70aa..38e583dbdb0 100644 --- a/js/hang/src/container/consumer.test.ts +++ b/js/hang/src/container/consumer.test.ts @@ -50,6 +50,7 @@ function writeLoc(track: Track.Producer, groups: [Micro, number[]][][]) { } group.finish(); } + track.finish(); track.close(); } @@ -397,10 +398,12 @@ test("Consumer refuses a data marker that shares its group", async () => { group.writeFrame({ payload: second, timestamp: Time.Timestamp.fromMicros(10_000) }); group.finish(); // A finished track resolves the read instead of hanging when the reader fails to end. + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("shares its group"); } finally { consumer.close(); + track.finish(); track.close(); } } @@ -558,6 +561,7 @@ test("Consumer delivers frames from a single group", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -572,6 +576,7 @@ test("Consumer forces keyframe true at index 0", async () => { const consumer = new Consumer(replay(track), { format: new LegacyFormat("data"), maxDelay: 500 as Milli }); writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -600,6 +605,7 @@ test("Consumer index spans MoQ frames for keyframe detection", async () => { group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // second MoQ frame → 3 samples group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -639,6 +645,7 @@ for (const [where, payloads] of [ g1.writeFrame({ payload: new Uint8Array([0x04]), timestamp: Time.Timestamp.now() }); g1.finish(); track.writeGroup(g1); + track.finish(); track.close(); const frames: number[] = []; @@ -667,6 +674,7 @@ test("Consumer delivers everything ahead of a decode error", async () => { malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.fromMicros(200_000) }); malformed.finish(); track.writeGroup(malformed); + track.finish(); track.close(); const items: string[] = []; @@ -695,6 +703,7 @@ test("Consumer keeps the first of several decode errors", async () => { group.finish(); track.writeGroup(group); } + track.finish(); track.close(); await settle(); @@ -766,6 +775,7 @@ test("Consumer keeps frames decoded before a group reset (truncated GoP)", async g0.abort(new NetError.Stream(StreamCode.Cancel)); writeGroupWithLegacyFrames(track, 1, [4 as Micro]); + track.finish(); track.close(); const items: string[] = []; @@ -806,6 +816,7 @@ test("Consumer skips groups via PTS-span when over the max delay", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [100_000 as Micro]); writeGroupWithLegacyFrames(track, 2, [200_000 as Micro]); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -846,8 +857,10 @@ test("Consumer measures arrivals as they land", async () => { expect(replacement.spread.peek()).toBe(consumer.spread.peek()); replacement.close(); } finally { + track.finish(); track.close(); consumer.close(); + next.finish(); next.close(); installed.close(); } @@ -862,6 +875,7 @@ test("Consumer delivers groups in sequence order regardless of arrival order", a writeGroupWithLegacyFrames(track, 2, [60_000 as Micro]); writeGroupWithLegacyFrames(track, 0, [0 as Micro]); writeGroupWithLegacyFrames(track, 1, [30_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -888,6 +902,7 @@ test("Consumer delivers a group that arrives below the cursor", async () => { // three are handed over, in sequence order, since delivery has not passed any of them yet. writeGroupWithLegacyFrames(track, 3, [0 as Micro]); writeGroupWithLegacyFrames(track, 6, [200_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -905,6 +920,7 @@ test("Consumer next() yields no gap before the first frame", async () => { writeGroupWithLegacyFrames(track, 0, [0 as Micro, 33_000 as Micro]); writeGroupWithLegacyFrames(track, 1, [66_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -938,6 +954,7 @@ test("Consumer reads nothing after close, even the frame a gap was returned ahea consumer.close(); expect(await consumer.next()).toBeUndefined(); + track.finish(); track.close(); }); @@ -959,6 +976,7 @@ test("Consumer keeps a trailing duration marker internal", async () => { }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); await settle(); @@ -989,6 +1007,7 @@ test("Consumer reads a leading marker as an end and keeps the first media keyfra }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(await consumer.next()).toEqual({ kind: "end", group: 0, end: 20_000 as Micro }); @@ -1104,6 +1123,7 @@ test("Consumer throws the first error in delivery order", async () => { malformed.finish(); track.writeGroup(malformed); writeGroupWithLegacyFrames(track, 2, [0 as Micro, 100_000 as Micro], wallClock); + track.finish(); track.close(); expect((await nextFrame(consumer))?.frame?.timestamp).toBe(10_000_000 as Micro); @@ -1152,6 +1172,7 @@ test("Consumer checks a whole decoded batch before a skip drops its group", asyn // Read only once the skip has dropped it, before the newer group's frame is delivered. await rewound; newest.finish(); + track.finish(); track.close(); const read = async () => { @@ -1325,6 +1346,7 @@ test("Consumer buffered signal updates as frames arrive", async () => { expect(ranges[0].start).toBe(0 as Milli); expect((ranges[0].end as number) >= 66).toBeTruthy(); + track.finish(); track.close(); consumer.close(); }); @@ -1341,6 +1363,7 @@ test("Consumer recovers from gap in group sequence numbers", async () => { writeGroupWithLegacyFrames(track, 3, [120_000 as Micro, 140_000 as Micro]); writeGroupWithLegacyFrames(track, 4, [160_000 as Micro, 180_000 as Micro]); writeGroupWithLegacyFrames(track, 5, [200_000 as Micro, 220_000 as Micro]); + track.finish(); track.close(); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -1370,6 +1393,7 @@ test("Consumer handles empty decode result without deadlock", async () => { group.writeFrame({ payload: new Uint8Array([0x02]), timestamp: Time.Timestamp.now() }); // valid decode group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 300); @@ -1394,6 +1418,7 @@ test("Consumer preserves empty media from formats without endpoint markers", asy group.writeFrame({ payload: new Uint8Array([1]), timestamp: Time.Timestamp.now() }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); expect(frameOf(await nextFrame(consumer)).payload).toHaveLength(0); @@ -1420,6 +1445,7 @@ test("Consumer with CmafFormat delivers correct timestamps", async () => { }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1443,6 +1469,7 @@ test("Consumer with CmafFormat times an untimed track from tfdt", async () => { group.writeFrame({ payload: fragment("untimedDelta") }); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1491,6 +1518,7 @@ test("Consumer duration-skips a stalled group once it is covered", async () => { track.writeGroup(g0); track.writeGroup(g1); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1533,6 +1561,7 @@ test("Consumer does not duration-skip when the gap is not covered", async () => await new Promise((resolve) => setTimeout(resolve, 20)); g0.writeFrame({ payload: new Uint8Array([20]), timestamp: Time.Timestamp.now() }); g0.finish(); + track.finish(); track.close(); const frames = await drainFrames(consumer, 200); @@ -1927,10 +1956,12 @@ test("a live video frame is delivered before its duration marker arrives", async timestamp: Time.Timestamp.now(), }); group.finish(); + track.finish(); track.close(); // The marker is the frame's duration, not an item. expect(await consumer.next()).toBeUndefined(); consumer.close(); + track.finish(); track.close(); }); @@ -1979,6 +2010,7 @@ for (const code of [StreamCode.Cancel, StreamCode.Internal, StreamCode.Old, Stre expect(error).not.toHaveBeenCalled(); } finally { consumer.close(); + track.finish(); track.close(); error.mockRestore(); } @@ -1996,7 +2028,11 @@ for (const end of [ const consumer = new Consumer(replay(track), { format: new LegacyFormat("data") }); try { const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } if (end) await expect(pending).rejects.toBe(end); else expect(await pending).toBeUndefined(); } finally { @@ -2017,10 +2053,12 @@ test("a group reset does not hide a later container decode failure", async () => // A truncated timestamp varint. malformed.writeFrame({ payload: new Uint8Array([0x80]), timestamp: Time.Timestamp.now() }); malformed.finish(); + track.finish(); track.close(); await expect(nextFrame(consumer)).rejects.toThrow("buffer too short"); } finally { consumer.close(); + track.finish(); track.close(); } }); @@ -2042,7 +2080,11 @@ for (const end of [ writeGroupWithLegacyFrames(track, 2, [1_000_000 as Micro]); await settle(); const pending = consumer.next(); - track.close(end ?? undefined); + if (end) track.abort(end); + else { + track.finish(); + track.close(); + } const resumed = await Promise.race([pending, settle(100).then(() => "stalled" as const)]); expect(resumed).not.toBe("stalled"); if (resumed === "stalled") return; @@ -2052,6 +2094,7 @@ for (const end of [ else expect(await consumer.next()).toBeUndefined(); } finally { consumer.close(); + track.finish(); track.close(); } }); diff --git a/js/hang/src/container/legacy.ts b/js/hang/src/container/legacy.ts index 5916111b02e..dd2bc91c7cf 100644 --- a/js/hang/src/container/legacy.ts +++ b/js/hang/src/container/legacy.ts @@ -184,6 +184,10 @@ export class Producer { if (!err) this.#close(); if (err) this.#group?.abort(err); else this.#group?.finish(); - this.#track.close(err); + if (err) this.#track.abort(err); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/snapshot/producer.ts b/js/json/src/snapshot/producer.ts index f25254a82e0..ed50e870ddb 100644 --- a/js/json/src/snapshot/producer.ts +++ b/js/json/src/snapshot/producer.ts @@ -194,7 +194,11 @@ export class Producer { // Any further update fails on the closed track, but it has to fail as a track error rather // than as a delta with nowhere to put it. this.#encoder.reset(); - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/stream/codec.test.ts b/js/json/src/stream/codec.test.ts index 51af525139e..55a4f646426 100644 --- a/js/json/src/stream/codec.test.ts +++ b/js/json/src/stream/codec.test.ts @@ -158,6 +158,7 @@ test("a rejected record leaves the producer able to retry", () => { const producer = new Producer({ track, compression: "deflate" }); // Closing the track makes every write fail, standing in for any post-appendGroup rejection. + track.finish(); track.close(); expect(() => producer.append({ value: { n: 1 } })).toThrow(); diff --git a/js/json/src/stream/producer.ts b/js/json/src/stream/producer.ts index 5e5c7612ce1..a3733df11cb 100644 --- a/js/json/src/stream/producer.ts +++ b/js/json/src/stream/producer.ts @@ -89,13 +89,14 @@ export class Producer { // sitting in the group with a generic error instead of the failure that ended the log. this.#group?.abort(abort); this.#group = undefined; - this.#track.close(abort); + this.#track.abort(abort); } /** Finish the track, closing the group. */ finish(): void { this.#group?.finish(); this.#group = undefined; + this.#track.finish(); this.#track.close(); } } diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 8e2d208e7a5..48a825e7ce4 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -124,7 +124,7 @@ test("a failed track preserves its error", async () => { const consumer = new Consumer({ track: track.subscribe() }); const next = consumer.next(); const failure = new Error("source failed"); - track.close(failure); + track.abort(failure); await expect(next).rejects.toBe(failure); }); diff --git a/js/json/src/window/producer.ts b/js/json/src/window/producer.ts index de269416096..929030c11e5 100644 --- a/js/json/src/window/producer.ts +++ b/js/json/src/window/producer.ts @@ -171,6 +171,10 @@ export class Producer { else this.#group?.finish(); this.#group = undefined; - this.#track.close(abort); + if (abort) this.#track.abort(abort); + else { + this.#track.finish(); + this.#track.close(); + } } } diff --git a/js/json/src/window/window.test.ts b/js/json/src/window/window.test.ts index 78187201e1f..82a7f8e528c 100644 --- a/js/json/src/window/window.test.ts +++ b/js/json/src/window/window.test.ts @@ -104,6 +104,7 @@ test("concurrent consumer reads are rejected", async () => { const first = consumer.next(); expect(() => consumer.next()).toThrow("multiple calls to next not supported"); + track.finish(); track.close(); expect(await first).toBeUndefined(); }); @@ -410,6 +411,7 @@ test("consumer resumes at a checkpoint after losing a group", async () => { group.writeFrame({ payload: frame.payload, timestamp: Time.Timestamp.now() }); frame.commit(); group.finish(); + track.finish(); track.close(); expect((await consumer.next())?.value).toEqual({ push: { index: 1, value: { n: 1 } } }); diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index e500038b24d..8d0a8f3c420 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -101,6 +101,7 @@ for (const trackCount of [8, 32, 128]) { `${trackCount},${observerCount},${(((performance.now() - start) * 1000) / demandUpdates).toFixed(1)}`, ); for (const dispose of disposes) dispose(); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); broadcast.close(); } diff --git a/js/net/bench/compression.ts b/js/net/bench/compression.ts index 42d1b882b97..bbd8ed0a1d3 100644 --- a/js/net/bench/compression.ts +++ b/js/net/bench/compression.ts @@ -44,6 +44,7 @@ for (const publishers of [1, 4, 16]) { read += performance.now() - started; inflated = groups.reduce((bytes, group) => bytes + hooks.groupCacheBytes(group), 0) - stored; if (inflated !== publishers * frames * payload.length) throw new Error("inflated bytes scale with readers"); + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } console.log([publishers, readers, "publish", ((publish * 1000) / rounds).toFixed(1), stored].join(",")); @@ -74,6 +75,7 @@ async function datagrams(count: number, queued: number): Promise<[number, number const deliver = ((performance.now() - started) * 1000) / (count * queued); Bun.gc(true); const retained = process.memoryUsage().heapUsed - before; + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); slow.length = 0; return [deliver, retained]; diff --git a/js/net/bench/drift.ts b/js/net/bench/drift.ts index efc820f3300..a37dfdcce29 100644 --- a/js/net/bench/drift.ts +++ b/js/net/bench/drift.ts @@ -54,6 +54,7 @@ export async function run(): Promise { `${retained},${publishers},${readers},${((elapsed * 1e6) / (iterations * publishers * readers)).toFixed(0)}`, ); } finally { + for (const track of tracks) track.finish(); for (const track of tracks) track.close(); } } diff --git a/js/net/bench/serve-browser-entry.ts b/js/net/bench/serve-browser-entry.ts index aa8accfa877..1a9dcdec5ec 100644 --- a/js/net/bench/serve-browser-entry.ts +++ b/js/net/bench/serve-browser-entry.ts @@ -120,6 +120,7 @@ async function measure(protocol: string, groups: number, viewers: number) { clearInterval(timer); publisher.close(); for (const stream of streams) stream.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/bench/subscribe-drop.ts b/js/net/bench/subscribe-drop.ts index 8f59a911f59..2f09592a101 100644 --- a/js/net/bench/subscribe-drop.ts +++ b/js/net/bench/subscribe-drop.ts @@ -33,6 +33,7 @@ try { group.finish(); track.writeGroup(group); } + track.finish(); track.close(); return { origin, broadcast }; }); diff --git a/js/net/bench/subscribe-tracks.ts b/js/net/bench/subscribe-tracks.ts index 6a7e3e2680c..10c64a9a810 100644 --- a/js/net/bench/subscribe-tracks.ts +++ b/js/net/bench/subscribe-tracks.ts @@ -55,7 +55,7 @@ for (const broadcasts of [1, 16, 128]) { }), ); if (sample) samples.push((performance.now() - start) * 1000); - track.close(new Error("benchmark finished")); + track.abort(new Error("benchmark finished")); } samples.sort((a, b) => a - b); console.log(`${broadcasts},${subscribers},${samples[10].toFixed(2)}`); diff --git a/js/net/bench/track.ts b/js/net/bench/track.ts index 356e1e28ca3..63736cd8542 100644 --- a/js/net/bench/track.ts +++ b/js/net/bench/track.ts @@ -111,6 +111,7 @@ async function measure( if (group) throw new Error("track did not end at its declared boundary"); } const end = (now() - endStart) * 1000; + producer.finish(); producer.close(); // Ending the track releases the newest group too; idle cleanup reclaims the cache. clock += CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8 + 1; diff --git a/js/net/examples/publish.ts b/js/net/examples/publish.ts index e9c38f6d262..2b05eb0b8e3 100644 --- a/js/net/examples/publish.ts +++ b/js/net/examples/publish.ts @@ -32,9 +32,10 @@ async function publishTrack(track: Moq.Track.Producer) { } // Mark the group as complete - group.close(); + group.finish(); // Mark the track as complete (optional) + track.finish(); track.close(); } diff --git a/js/net/src/bandwidth.test.ts b/js/net/src/bandwidth.test.ts index 0b36595661b..e669bbae921 100644 --- a/js/net/src/bandwidth.test.ts +++ b/js/net/src/bandwidth.test.ts @@ -186,6 +186,7 @@ test("a closed track is pruned", () => { const first = track(VIDEO); first.subscribe(); const firstShare = allocator.reserve(first.demand(), 4_000_000); + first.finish(); first.close(); const second = track(VIDEO); diff --git a/js/net/src/bandwidth.ts b/js/net/src/bandwidth.ts index 4285b0a1217..4c03c690a5d 100644 --- a/js/net/src/bandwidth.ts +++ b/js/net/src/bandwidth.ts @@ -93,7 +93,7 @@ interface Registry { function wantsOf(entries: readonly Entry[]): Want[] { const wants: Want[] = []; for (const entry of entries) { - if (entry.demand.closed.peek() !== undefined) continue; + if (hooks.demandReleased(entry.demand).peek() !== undefined) continue; if (entry.demand.wanted.peek() === undefined) continue; wants.push({ id: entry.id, priority: entry.priority, max: entry.max }); } @@ -164,7 +164,7 @@ export class Allocator implements Handle { const entries = effect.get(this.#entries); for (const entry of entries) { effect.get(entry.demand.wanted); - effect.get(entry.demand.closed); + effect.get(hooks.demandReleased(entry.demand)); } this.#publish(); }); @@ -239,7 +239,7 @@ export class Allocator implements Handle { #prune(): void { const entries = this.#entries.peek(); - const live = entries.filter((entry) => entry.demand.closed.peek() === undefined); + const live = entries.filter((entry) => hooks.demandReleased(entry.demand).peek() === undefined); if (live.length !== entries.length) this.#entries.set(live); } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 103ed74d353..09073066205 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -51,6 +51,7 @@ test("consumer dedupes repeat subscriptions onto one upstream request", async () expect((await pendingRequest(consumer))?.name).toBe("audio"); // Once the shared track closes, a later subscribe re-opens it. + producer.finish(); producer.close(); consumer.track("video").subscribe(); expect((await pendingRequest(consumer))?.name).toBe("video"); @@ -104,6 +105,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); first.appendGroup().writeFrame(textFrame("a", Timestamp.fromMillis(1000))); + first.finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); @@ -116,6 +118,7 @@ test("a replacement producer refuses a rewind past its predecessor", () => { const track = next.createTrack("media", { timescale: Timescale.MILLI }); track.appendGroup().writeFrame(textFrame("c", Timestamp.fromMillis(500))); + second.finish(); second.close(); broadcast.close(); next.close(); @@ -127,12 +130,14 @@ test("an empty replacement ends past its predecessor's groups", async () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("media", { timescale: Timescale.MILLI }); first.appendGroup().finish(); + first.finish(); first.close(); const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); const subscriber = second.subscribe(); expect(() => second.finishAt({ group: 0, frame: 0 })).toThrow(ProtocolViolation); expect(() => second.finishAt({ group: 0, frame: 1 })).toThrow(ProtocolViolation); + second.finish(); second.close(); expect(await subscriber.finished()).toEqual({ group: 1, frame: 0 }); broadcast.close(); @@ -146,11 +151,13 @@ test("a removed producer writes nothing once its replacement claims the name", a const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); const subscriber = second.subscribe(); + second.finish(); second.close(); expect(() => first.appendGroup()).toThrow("replaced"); expect(() => first.writeGroup(new GroupProducer(1))).toThrow("replaced"); expect(() => first.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toThrow("replaced"); expect(await subscriber.finished()).toEqual({ group: 1, frame: 0 }); + first.finish(); first.close(); broadcast.close(); }); @@ -164,8 +171,9 @@ test("a removed producer's abort ignores its replacement's groups", async () => const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); for (let i = 0; i < 3; i++) second.appendGroup().finish(); const abort = new Error("cut off"); - first.close(abort); + first.abort(abort); expect(first.closed.peek()).toBe(abort); + second.finish(); second.close(); broadcast.close(); }); @@ -186,6 +194,7 @@ test("a replacement producer refuses to append a sequence the name already wrote firstProducer.writeGroup(new GroupProducer(8)); firstProducer.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); firstSubscriber.close(); + firstProducer.finish(); firstProducer.close(); const secondSubscriber = broadcast.track("media").subscribe(); @@ -209,6 +218,7 @@ test("a replacement producer refuses to append a sequence the name already wrote expect(nextRequest.accept({ timescale: Timescale.MILLI }).appendGroup().sequence).toBe(0); secondSubscriber.close(); + secondProducer.finish(); secondProducer.close(); nextSubscriber.close(); broadcast.close(); @@ -276,12 +286,14 @@ test("created and requested tracks share a name's sequences", async () => { expect(first.appendGroup().sequence).toBe(0); first.writeGroup(new GroupProducer(8)); first.insertDatagram(12, Timestamp.fromMillis(0), new Uint8Array()); + first.finish(); first.close(); // A replacement refuses to append past nothing, since that would reuse every explicit write. const second = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(() => second.appendGroup()).toThrow("duplicate sequence"); second.writeGroup(new GroupProducer(13)); + second.finish(); second.close(); // An on-demand producer checks the same namespace, and so does a later created track. @@ -294,10 +306,12 @@ test("created and requested tracks share a name's sequences", async () => { expect(() => requested.appendDatagram(Timestamp.fromMillis(0), new Uint8Array())).toThrow("duplicate sequence"); requested.insertDatagram(14, Timestamp.fromMillis(0), new Uint8Array()); subscriber.close(); + requested.finish(); requested.close(); const later = broadcast.createTrack("media", { timescale: Timescale.MILLI }); expect(() => later.appendGroup()).toThrow("duplicate sequence"); + later.finish(); later.close(); // Only a new broadcast starts over. @@ -326,8 +340,13 @@ test("a written name refuses to reuse its sequences after many other names", () const broadcast = new BroadcastProducer(); const written = broadcast.createTrack("written"); written.appendGroup().finish(); + written.finish(); written.close(); - for (let i = 0; i < 1000; i++) broadcast.createTrack(`unused-${i}`).close(); + for (let i = 0; i < 1000; i++) { + const unused = broadcast.createTrack(`unused-${i}`); + unused.finish(); + unused.close(); + } expect(() => broadcast.createTrack("written").appendGroup()).toThrow("duplicate sequence"); broadcast.close(); @@ -358,6 +377,7 @@ test("publishing-side subscriptions coalesce onto one request", async () => { await broadcast.demand().unused(); expect(producer.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -476,6 +496,7 @@ test("a held info lookup ending after removeTrack leaves demand alone", async () await Promise.resolve(); expect(demand.used.peek()).toBe(false); + track.finish(); track.close(); broadcast.close(); }); @@ -494,6 +515,7 @@ test("removeTrack leaves a track that only a request serves", async () => { expect(producer.closed.peek()).toBeUndefined(); subscriber.close(); + producer.finish(); producer.close(); broadcast.close(); }); @@ -511,6 +533,7 @@ test("closing a broadcast rejects a dequeued request", async () => { expect(() => producer.appendGroup()).toThrow("track is closed"); await expect(subscriber.info()).rejects.toThrow("track closed before info was known"); subscriber.close(); + producer.finish(); producer.close(); }); @@ -737,6 +760,7 @@ test("a finished track is still served from its cache", async () => { const broadcast = new BroadcastProducer(); const track = broadcast.createTrack("track1", { timescale: Timescale.MILLI }); track.writeFrame(textFrame("last")); + track.finish(); track.close(); // Like Rust, finishing keeps the track: a late subscriber reads the cache, then the clean end. @@ -753,7 +777,7 @@ test("a finished track is still served from its cache", async () => { test("an aborted track is gone, so a subscribe with no handler answers NotFound", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); // Nothing serves requests on demand, so this must fail rather than wait forever. const subscriber = broadcast.consume().track("track1").subscribe(); @@ -765,7 +789,7 @@ test("an aborted track is gone, so a subscribe with no handler answers NotFound" test("an aborted track falls through to a request when a handler is serving", async () => { const broadcast = new BroadcastProducer(); const pulled = wireOf(broadcast).requested(); - broadcast.createTrack("track1").close(new Error("boom")); + broadcast.createTrack("track1").abort(new Error("boom")); broadcast.track("track1").subscribe(); expect((await pulled)?.name).toBe("track1"); @@ -885,6 +909,7 @@ test("broadcast demand watches static and pending tracks rather than broadcast h await demand.unused(); expect(demand.used.peek()).toBe(false); request.reject(); + video.finish(); video.close(); broadcast.close(); expect(await demand.closed).toBeNull(); @@ -902,6 +927,7 @@ test("broadcast closure clears active demand and subscriptions", async () => { expect(demand.used.peek()).toBe(false); await demand.unused(); subscriber.close(); + track.finish(); track.close(); }); @@ -942,6 +968,7 @@ test("removeTrack stops counting the removed track's demand at once", async () = expect(demand.used.peek()).toBe(false); subscriber.close(); + video.finish(); video.close(); broadcast.close(); }); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 65d71072aab..9a26745509f 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -81,12 +81,12 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: if (state.demands.has(producer)) return; const demand = producer.demand(); // A closed track is never demand, and its close already fired, so nothing would dispose a watcher. - if (demand.closed.peek() !== undefined) return; + if (hooks.demandReleased(demand).peek() !== undefined) return; let active = false; const update = () => { const used = state.closed.peek() === undefined && - demand.closed.peek() === undefined && + hooks.demandReleased(demand).peek() === undefined && ((lookups?.pending ?? 0) > 0 || demand.wanted.peek() !== undefined); if (active === used) return; state.active += used ? 1 : -1; @@ -95,8 +95,8 @@ function watchDemand(state: BroadcastState, producer: track.Producer, lookups?: }; const disposeUsed = demand.wanted.subscribe(update); if (lookups) lookups.changed = update; - const disposeClosed = demand.closed.subscribe(() => { - if (demand.closed.peek() === undefined) return; + const disposeClosed = hooks.demandReleased(demand).subscribe(() => { + if (hooks.demandReleased(demand).peek() === undefined) return; cleanup(); }); const cleanup = () => { @@ -161,13 +161,13 @@ function closeState(state: BroadcastState) { function lookup(state: BroadcastState, name: string): track.Producer | undefined { const inserted = state.tracks.get(name); if (inserted) { - if (!(inserted.closed.peek() instanceof Error)) return inserted; + if (!(hooks.trackReleased(inserted).peek() instanceof Error)) return inserted; state.tracks.delete(name); } const requested = state.requests.get(name); if (requested) { - if (requested.closed.peek() === undefined) return requested; + if (hooks.trackReleased(requested).peek() === undefined) return requested; state.requests.delete(name); } @@ -193,7 +193,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce if (!state.served) { // Answer through the track rather than throwing, so a peer's subscribe is refused with // the code instead of an unhandled error. - producer.close(new NotFound(`track ${name}`)); + producer.abort(new NotFound(`track ${name}`)); return { producer, requested: false }; } @@ -201,7 +201,7 @@ function logical(state: BroadcastState, name: string): { producer: track.Produce state.lookups.set(producer, lookups); watchDemand(state, producer, lookups); state.requests.set(name, producer); - void producer.closed.then(() => { + void hooks.trackReleased(producer).then(() => { if (state.requests.get(name) === producer) state.requests.delete(name); }); @@ -231,7 +231,10 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) producer.close(); + if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) { + producer.finish(); + producer.close(); + } }; info.then( () => { @@ -315,7 +318,7 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info state.listing.listOwn(producer.name, producer, info); // A finished track keeps serving its cache, so only an abort evicts it. - void producer.closed.then((closed) => { + void hooks.trackReleased(producer).then((closed) => { if (!(closed instanceof Error)) return; if (state.tracks.get(producer.name) === producer) state.tracks.delete(producer.name); state.listing.unlistOwn(producer.name, producer); @@ -449,7 +452,7 @@ export class Producer { // One logical track per name: an open track or a live request already serves it. // A request a handler already pulled stays duplicate, as in Rust. const live = [this.#state.tracks.get(name), this.#state.requests.get(name)]; - if (live.some((existing) => existing && existing.closed.peek() === undefined)) { + if (live.some((existing) => existing && hooks.trackReleased(existing).peek() === undefined)) { throw new Error(`duplicate track: ${name}`); } diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 3ec036869ee..f6783b1ccee 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -687,6 +687,7 @@ for (const [name, teardown] of Object.entries(teardowns)) { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); teardown.end(handle, win); await clock.advance(Nano.fromMilli(teardown.linger)); @@ -779,6 +780,7 @@ for (const [name, drainEnd] of Object.entries(drainEnds)) { remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -844,6 +846,7 @@ test("a retired GOAWAY predecessor still closes by its handover cap", async () = remote.close(); reload.close(); for (const dial of dials) (await dial.server).abort(); + producer.finish(); producer.close(); broadcast.close(); origin.close(); @@ -904,6 +907,7 @@ test("closing the loop keeps the tokens until the session drains", async () => { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); reload.close(); diff --git a/js/net/src/flate/group.test.ts b/js/net/src/flate/group.test.ts index 58da8efaf4c..517e47c5cca 100644 --- a/js/net/src/flate/group.test.ts +++ b/js/net/src/flate/group.test.ts @@ -35,6 +35,7 @@ test("synchronous writers store slices; plain readers share one charged inflated expect(inflating).toHaveBeenCalledTimes(2); } finally { inflating.mockRestore(); + track.finish(); track.close(); } }); @@ -80,6 +81,7 @@ test("empty frames preserve the window and prewritten groups bind without duplic group.finish(); for (const expected of [payload, new Uint8Array(), payload]) expect((await reader.readFrame())?.payload).toEqual(expected); + track.finish(); track.close(); }); @@ -92,6 +94,7 @@ test("a cut compressed group preserves its committed frames before returning its group.abort(cut); expect((await reader.readFrame())?.payload).toEqual(payload); await expect(reader.readFrame()).rejects.toThrow(cut); + track.finish(); track.close(); }); @@ -105,6 +108,7 @@ test("a shared plain read cannot change a later synchronous write's admission", expect(() => group.writeFrame({ payload: new Uint8Array() })).not.toThrow(); group.finish(); expect((await reader.readFrame())?.payload.length).toBe(0); + track.finish(); track.close(); }); @@ -122,6 +126,7 @@ test("datagrams buffered before accept share one stored form, each reader its ow expect(stored(first)?.inflated).toBe(payload.length); expect(first.payload).toEqual(payload); expect(second.payload).toEqual(payload); + track.finish(); track.close(); }); @@ -134,6 +139,7 @@ test("a malformed compressed datagram is refused before reaching readers", () => /declared size/, ); expect(hooks.tryRecvDatagram(reader).kind).toBe("idle"); + track.finish(); track.close(); }); @@ -149,6 +155,7 @@ test("refresh measures stored slices without inflating a compressed group", () = } finally { inflating.mockRestore(); group.finish(); + track.finish(); track.close(); } }); diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 56979bfd8d4..95f37d45f99 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -100,6 +100,17 @@ test("a closed group rejects a further write without discarding committed frames expect((await consumer.readFrame())?.payload[0]).toBe(0); }); +test("an abort after finish keeps the clean outcome", async () => { + const { producer, consumer } = pair(0); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + producer.finish(); + producer.abort(new Error("boom")); + + expect(producer.closed.peek()).toBeNull(); + expect((await consumer.readFrame())?.payload[0]).toBe(1); + expect(await consumer.readFrame()).toBeUndefined(); +}); + test("a read that starts later skips frames below it instead of returning them", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index c1236f09bb4..365b7b47752 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -539,6 +539,7 @@ test("a SUBSCRIBE withdrawn while resuming releases its demand", async () => { const track = await first; while (track.subscription.peek() !== undefined) await track.subscription.changed(); // Gone, so the resume asks for the track again, and nothing answers. + track.finish(); track.close(); const second = wireOf(fx.broadcast).requested(); const parameters = new Parameters(); @@ -657,6 +658,7 @@ test("RENDEZVOUS_TIMEOUT has a zero cap with TIMEOUT for a requested wait", asyn expect(await wire.u53()).toBe(RequestError.id); expect((await RequestError.decode(wire, version)).errorCode).toBe(millis && !live ? 0x02 : 0x10); } finally { + track?.finish(); track?.close(); broadcast?.close(); origin.close(); @@ -692,6 +694,7 @@ test("reserved namespace requests get DOES_NOT_EXIST while other dot namespaces } } finally { request.cancel(); + track?.finish(); track?.close(); broadcast?.close(); claim?.close(); @@ -898,6 +901,7 @@ test("a group stream whose FIN never completes is reset when the group expires", // Its successor presents no further, so only the wall clock since it arrived convicts // group 0, after the track has ended. write(1); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - 1))); expect(reset).toBe(false); @@ -980,6 +984,7 @@ test("a group stream whose FIN never completes is reset after the cache evicts i const later = Milli(10_000); await clock.advance(Nano.fromMilli(later)); write(2); + track.finish(); track.close(); await clock.advance(Nano.fromMilli(Milli(budget - later - 1))); @@ -2646,7 +2651,11 @@ test("subscription completion sends PUBLISH_DONE on every supported draft", asyn expect(await client.reader.u53()).toBe(SubscribeOk.id); await SubscribeOk.decode(client.reader, version); - track.close(abort); + if (abort) track.abort(abort); + else { + track.finish(); + track.close(); + } expect(await client.reader.u53()).toBe(PublishDone.id); const done = await PublishDone.decode(client.reader, version); @@ -3499,6 +3508,7 @@ test.each([false, true])("draft-20: exact terminal 5/5 survives group FIN=%s", a } track.finishAt({ group: 5, frame: 5 }); group.finish(); + track.finish(); track.close(); if (finished) { const marker = await fx.uni.read(); @@ -3575,6 +3585,7 @@ test("draft-20: an inline terminal reset after its FIN falls back to a standalon track.finishAt({ group: 5, frame: 5 }); group.finish(); + track.finish(); track.close(); const inline = await reader.decode((c) => Frame.decode(c, header.flags, undefined)); @@ -3625,6 +3636,7 @@ test("draft-20: a clean close past a bounded filter's end still sends PUBLISH_DO // Beyond the end, so it is never served, and it must not hold the subscription open. writeGroup(track, 1); // group 1 + track.finish(); track.close(); expect(await client.reader.u53()).toBe(PublishDone.id); @@ -3839,6 +3851,7 @@ test("draft-20: PUBLISH_DONE waits for a queued group and counts every stream", try { writeGroup(track, 1); + track.finish(); track.close(); // Nothing ends the subscription while the group waits for its slot. @@ -4008,6 +4021,7 @@ test("requester FIN keeps a draft-19 subscription serving; STOP_SENDING cancels await serving; } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4037,6 +4051,7 @@ for (const version of [Version.DRAFT_17, Version.DRAFT_18] as const) { expect(track.subscription.peek()).toBeUndefined(); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4070,6 +4085,7 @@ test("REQUEST_UPDATE applies priority and preserves it when omitted", async () = expect(track.subscription.peek()?.priority).toBe(117); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4095,6 +4111,7 @@ test("REQUEST_UPDATE accepts delivery timeouts without changing the subscription expect(track.subscription.peek()).toEqual(before); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4129,6 +4146,7 @@ test("TRACK_PROPERTY_FILTER on a subscription update closes with PROTOCOL_VIOLAT expect(Number((failure as ProtocolViolation).code)).toBe(0x03); } finally { client.close(); + track.finish(); track.close(); fx.close(); await serving; @@ -4158,6 +4176,7 @@ test("unsupported REQUEST_UPDATE is refused and ends with UPDATE_FAILED", async expect(done.statusCode).toBe(0x08); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4221,6 +4240,7 @@ test("a SUBSCRIBE pauses without demand and resumes as a new subscriber", async } } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4248,6 +4268,7 @@ test("a paused SUBSCRIBE ends once its grant is revoked", async () => { expect((await PublishDone.decode(client.reader, V20)).statusCode).toBe(PublishDoneStatus.UNAUTHORIZED); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4295,6 +4316,7 @@ test("a pause resets a group stream parked on flow control", async () => { expect(reset).toBe(true); } finally { client.close(); + track.finish(); track.close(); fx.close(); } @@ -4458,6 +4480,7 @@ test("subscriber max age and REQUEST_UPDATE preserve omitted preferences", async expect(track.subscription.peek()?.maxDelay).toBe(Milli(900)); } finally { client.close(); + track.finish(); track.close(); fx.close(); } diff --git a/js/net/src/ietf/subscribe_tracks.test.ts b/js/net/src/ietf/subscribe_tracks.test.ts index a2e3140f685..2e868207fc2 100644 --- a/js/net/src/ietf/subscribe_tracks.test.ts +++ b/js/net/src/ietf/subscribe_tracks.test.ts @@ -197,6 +197,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(); + if (finished) track.finish(); if (finished) track.close(); broadcast.removeTrack("video"); expect(await stream.reader.u53()).toBe(PublishDone.id); @@ -218,6 +219,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -253,6 +255,7 @@ for (const version of drafts) { const listing = await fx.list(); await ok(listing, version); const { stream } = await fx.offered(false); + track.finish(); track.close(); broadcast.removeTrack("video"); await clock.advance(Nano.zero); @@ -287,6 +290,7 @@ for (const version of drafts) { published.set(publish.trackName, stream); } await clock.advance(Nano.zero); + video.finish(); video.close(); if (remove) broadcast.removeTrack("video"); broadcast.close(); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index bf458da1aff..b359681defb 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -846,7 +846,7 @@ export class Subscriber { maxAge = result.maxAge; break; } - if (demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) continue; + if (hooks.demandReleased(demand).peek() === undefined && demand.wanted.peek() !== undefined) continue; throw abandoned; } console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`); @@ -931,7 +931,7 @@ export class Subscriber { const unanswered = new Signal(0); const done = race([ this.#runPublishDone(stream, subscription, broadcast, unanswered).then(() => publisherEnded), - producer.closed.then(() => localEnded), + hooks.trackReleased(producer).then(() => localEnded), revoked, ]); const updates = this.#runUpdates(stream, requestId, producer, priority, maxAge, unanswered, done); @@ -943,7 +943,11 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; terminal = reason; break; @@ -955,8 +959,9 @@ export class Subscriber { // straight out of its loop for the same reason. if (terminal === revokedEnded) { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); - producer.close(refused); + producer.abort(refused); } else { + producer.finish(); producer.close(); } @@ -975,7 +980,7 @@ export class Subscriber { stream.abort(err); const e = await sessionCause(this.#quic, err); if (this.#localClose) hooks.releaseTrack(producer); - else producer.close(e); + else producer.abort(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); @@ -1033,7 +1038,7 @@ export class Subscriber { const { tail, track } = subscription; const complete = () => count > 0n && BigInt(tail.streams) >= count; - await tail.settle(complete, track.closed); + await tail.settle(complete, hooks.trackReleased(track)); } /** @@ -1103,7 +1108,7 @@ export class Subscriber { } catch (err) { // Nothing reached the wire, but the track can no longer follow its subscribers. console.warn(`subscribe update failed: id=${requestId} error=${reason(error(err))}`); - producer.close(error(err)); + producer.abort(error(err)); // Drafts 14 and 15 require the next request ID, so one spent and never sent would // stall every later request: as in Rust, that ends the session. if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -1572,7 +1577,7 @@ export class Subscriber { // cache. const frame = stream.tryDecode(decode) ?? - (await race([stream.decodeMaybe(decode), producer?.closed ?? track.closed])); + (await race([stream.decodeMaybe(decode), producer?.closed ?? hooks.trackReleased(track)])); if (!producer && (frame === null || frame instanceof Error)) { stream.stop(frame ?? new StreamError(StreamCode.Cancel)); return; @@ -1648,14 +1653,14 @@ export class Subscriber { if (e instanceof EndOfTrackProperties) { // The group before the marker is complete; only the track's end can't be carried. producer?.finish(); - track.close(e); + track.abort(e); } else if ( e instanceof ProtocolViolation || (e instanceof StreamError && e.code === StreamCode.MalformedTrack) ) { // The publisher broke the track's end or its content, which no later group can repair. producer?.abort(e); - track.close(e); + track.abort(e); } else { // A stream that fails before its first object still names a group, which the // reader sees fail rather than silently go missing. diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index f72d19eb684..52a163525ec 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1051,7 +1051,9 @@ test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at % for (;;) { const request = await wireOf(broadcast).requested(); if (!request) return; - request.accept({ timescale: Timescale.MILLI }).close(); + const accepted = request.accept({ timescale: Timescale.MILLI }); + accepted.finish(); + accepted.close(); } })(); } @@ -2872,7 +2874,7 @@ test("integration: lite draft-05 ends a track with the publisher's reset", async expect(await group?.readString()).toBe("head"); const reset = StreamCode(70); - for (const producer of served) producer.close(new StreamError(reset)); + for (const producer of served) producer.abort(new StreamError(reset)); const closed = await withTimeout( Promise.resolve(track.closed), Nano.fromMilli(Milli(2000)), diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index f9c7c5f09d2..14504ac7fd2 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -400,6 +400,13 @@ export const hooks: { * continues past it. Mirrors the Rust `close` on a `received()` copy; assigned by `track.ts`. */ releaseTrack: (producer: Producer) => void; + /** + * Settles once the producer is released (closed or aborted), unlike its public `closed`, + * which also settles on a reached end while the producer keeps serving. Assigned by `track.ts`. + */ + trackReleased: (producer: Producer) => GetPromise; + /** {@link trackReleased} for a demand handle. Assigned by `track.ts`. */ + demandReleased: (demand: Demand) => GetPromise; /** The producer's largest reported location and its subsequent changes. */ producerLargest: (producer: Producer) => Getter; @@ -566,6 +573,12 @@ export const hooks: { releaseTrack: () => { throw new Error("track.ts not loaded"); }, + trackReleased: () => { + throw new Error("track.ts not loaded"); + }, + demandReleased: () => { + throw new Error("track.ts not loaded"); + }, producerLargest: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 7f47d064fe5..47475f4c87f 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -76,13 +76,15 @@ test("a track leaving the table is removed", async () => { const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); broadcast.removeTrack("a"); - b.close(new Error("aborted")); + b.abort(new Error("aborted")); expect(await tracks.drain()).toEqual(["-a", "-b"]); }); test("a finished track is listed while the table holds it", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // It finished before it was removed. @@ -108,7 +110,9 @@ test("a lister finishes or removes a track", async () => { test("a recreated track leaves as it ended before it is added again", async () => { const broadcast = new BroadcastProducer(); - broadcast.createTrack("a").close(); + const a = broadcast.createTrack("a"); + a.finish(); + a.close(); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "synced"]); // The finished track still holds the name; its replacement takes it over. @@ -205,10 +209,12 @@ test("a clean end follows the removals before it", async () => { const live = broadcast.createTrack("live"); const tracks = list(broadcast); expect(await tracks.drain()).toEqual(["+a", "+b", "+live", "synced"]); + a.finish(); a.close(); broadcast.removeTrack("a"); broadcast.removeTrack("b"); broadcast.createTrack("unseen"); + live.finish(); live.close(); broadcast.close(); expect(await tracks.drain()).toEqual(["~a", "-b", "~live", "end"]); @@ -221,6 +227,7 @@ test("a clean end follows the removals before it", async () => { const own = both.createTrack("a"); const served = list(both); expect(await served.drain()).toEqual(["+a", "synced"]); + own.finish(); own.close(); both.close(); expect(await served.drain()).toEqual(["~a", "end"]); @@ -230,6 +237,7 @@ test("a clean end follows the removals before it", async () => { const gone = closing.createTrack("gone"); const reader = list(closing); expect(await reader.drain()).toEqual(["+gone", "synced"]); + gone.finish(); gone.close(); closing.removeTrack("gone"); closing.close(); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index 2ca070e1ba7..45a8d2e5342 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -14,6 +14,7 @@ */ import { type GetPromise, type Getter, Signal } from "@moq/signals"; import { StreamCode, StreamError } from "./error.ts"; +import { hooks } from "./internal.ts"; import type * as track from "./track.ts"; /** @@ -36,7 +37,7 @@ export type TracksEvent = /** Whether `own` closed cleanly, so its track leaves the listing as finished. */ function finished(own: track.Producer): boolean { - return own.closed.peek() === null; + return hooks.trackReleased(own).peek() === null; } /** One listed track and who lists it. */ diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 26ef77d27ec..b665548b359 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -245,6 +245,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { const last = producer.appendGroup(); last.writeFrame(textFrame("last")); last.finish(); + producer.finish(); producer.close(); const closing = server.close(); @@ -300,6 +301,7 @@ async function servedRawSubscription() { // Finish the track only once it is served: a track closed before anyone subscribed is gone. expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); // SUBSCRIBE_END, then the publisher's FIN. await subscriber.reader.readAll(); @@ -467,6 +469,7 @@ test("close waits for a request served while withdrawals are in flight", async ( startGroup: 0, }).encode(subscriber.writer, version); expect("start" in (await decodeSubscribeResponse(subscriber.reader, version))).toBe(true); + producer.finish(); producer.close(); await subscriber.reader.readAll(); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index f76c909fbbf..3231eae906d 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -217,6 +217,7 @@ async function subscribeEnd(sequences: number[], version: Version = Version.DRAF // arrives late opens no stream at all, so there is nothing firmer to wait on. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); try { @@ -819,6 +820,7 @@ test("lite draft-06: a cut group is dropped early, once", async () => { sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -858,6 +860,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, version); await flush(); + sub.track.finish(); sub.track.close(); const codes: number[] = []; for (;;) { @@ -928,6 +931,7 @@ test("lite draft-06: the end sweep does not drop a sent datagram", async () => { expect(await sub.servedSequence()).toBe(0); sub.track.insertDatagram(1, Timestamp.fromMillis(1), new Uint8Array([1])); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -954,6 +958,7 @@ test("lite draft-06: a cut dropped then re-created is not served", async () => { sub.serve(1); sub.serve(2); expect(await sub.servedSequence()).toBe(2); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1011,6 +1016,7 @@ test("lite draft-06: an update lowering the start is swept at the end", async () expect(await sub.servedSequence()).toBe(5); await replayUpdate({ priority: 0, startGroup: 2 }).encode(sub.client.writer, Version.DRAFT_06); await flush(); + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([[2, 4]]); } finally { @@ -1033,6 +1039,7 @@ test("lite draft-06: a raised floor is not dropped early", async () => { sub.serve(sequence); expect(await sub.servedSequence()).toBe(sequence); } + sub.track.finish(); sub.track.close(); expect(await drops(sub.client.reader, Version.DRAFT_06)).toEqual([]); } finally { @@ -1137,6 +1144,7 @@ for (const version of [Version.DRAFT_06, Version.DRAFT_07]) { hooks.startTrack(sub.track, { requested: 2 }); sub.serve(6); await flush(); + sub.track.finish(); sub.track.close(); const response = await decodeSubscribeResponse(sub.client.reader, version); if (!("start" in response)) throw new Error("expected SUBSCRIBE_START"); @@ -1634,6 +1642,7 @@ test("lite draft-07: subscribe end precedes groups below a declared finish", asy second.writeFrame(textFrame("hello")); second.finish(); track.writeGroup(second); + track.finish(); track.close(); const resp = await pending; @@ -1676,6 +1685,7 @@ async function heldOpenEnd() { group.writeFrame(textFrame("hello")); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1758,6 +1768,7 @@ test("lite draft-07: a group whose close fails is dropped", async () => { group.writeFrame(textFrame("hello")); group.finish(); track.writeGroup(group); + track.finish(); track.close(); const start = await decodeSubscribeResponse(client.reader, Version.DRAFT_07); @@ -1823,6 +1834,7 @@ test("blocked DROP responses stop opening later groups", async () => { await new Promise((resolve) => setImmediate(resolve)); expect(opens).toHaveBeenCalledTimes(8); release(); + track.finish(); track.close(); let dropped = 0; for (;;) { @@ -1889,6 +1901,7 @@ async function serve( // the order written rather than whatever the cache hands over in one batch. await new Promise((resolve) => setTimeout(resolve, 5)); } + track.finish(); track.close(); const reader = pair.client.incomingUnidirectionalStreams.getReader(); @@ -2187,6 +2200,7 @@ test("lite draft-05: a group waiting for a stream slot is dropped when the subsc test("lite draft-05: a group waiting for a stream slot survives the track finishing", async () => { const { client, track, freeSlot, outcome, close } = await saturatedGroup(); + track.finish(); track.close(); // SUBSCRIBE_END goes out while the group is still waiting for its slot. @@ -2863,6 +2877,7 @@ for (const version of [Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07]) { client.close(); server.close(); publisher.close(); + track.finish(); track.close(); broadcast.close(); origin.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 9f7c1a02beb..1911a5a7df0 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -837,7 +837,7 @@ export class Subscriber { state.revoked = refused; console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); state.cancel.abort(refused); - state.producer?.close(refused); + state.producer?.abort(refused); state.stream?.abort(refused); }); try { @@ -886,7 +886,7 @@ export class Subscriber { // its TRACK stream so a peer that never answers can't hold one per attempt. const retire = () => { state.cancel.abort(valid?.reason); - state.producer?.close(error(valid?.reason)); + state.producer?.abort(error(valid?.reason)); state.stream?.abort(error(valid?.reason)); }; valid?.addEventListener("abort", retire); @@ -952,7 +952,7 @@ export class Subscriber { // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. // Updates stop quietly at the FIN or a failed write, either of which can land before the // responses ahead of it are decoded, so only a refused update is terminal on its own. - const terminal: PromiseLike[] = [closed, producer.closed]; + const terminal: PromiseLike[] = [closed, hooks.trackReleased(producer)]; if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates.then(() => closed)); const done = race(terminal); @@ -963,11 +963,16 @@ export class Subscriber { const demand = producer.demand(); for (;;) { const reason = await race([done, unwanted(demand).then(() => idle)]); - if (reason === idle && demand.closed.peek() === undefined && demand.wanted.peek() !== undefined) + if ( + reason === idle && + hooks.demandReleased(demand).peek() === undefined && + demand.wanted.peek() !== undefined + ) continue; break; } + producer.finish(); producer.close(); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. @@ -976,7 +981,7 @@ export class Subscriber { console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { const e = await sessionCause(this.#quic, err); - producer.close(e); + producer.abort(e); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { @@ -1395,7 +1400,7 @@ export class Subscriber { entry.held?.close(); entry.held = undefined; if (!resp) { - if (entry.end === undefined && entry.track.closed.peek() === undefined) + if (entry.end === undefined && hooks.trackReleased(entry.track).peek() === undefined) throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END"); return; } @@ -1411,7 +1416,7 @@ export class Subscriber { if (entry.end !== undefined) throw new ProtocolViolation("duplicate SUBSCRIBE_END"); entry.end = resp.end.group; // A local close can win the race with the response; there is nothing left to end. - if (entry.track.closed.peek() !== undefined) continue; + if (hooks.trackReleased(entry.track).peek() !== undefined) continue; try { // Lower groups may still be on the wire behind a higher one, so readers hold // at a hole below the end until the tail settles. @@ -1457,7 +1462,7 @@ export class Subscriber { return tail.covers(start, end); }; - return tail.settle(complete, track.closed); + return tail.settle(complete, hooks.trackReleased(track)); } /** @@ -1479,7 +1484,7 @@ export class Subscriber { stream: Stream, ): Promise { const track = entry.track; - const stopped: Promise = race([track.closed, stream.reader.closed]).then(() => null); + const stopped: Promise = race([hooks.trackReleased(track), stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, order: msg.order, @@ -1605,7 +1610,7 @@ export class Subscriber { const violation = new ProtocolViolation( `group ${group.sequence} is at or past the declared end ${entry.end}`, ); - track.close(violation); + track.abort(violation); throw violation; } track.writeGroup(producer); @@ -1614,13 +1619,13 @@ export class Subscriber { // TRACK_INFO (or implicit defaults) resolves it on the subscribe stream. let scale = timescale.peek(); while (scale === undefined) { - if (track.closed.peek() !== undefined) { + if (hooks.trackReleased(track).peek() !== undefined) { // Subscription ended before the scale resolved; nothing to decode. producer.finish(); stream.stop(StreamCode.Cancel); return; } - await Signal.race(timescale, track.closed); + await Signal.race(timescale, hooks.trackReleased(track)); scale = timescale.peek(); } @@ -1799,7 +1804,7 @@ export class Subscriber { this.#closed.abort(cut); for (const { track } of this.#subscribes.values()) { - if (err) track.close(err); + if (err) track.abort(err); else hooks.releaseTrack(track); } diff --git a/js/net/src/resume.test-util.ts b/js/net/src/resume.test-util.ts index fd3a1a15fd1..41d2911028e 100644 --- a/js/net/src/resume.test-util.ts +++ b/js/net/src/resume.test-util.ts @@ -15,6 +15,7 @@ export function readsFrom(active: BroadcastConsumer | undefined, source: Broadca return sub.tryRecvGroup() !== undefined; } finally { sub.close(); + track.finish(); track.close(); source.removeTrack(name); } diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 5dac619b351..8ca55fa468e 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -364,7 +364,7 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on } } catch (err) { if (stopped) return; - output.close(err instanceof Error ? err : new Error(String(err))); + output.abort(err instanceof Error ? err : new Error(String(err))); broadcast.close(); return err; } @@ -376,7 +376,11 @@ test("a lagging same-epoch replica resumes from one publisher's bytes exactly on stopped = true; input.close(); release.resolve(); - output.close(err); + if (err) output.abort(err); + else { + output.finish(); + output.close(); + } broadcast.close(); }, }; @@ -568,7 +572,7 @@ test("a replacement claiming different track properties is refused once the old expect(sub.closed.peek()).toBeUndefined(); // The old copy failing with its route is what ends the track. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -587,7 +591,7 @@ test("waiting for the end of a track whose replacement was refused ends with the finished.catch(() => {}); // The old copy failing with its route leaves only the refusal. - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await expect(finished).rejects.toThrow("changed its properties"); sub.close(); @@ -795,7 +799,7 @@ test("a route that goes for good ends the track as its last copy does", async () ta.write(0); expect(drain(sub)).toEqual([0]); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); disposeA(); await settle(); expect(request.active.peek()).toBeUndefined(); @@ -901,7 +905,7 @@ test("a replacement spliced before the first copy answers is still checked again await settle(); expect((await sub.info()).priority).toBe(1); - old.close(new StreamError(StreamCode.SessionClosed)); + old.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(() => sub.tryRecvGroup()).toThrow("changed its properties"); @@ -1175,6 +1179,7 @@ test("draining groups to their end leaves buffered datagrams readable", async () const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); expect(drain(sub)).toEqual([0]); expect(await sub.recvGroup()).toBeUndefined(); @@ -1461,7 +1466,7 @@ test.each([true, false])( expect(dec.decode((await group.readFrame())?.payload)).toBe("1.0"); // The old route goes away, and the reader reads on and sees its copy end. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); const later = next(); await settle(); failing.abort(new StreamError(StreamCode.SessionClosed)); @@ -1745,7 +1750,7 @@ test("the largest follows the serving copy across a swap without moving back, an expect(hooks.peekLargest(sub).largest).toEqual({ group: 3, frame: 0 }); // The route fails: its copy's stream ends, the logical track's does not. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); let settled = false; const next = sub.largest().finally(() => { settled = true; @@ -1764,6 +1769,7 @@ test("the largest follows the serving copy across a swap without moving back, an // No route replaces this one: the stream ends with its copy. front.conclude(); const last = sub.largest(); + tb.producer.finish(); tb.producer.close(); expect(await last).toBeUndefined(); @@ -1966,7 +1972,7 @@ test.each([true, false])("a replaced copy backs a group before its first arrival front.serve(b.consume()); await settle(); // Its route fails once replaced: closing cleanly would declare the track ends at group 1. - ta.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); front.serve(c.consume()); await settle(); @@ -2009,6 +2015,7 @@ test("a clean end waits for a group the replaced copy still carries", async () = // The new route has only the group already handed out, and ends there. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2131,6 +2138,7 @@ test("the serving copy closing cleanly settles closed and leaves its datagrams r const sub = front.consumer.track("seq").subscribe(); ta.write(0); ta.producer.appendDatagram(undefined, enc.encode("d")); + ta.producer.finish(); ta.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); @@ -2317,6 +2325,7 @@ test.each(["lands", "expired"])( // The new route has only the group already handed out, and ends there. Nothing reads. tb.write(2); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2385,6 +2394,7 @@ test("a clean end keeps the serving copy while a group read from a replaced one // The new route has all of group 1 and ends there. tb.write(1, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); @@ -2419,6 +2429,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The new route ends at 3, while the old one still owes group 1. tb.write(2); tb.producer.finishAt({ group: 3, frame: 0 }); + tb.producer.finish(); tb.producer.close(); front.serve(b.consume()); await settle(); @@ -2458,6 +2469,7 @@ test("a clean end keeps the serving copy while a group handed out before the swa front.serve(b.consume()); await settle(); tb.write(0, 2); + tb.producer.finish(); tb.producer.close(); await settle(); expect(sub.closed.peek()).toBeNull(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 7a7c6d0cbf8..912c9b037c1 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -249,7 +249,7 @@ function subscribe(wire: BroadcastWire, name: string, subscription: track.Subscr return wire.subscribe(name, subscription); } catch (err) { const failed = new track.Producer(name); - failed.close(err instanceof Error ? err : new Error(String(err))); + failed.abort(err instanceof Error ? err : new Error(String(err))); return failed.subscribe(subscription); } } diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index c83f05f1372..1e0cd097928 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -69,6 +69,7 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); video.writeFrame(textFrame("2")); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["2"]); expect(await reader.closed).toBeNull(); @@ -94,6 +95,7 @@ test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( video.writeFrame(textFrame("1")); video.writeFrame(textFrame("2")); const ended = performance.now(); + video.finish(); video.close(); expect(await readAll(reader)).toEqual(["1", "2"]); diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 24a365b6f46..f7b24c8dccc 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -195,6 +195,7 @@ test("a replacement wants the group past its predecessor", () => { const broadcast = new BroadcastProducer(); const first = broadcast.createTrack("test"); first.writeGroup(new GroupProducer(7)); + first.finish(); first.close(); const second = broadcast.createTrack("test"); @@ -242,6 +243,7 @@ test("the group range bounds datagrams", async () => { for (const sequence of [4, 5, 7, 6]) { producer.insertDatagram(sequence, Timestamp.fromMillis(0), enc.encode("x")); } + producer.finish(); producer.close(); expect((await track.recvDatagram())?.sequence).toBe(5); @@ -256,6 +258,7 @@ for (const timed of [true, false]) { producer.appendDatagram(timestamp, enc.encode("before")); const subscriber = producer.subscribe({ maxDelay: Milli(50) }); const sequence = producer.appendDatagram(timestamp, enc.encode("after")); + producer.finish(); producer.close(); const datagram = await subscriber.recvDatagram(); expect(datagram?.sequence).toBe(sequence); @@ -273,6 +276,7 @@ test("datagram buffers drop oldest at capacity without delaying active subscribe producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("x")); expect((await fast.recvDatagram())?.sequence).toBe(sequence); } + producer.finish(); producer.close(); for (let sequence = count - 64; sequence < count; sequence++) { expect((await slow.recvDatagram())?.sequence).toBe(sequence); @@ -385,6 +389,7 @@ test("insertDatagram stale does not rewind after append", async () => { test("insertDatagram after close is refused", () => { const producer = new TrackProducer("test"); const ts = Timestamp.fromMillis(0); + producer.finish(); producer.close(); expect(() => producer.insertDatagram(0, ts, enc.encode("x"))).toThrow("track is closed"); expect(() => producer.appendDatagram(ts, enc.encode("x"))).toThrow("track is closed"); @@ -392,7 +397,7 @@ test("insertDatagram after close is refused", () => { test("insertDatagram after abort is refused", () => { const producer = new TrackProducer("test"); - producer.close(new Error("cancel")); + producer.abort(new Error("cancel")); expect(() => producer.insertDatagram(0, Timestamp.fromMillis(0), enc.encode("x"))).toThrow("track is closed"); }); @@ -446,6 +451,7 @@ test("subscriber maxAge is refused before defaults or updates take effect", () = // Publisher retention keeps its distinct maxAge field. producer.accept({ maxAge: Milli(500) }); track.close(); + producer.finish(); producer.close(); }); @@ -454,6 +460,7 @@ test("an unknown subscriber order is refused", () => { const producer = new TrackProducer("test"); expect(() => producer.subscribe({ order: "newest" } as unknown as Subscription)).toThrow(TypeError); expect(producer.subscription.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -836,6 +843,7 @@ test("a late lower group within the budget is delivered", async () => { late.finish(); expect((await track.recvGroup())?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -909,6 +917,7 @@ test("a stamped successor expires a group stalled before its first frame", async clock.set(11_000); producer.writeFrame({ payload: enc.encode("edge"), timestamp: Timestamp.fromMillis(1000) }); + producer.finish(); producer.close(); expect((await track.recvGroup())?.sequence).toBe(1); @@ -1115,6 +1124,7 @@ test("a fully read group still reports its expiry", async () => { expect(await raced).toMatchObject({ code: StreamCode.Old }); expect(group.expired.peek()).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1151,6 +1161,7 @@ test("a stalled track keeps only its newest group", () => { clock.set(12_000); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1176,6 +1187,7 @@ test("frozen timestamps still age out by wall clock", () => { clock.set(12_500); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([2]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1199,7 +1211,9 @@ test("a group shared by two tracks ages each from its own arrival", () => { // Group 0's successor joined the first track a full budget ago. expect(drain(first.subscribe({ maxDelay: Milli(1000) }))).toEqual([1]); } finally { + first.finish(); first.close(); + second.finish(); second.close(); clock.installed.close(); } @@ -1218,6 +1232,7 @@ test("a startup burst ages out by media time", () => { // behind, past 1.5s. Their successors arrived just now. expect(drain(producer.subscribe({ maxDelay: Milli(1500) }))).toEqual([2, 3, 4]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1254,6 +1269,7 @@ test("a parked read wakes at its wall deadline", async () => { await clock.advance(11_000); expect(await read).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1295,6 +1311,7 @@ test("an evicted successor with unread frames still starts the wall clock", asyn expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1340,6 +1357,7 @@ test("a failed successor hands the wall clock on after the cache evicts it", asy expect(stale).toBe(true); expect(await guarded).toMatchObject({ code: StreamCode.Old }); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1366,6 +1384,7 @@ test("a run of empty groups ages out by wall clock", () => { expect(drain(track)).toEqual([RUN + 1]); expect(drain(producer.subscribe({ maxDelay: Milli(1000) }))).toEqual([RUN + 1]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1388,6 +1407,7 @@ test("a live track retains its newest closed group through idle expiry", async ( expect(replay.tryRecvGroup()).toBeUndefined(); replay.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1434,6 +1454,7 @@ test("idle expiry reclaims an ended track's held mirror", async () => { if (!group) throw new Error("missing group"); expect(new TextDecoder().decode((await group.readFrame())?.payload)).toBe("first"); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1516,6 +1537,7 @@ test("an abandoned open group ages out with no further write", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(await read).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1536,6 +1558,7 @@ test("publisher maxAge does not change the idle cache deadline", async () => { await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); expect(idle.closed.peek()).toBeInstanceOf(TooFarBehind); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -1556,6 +1579,7 @@ test("retention pruning preserves clean EOF for a drained mirror", async () => { expect(await group.readString()).toBe("only"); expect(await group.readFrame()).toBeUndefined(); + producer.finish(); producer.close(); clock.set(10_000 + CACHE_WINDOW_MS + 200); producer.subscribe({ maxDelay: Milli(100) }); @@ -1614,6 +1638,7 @@ test("latency budget retains a consumed live-edge anchor", async () => { late.writeFrame({ payload: enc.encode("late"), timestamp: Timestamp.fromMillis(0) }); late.finish(); producer.writeGroup(late); + producer.finish(); producer.close(); expect(track.recvGroup()).resolves.toBeUndefined(); @@ -1661,11 +1686,13 @@ test.each(["arrival", "ordered"] as const)("subscription bounds stay reader-loca // Arrival readers can now offer the unread group below the old floor; ordered ones cannot rewind. if (!ordered) expect((await capped)?.sequence).toBe(0); expect((await (ordered ? capped : read()))?.sequence).toBe(4); + producer.finish(); producer.close(); expect(await read()).toBeUndefined(); } finally { subscriber.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1685,6 +1712,7 @@ test("subscription updates narrow the cursor before its first read", async () => } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1706,6 +1734,7 @@ test("a subscription cap chooses its own live edge", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1729,6 +1758,7 @@ test("subscription updates bound each reader's datagrams", async () => { } finally { track.close(); everything.close(); + producer.finish(); producer.close(); } }); @@ -1776,6 +1806,7 @@ test("withRange caps frame-level reads like the group cursor", async () => { producer.writeFrame({ payload: enc.encode("zero"), timestamp: Timestamp.fromMillis(0) }); producer.writeFrame({ payload: enc.encode("one"), timestamp: Timestamp.fromMillis(1) }); + producer.finish(); producer.close(); expect((await track.readFrame())?.group).toBe(0); @@ -1824,6 +1855,7 @@ test("recvGroup serves a late arrival after a newer group", async () => { // Staleness is the latency window's job, not arrival order's: the track // still finishes normally afterward. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); }); @@ -1844,6 +1876,7 @@ test("withRange parks recvGroup beyond the cap and a raised cap re-offers", asyn expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); // A clean close keeps the parked group claimable: the cap may still rise. + producer.finish(); producer.close(); expect(await Promise.race([pending, new Promise((resolve) => setTimeout(() => resolve("parked"), 10))])).toBe( "parked", @@ -1939,6 +1972,7 @@ test("closing the subscriber releases a recvGroup parked after a clean close", a const pending = track.recvGroup(); expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending"); + producer.finish(); producer.close(); track.close(); expect(await pending).toBeUndefined(); @@ -2018,6 +2052,7 @@ test("recvDatagram does not commit the group cursor", async () => { test("nextGroup returns undefined when track closes", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe().ordered(); + producer.finish(); producer.close(); expect(await track.nextGroup()).toBeUndefined(); }); @@ -2042,6 +2077,7 @@ test("a closed track still delivers a group parked above the cap once the cap is // Group 2 parks above the cap; a clean close must not resolve it as finished. const parked = track.nextGroup(); + producer.finish(); producer.close(); const timeout = new Promise((resolve) => setTimeout(() => resolve("pending"), 10)); expect(await Promise.race([parked, timeout])).toBe("pending"); @@ -2066,6 +2102,7 @@ test("end reports the head after the highest produced sequence, datagrams includ producer.writeGroup(new GroupProducer(0)); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); + producer.finish(); producer.close(); expect(track.end()).toEqual({ group: 2, frame: 0 }); }); @@ -2074,12 +2111,13 @@ test("end reports the head after the highest produced sequence, datagrams includ test("end is group 0 for an empty track and undefined after an abort", async () => { const empty = new TrackProducer("test"); const emptyTrack = empty.subscribe(); + empty.finish(); empty.close(); expect(emptyTrack.end()).toEqual({ group: 0, frame: 0 }); const aborted = new TrackProducer("test"); const abortedTrack = aborted.subscribe(); - aborted.close(new Error("boom")); + aborted.abort(new Error("boom")); expect(abortedTrack.end()).toBeUndefined(); }); @@ -2098,6 +2136,7 @@ test("readFrame does not livelock when a sole group finishes before the next arr const g1 = producer.appendGroup(); g1.writeFrame(textFrame("hello")); g1.finish(); + producer.finish(); producer.close(); }, 10); @@ -2144,6 +2183,7 @@ test("largest names the newest frame written", async () => { // Reading a group does not move the edge: it is what the track produced, not what is left. await subscriber.recvGroup(); const ended = subscriber.largest(); + producer.finish(); producer.close(); expect(await ended).toBeUndefined(); }); @@ -2165,6 +2205,7 @@ test("largest ends at the declared end while the producer stays open", async () group.finish(); expect(await ended).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -2192,6 +2233,7 @@ test("a late group below the largest changes nothing", async () => { late.writeFrame({ payload: enc.encode("b"), timestamp: Timestamp.now() }); expect(await subscriber.largest()).toBeUndefined(); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2223,6 +2265,7 @@ test("a recreated largest group is reported before the end", async () => { expect(await next).toEqual({ group: 0, frame: 1 }); again.finish(); expect(await subscriber.largest()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -2232,6 +2275,7 @@ test("a datagram raises the largest", async () => { producer.appendGroup().finish(); expect(producer.appendDatagram(Timestamp.now(), enc.encode("d"))).toBe(1); expect(await subscriber.largest()).toEqual({ group: 1, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2275,6 +2319,7 @@ test("largest reports only within the bounds", async () => { expect(await isPending(next)).toBe(true); framedGroup(producer, 8, 0); expect(await next).toEqual({ group: 8, frame: 0 }); + producer.finish(); producer.close(); }); @@ -2305,6 +2350,7 @@ test("largest follows the bounds as they move", async () => { expect(await next).toEqual({ group: 6, frame: 0 }); reader.withRange({ start: { included: 3 } }); expect(await isPending(reader.largest())).toBe(true); + producer.finish(); producer.close(); }); @@ -2313,6 +2359,7 @@ test("largest follows the bounds as they move", async () => { test("largest stays ended when the bounds widen", async () => { const producer = new TrackProducer("test").accept({}); framedGroup(producer, 5, 1).finish(); + producer.finish(); producer.close(); const reader = producer.subscribe().withRange({ end: { excluded: 3 } }); @@ -2345,6 +2392,7 @@ test("a live-edge reader starts on an empty largest group", async () => { const after = await reader.recvGroup(); expect(after?.sequence).toBe(2); expect(await after?.readFrame()).toBeDefined(); + producer.finish(); producer.close(); }); @@ -2361,6 +2409,7 @@ test("largest survives a mirror replay of an aborted group", async () => { const late = producer.subscribe(); expect(await late.largest()).toEqual({ group: 0, frame: MAX_GROUP_FRAMES - 1 }); + producer.finish(); producer.close(); }); @@ -2414,6 +2463,7 @@ test("a lowered range re-offers a cached cut with its error", async () => { expect(group?.sequence).toBe(0); await expect(group?.readString()).rejects.toBe(error); track.close(); + producer.finish(); producer.close(); }); @@ -2422,6 +2472,7 @@ test("a lowered range still re-offers after the producer closes", () => { const track = producer.subscribe({ maxDelay: Milli(5000), groups: { start: { included: 1 } } }); for (const sequence of [0, 1]) producer.writeGroup(new GroupProducer(sequence)); expect(track.tryRecvGroup()?.sequence).toBe(1); + producer.finish(); producer.close(); track.withRange({}); @@ -2440,6 +2491,7 @@ for (const abort of [undefined, new Error("gone")]) { track.withRange({}); expect(hooks.tryRecvGroup(track, false)).toEqual(abort ? { kind: "error", error: abort } : { kind: "done" }); + producer.finish(); producer.close(); }); } @@ -2467,6 +2519,7 @@ test("finishAt refuses an end at or below a produced sequence", () => { expect(() => producer.writeGroup(new GroupProducer(6))).toThrow(); expect(() => producer.appendGroup()).toThrow(); expect(() => producer.insertDatagram(6, Timestamp.now(), new Uint8Array(1))).toThrow(); + producer.finish(); producer.close(); }); @@ -2492,6 +2545,7 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect((await track.recvGroup())?.sequence).toBe(7); // A clean close keeps the declared end rather than the live edge. + producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); expect(track.end()).toEqual({ group: 8, frame: 0 }); @@ -2530,10 +2584,11 @@ for (const ordered of [false, true]) { expect(track.closed.peek()).toBeUndefined(); const aborted = new Error("cut off"); - producer.close(aborted); + producer.abort(aborted); await expect(receive()).rejects.toThrow("cut off"); await expect(received?.readString() ?? Promise.resolve()).rejects.toThrow("cut off"); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2561,6 +2616,7 @@ for (const channel of ["group", "datagram"]) { expect(await track.recvDatagram()).toBeUndefined(); expect(track.closed.peek()).toBeUndefined(); } finally { + producer.finish(); producer.close(); track.close(); } @@ -2570,7 +2626,7 @@ for (const channel of ["group", "datagram"]) { test("finished rejects when the track aborts or closes without an end", async () => { const aborted = new TrackProducer("test"); const pending = aborted.subscribe().finished(); - aborted.close(new Error("boom")); + aborted.abort(new Error("boom")); await expect(pending).rejects.toThrow("boom"); const producer = new TrackProducer("test"); @@ -2582,6 +2638,7 @@ test("finished rejects when the track aborts or closes without an end", async () const clean = new TrackProducer("test"); const reader = clean.subscribe(); clean.appendGroup().finish(); + clean.finish(); clean.close(); expect(await reader.finished()).toEqual({ group: 1, frame: 0 }); }); @@ -2608,6 +2665,7 @@ test("an omitted publisher limit still ages idle groups out", async () => { expect(after.tryRecvGroup()).toBeUndefined(); after.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2629,6 +2687,7 @@ test("a live track does not pin an aborted newest group", () => { expect(late.tryRecvGroup()).toBeUndefined(); late.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2645,6 +2704,7 @@ test("closing a track keeps an idle newest group for subscribers to drain", asyn // Idle well past the window, but protected while the track is live. clock.set(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + producer.finish(); producer.close(); expect(await (await subscriber.recvGroup())?.readString()).toBe("snapshot"); expect(await subscriber.recvGroup()).toBeUndefined(); @@ -2666,6 +2726,7 @@ test("a late subscriber to a closed unlimited track is released once the cache a const source = producer.appendGroup(); source.writeFrame(textFrame("last")); source.finish(); + producer.finish(); producer.close(); // Counts the map entries subscribing leaves behind, rather than the heap: any entry @@ -2708,7 +2769,7 @@ test("an abort keeps finished groups for a slow reader, then reports it", async } producer.appendGroup().writeFrame(textFrame("open")); const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { for (let i = 0; i < 2; i++) { @@ -2731,7 +2792,7 @@ test("an abort after the declared end settles ends clean", async () => { group.finish(); } producer.finishAt({ group: 2, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); for (const next of [() => arrival.recvGroup(), () => ordered.nextGroup()]) { expect((await next())?.sequence).toBe(0); @@ -2744,10 +2805,51 @@ test("an abort after the declared end reached by a datagram ends clean", () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); producer.appendDatagram(Timestamp.fromMillis(0), enc.encode("d")); producer.finishAt({ group: 1, frame: 0 }); - producer.close(new Error("boom")); + producer.abort(new Error("boom")); expect(producer.closed.peek()).toBeNull(); }); +test("finish is not terminal: closed settles once the groups below the end finish", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const group = producer.appendGroup(); + group.writeFrame(textFrame("late")); + producer.finish(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group below the end is still writable after finish. + group.writeFrame(textFrame("later")); + group.finish(); + await settle(); + expect(producer.closed.peek()).toBeNull(); + // Settled, not released: the producer keeps serving its cache. + expect(hooks.trackReleased(producer).peek()).toBeUndefined(); + + const read = await subscriber.recvGroup(); + expect(read?.sequence).toBe(0); + expect(await subscriber.recvGroup()).toBeUndefined(); + producer.close(); + expect(hooks.trackReleased(producer).peek()).toBeNull(); +}); + +test("closed reports an abort before the end settles", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.appendGroup().writeFrame(textFrame("open")); + producer.finish(); + const abort = new Error("boom"); + producer.abort(abort); + expect(producer.closed.peek()).toBe(abort); +}); + +test("close without finish aborts with a cancel", () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + producer.writeFrame(textFrame("only")); + producer.close(); + const closed = producer.closed.peek(); + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(StreamCode.Cancel); +}); + test("an ended track's buffered groups age out for a stale subscriber", async () => { for (const abort of [undefined, new Error("boom")]) { const clock = manualTime(10_000); @@ -2757,13 +2859,18 @@ test("an ended track's buffered groups age out for a stale subscriber", async () const group = producer.appendGroup(); group.writeFrame(textFrame("x")); group.finish(); - producer.close(abort); + if (abort) producer.abort(abort); + else { + producer.finish(); + producer.close(); + } // Nothing writes after close; the scheduled cleanup can lag by one scan slice. await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); if (abort) expect(() => stale.tryRecvGroup()).toThrow(abort); else expect(stale.tryRecvGroup()).toBeUndefined(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2777,7 +2884,7 @@ test("an abort leaves a group already taken readable, then reports the abort", a producer.appendGroup().writeFrame(textFrame("held")); const held = [await arrival.recvGroup(), await ordered.nextGroup()]; const boom = new Error("boom"); - producer.close(boom); + producer.abort(boom); for (const group of held) { expect(group?.sequence).toBe(0); @@ -2876,6 +2983,7 @@ test("unknown track metadata keeps presentation-time expiry until accepted", () producer.accept({ timescale: Timescale.MILLI }); expect(drain(producer.subscribe({ maxDelay: Milli(500) }))).toEqual([2, 3]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2905,6 +3013,7 @@ test("an untimed subscriber starts at the oldest fresh group", () => { producer.writeFrame({ payload: enc.encode("5") }); expect(drain(live)).toEqual([5]); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -2974,6 +3083,7 @@ test("explicit and producer-cut drops survive outside the group cache", async () expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 4, end: 5, error: cut }); reader.close(); + producer.finish(); producer.close(); }); @@ -2996,6 +3106,7 @@ test("a group ended by a cancel or cache policy is a hole, not a drop", async () for (const sequence of holes.keys()) expect(hooks.givenUp(reader, sequence).error).toBeUndefined(); reader.close(); + producer.finish(); producer.close(); }); @@ -3022,6 +3133,7 @@ test("an upstream drop ends a group still arriving with its error", async () => expect(await whole?.readString()).toBe("4"); reader.close(); + producer.finish(); producer.close(); }); @@ -3035,6 +3147,7 @@ test("a drop below the window is reported once the window lowers to it", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "drop", start: 2, end: 3, error: DROPPED }); reader.close(); + producer.finish(); producer.close(); }); @@ -3053,6 +3166,7 @@ test("a re-created cut group is served, not dropped", async () => { expect(recv.kind === "group" ? recv.group.sequence : recv).toBe(4); reader.close(); + producer.finish(); producer.close(); }); @@ -3075,6 +3189,7 @@ test("a wide drop is held and reported as one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3090,6 +3205,7 @@ test("adjacent drops with the same code merge into one run", () => { expect(hooks.tryRecvGroup(reader, true)).toEqual({ kind: "idle" }); reader.close(); + producer.finish(); producer.close(); }); @@ -3102,6 +3218,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => producer.writeGroup(group); for (let i = 0; i < 5; i++) group.writeFrame({ payload: new Uint8Array([i]) }); group.finish(); + producer.finish(); producer.close(); await clock.advance(10_000 + CACHE_WINDOW_MS + 1); expect(await producer.subscribe().largest()).toEqual({ group: 5, frame: 4 }); @@ -3111,6 +3228,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => expect(reader.end()).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { + producer.finish(); producer.close(); clock.installed.close(); } @@ -3130,6 +3248,7 @@ test("a wire end repeats or refines but never moves", () => { ]) { expect(() => hooks.endTrack(producer, moved)).toThrow(ProtocolViolation); } + producer.finish(); producer.close(); }); @@ -3143,6 +3262,7 @@ test("a mid-group end refuses to cut existing frames", () => { expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); expect(producer.subscribe().end()).toBeUndefined(); producer.finishAt({ group: 5, frame: 6 }); + producer.finish(); producer.close(); }); @@ -3164,6 +3284,7 @@ test.each([true, false])("a mid-group end caps later writes (declared first: %s) for (let i = 0; i < 3; i++) overfull.writeFrame({ payload: new Uint8Array([i]) }); group.abort(new Error("replaced")); expect(() => producer.writeGroup(overfull)).toThrow(ProtocolViolation); + producer.finish(); producer.close(); }); @@ -3270,6 +3391,7 @@ test("IETF provenance survives normalization on timed and untimed tracks", () => stampGroup(producer, 1, 500); producer.writeFrame({ payload: enc.encode("arrival"), timestamp: Timestamp.fromMillis(250) }); } finally { + producer.finish(); producer.close(); } }); @@ -3361,6 +3483,7 @@ test("an evicted group still bounds, and a late group below it is unverified", a const track = producer.subscribe({ maxDelay: Milli(60_000) }); expect(() => stampGroup(producer, 1, 4000)).toThrow(TimestampMismatch); stampGroup(producer, 1, 500); + producer.finish(); producer.close(); const timestamps = new Map(); @@ -3427,6 +3550,7 @@ test("a pending tail holds readers at a hole until it fills", async () => { expect((await next)?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3437,6 +3561,7 @@ test("a pending tail ends readers at the boundary once the track closes", async const next = reader.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); expect(await reader.closed).toBeNull(); @@ -3451,6 +3576,7 @@ test("without a declared start, the first arrival stands in for it", async () => producer.writeGroup(group); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3476,6 +3602,7 @@ test("a source start follows the current requested floor after an update", async expect(hooks.sourceStart(reader)).toBe(9); } finally { reader.close(); + producer.finish(); producer.close(); } }); @@ -3497,6 +3624,7 @@ test("a pending tail owes from SUBSCRIBE_START while the demand keeps its floor" expect((await lower.recvGroup())?.sequence).toBe(3); const next = lower.recvGroup(); expect(await settled(next)).toBe(false); + producer.finish(); producer.close(); expect(await next).toBeUndefined(); }); @@ -3522,6 +3650,7 @@ test("an end after the highest group holds the reader at the hole", async () => expect((await next)?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3534,7 +3663,7 @@ test("an abort errors a pending tail short of a group", async () => { const next = reader.recvGroup(); expect(await settled(next)).toBe(false); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); await expect(next).rejects.toThrow("dropped"); }); @@ -3542,7 +3671,7 @@ test("an abort after a full tail ends cleanly", async () => { const { producer, reader, write } = pendingTail(2); write(1); write(0); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); expect((await reader.recvGroup())?.sequence).toBe(0); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); @@ -3568,7 +3697,7 @@ for (const [waited, clean] of [ expect(await settled(next)).toBe(false); clock.set(waited); - producer.close(new Error("dropped")); + producer.abort(new Error("dropped")); if (clean) expect(await next).toBeUndefined(); else await expect(next).rejects.toThrow("dropped"); } finally { @@ -3592,6 +3721,7 @@ test("an evicted group still fills a pending tail", async () => { expect(producer.subscribe({ maxDelay: Milli(60_000) }).tryRecvGroup()?.sequence).toBe(2); expect((await reader.recvGroup())?.sequence).toBe(2); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); } finally { clock.installed.close(); @@ -3604,6 +3734,7 @@ test("a datagram fills its sequence in a pending tail", async () => { write(1); expect((await reader.recvGroup())?.sequence).toBe(1); expect(await reader.recvGroup()).toBeUndefined(); + producer.finish(); producer.close(); }); @@ -3618,10 +3749,11 @@ for (const abort of [false, true]) { expect(await settled(next)).toBe(false); producer.dropGroups(0, 2, DROPPED); - if (abort) producer.close(new Error("dropped")); + if (abort) producer.abort(new Error("dropped")); expect(await next).toBeUndefined(); if (abort) expect(await reader.closed).toBeNull(); else expect(reader.closed.peek()).toBeUndefined(); + producer.finish(); producer.close(); }); } diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 57301876f14..7413e5a2011 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -386,7 +386,11 @@ export class Request { /** Reject the request, closing the track optionally with an error. */ reject(err?: Error): void { this.#pending.delete(this); - this.#producer.close(err); + if (err) this.#producer.abort(err); + else { + this.#producer.finish(); + this.#producer.close(); + } } } @@ -426,7 +430,7 @@ export class Staged { return this.producer.accept(this.#info); } catch (err) { // Refused, so its subscribers see the error instead of waiting on an open request. - this.producer.close(err instanceof Error ? err : new Error(String(err))); + this.producer.abort(err instanceof Error ? err : new Error(String(err))); throw err; } } @@ -435,7 +439,7 @@ export class Staged { [Symbol.dispose](): void { if (this.#done) return; this.#done = true; - this.producer.close(new Error("staged track dropped")); + this.producer.abort(new Error("staged track dropped")); } } @@ -569,6 +573,9 @@ class TrackState { // end does not end readers yet. Mirrors the Rust `awaits_tail`. owed = new Signal(false); closed = new Once(); + // The producer's outcome, settled by its close or by a reached and settled end, whichever + // is first. Only the producer's own state uses it. + outcome = new Once(); update: Signal; /** Resolved once the producer commits the immutable properties. */ info = new Signal(undefined); @@ -695,6 +702,7 @@ export class Demand { static { makeDemand = (name, wanted, state) => new Demand(name, wanted, state); hooks.demandPriority = (demand) => demand.#state.info.peek()?.priority ?? DEFAULT_PRIORITY; + hooks.demandReleased = (demand) => demand.#state.closed; } /** @@ -709,9 +717,12 @@ export class Demand { return this.#wanted; } - /** Settles once the track closes: `null` on a clean close, or the abort {@link Error}. */ + /** + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled, or on a clean release, else the abort {@link Error}. Never because readers left. + */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } } @@ -816,7 +827,8 @@ export class Producer { hooks.producerLargest = (producer) => producer.#state.largest; hooks.joinProducer = (producer) => producer.#join(); hooks.endTrack = (producer, end) => producer.#endPending(end); - hooks.releaseTrack = (producer) => producer.#close(undefined, false); + hooks.releaseTrack = (producer) => producer.#close(undefined); + hooks.trackReleased = (producer) => producer.#state.closed; hooks.startTrack = (producer, served) => { producer.#served = served; producer.#owe(); @@ -954,11 +966,13 @@ export class Producer { } /** - * Settles once the track closes: `null` on a clean close, or the abort {@link Error}. + * Settles once the track's outcome is final: `null` once its declared end is reached and + * settled (every group below it finished) or on a clean release, else the abort + * {@link Error}. The producer stays open after a settled end, serving its cache. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. */ get closed(): GetPromise { - return this.#state.closed; + return this.#state.outcome; } /** @@ -987,7 +1001,7 @@ export class Producer { } } } catch (err) { - this.close(err instanceof Error ? err : new Error(String(err))); + this.abort(err instanceof Error ? err : new Error(String(err))); throw err; } this.#state.info.set(resolved); @@ -1651,6 +1665,12 @@ export class Producer { const end = this.#state.end.peek(); const owed = end !== undefined && this.#owes(finalSequence(end)); for (const state of [this.#state, ...this.#sinks]) state.owed.set(owed); + this.#settle(); + } + + // Settle the outcome cleanly once the declared end is reached and settled. + #settle(): void { + if (this.#state.outcome.peek() === undefined && this.#settled()) this.#state.outcome.set(null); } // Whether the pending tail misses a sequence from where the feed starts up to `final`: one @@ -1720,6 +1740,7 @@ export class Producer { // the end raised it already, so neither a late group below it nor a recreated copy of it // moves it after this. #endLargest(): void { + this.#settle(); if (this.#state.largestEnded.peek() || this.#largestOpen) return; const end = this.#state.end.peek(); if (end === undefined || this.#liveEdge() < finalSequence(end)) return; @@ -1727,35 +1748,55 @@ export class Producer { } /** - * Close the track and every subscriber, mirroring the abort to their groups. Idempotent. + * Declare the track's end after the highest sequence written to its name, including by an + * earlier producer this one replaces. A no-op once an end is declared or the track closed, + * so a cleanup path can finish whatever the track's state. * - * A clean close keeps the end {@link finishAt} declared, or declares one past the highest - * sequence produced; an abort ends without one. Subscribers still draining get the - * finished groups first, then the end or the abort. An abort after the declared end - * settled (reached, with every group below it finished) is a clean close. The groups - * left behind still age out on the idle cache window, so a stale subscriber can't pin - * them. + * Not terminal: groups below the end may still be written. Readers end once the live edge + * reaches it. */ - close(abort?: Error) { - this.#close(abort, true); + finish(): void { + if (this.#state.end.peek() !== undefined || this.#state.closed.peek() !== undefined) return; + this.#finishAt({ group: this.#liveEdge(), frame: 0 }); } - // `declare` is false for a received copy whose session closed locally: a dead route, not - // finished content, so a clean close there leaves the end undeclared. - #close(abort: Error | undefined, declare: boolean) { + /** + * Abort the track and every subscriber, mirroring the error to their open groups. Terminal + * and idempotent. + * + * Subscribers still draining get the finished groups first, then the error. An abort after + * the declared end settled (reached, with every group below it finished) only releases the + * track: readers end cleanly. The groups left behind still age out on the idle cache + * window, so a stale subscriber can't pin them. + */ + abort(error: Error): void { + this.#close(error); + } + + /** + * Release this producer, like dropping the last Rust producer. Idempotent. + * + * With an end declared ({@link finish} or {@link finishAt}), the track ends there and its + * subscribers drain to it. Without one, the track aborts: nothing finished it. + */ + close(): void { + if (this.#state.end.peek() !== undefined) this.#close(undefined); + else this.#close(new StreamError(StreamCode.Cancel, { message: `track ${this.name} closed without finish` })); + } + + // `abort` is undefined for a clean close, which keeps whatever end was declared. A received + // copy whose session closed locally closes cleanly without one: a dead route, not finished + // content. + #close(abort: Error | undefined) { if (this.#state.closed.peek() !== undefined) return; // A gap past the grace was lost before the abort rather than cut off by it. if (abort && this.#tailPending) this.#arrivals.expire(now(), this.#grace()); if (abort && this.#settled()) abort = undefined; - // A replacement continues its name's timeline: ending below an earlier producer's - // groups would contradict the ones subscribers already received. - if (declare && abort === undefined && this.#state.end.peek() === undefined) { - this.#declareEnd({ group: this.#liveEdge(), frame: 0 }); - } // Nobody will finish these, so a subscriber that has not taken one yet never sees it. // Not evicted: a reader already holding one keeps its frames and sees the abort. const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; closeTrackState(this.#state, abort); + if (this.#state.outcome.peek() === undefined) this.#state.outcome.set(abort ?? null); this.#sequence.holders--; if (this.#sequence.owner === this) this.#sequence.owner = undefined; for (const { group } of this.#cache) { diff --git a/js/net/src/untimed.test.ts b/js/net/src/untimed.test.ts index 9170bec2045..d984bb53ab5 100644 --- a/js/net/src/untimed.test.ts +++ b/js/net/src/untimed.test.ts @@ -89,10 +89,11 @@ async function republish(input: BroadcastConsumer): Promise { out.finish(); } // Closing the broadcast does not end a track inserted into it. + video.finish(); video.close(); } catch (err) { // An upstream abort goes downstream as an abort, never as a clean end. - video.close(err instanceof Error ? err : new Error(String(err))); + video.abort(err instanceof Error ? err : new Error(String(err))); } finally { copy.close(); } diff --git a/js/net/src/util/demand.ts b/js/net/src/util/demand.ts index 7f0789573a8..4f9a2384604 100644 --- a/js/net/src/util/demand.ts +++ b/js/net/src/util/demand.ts @@ -1,10 +1,11 @@ import { Signal } from "@moq/signals"; +import { hooks } from "../internal.ts"; import type { Demand } from "../track.ts"; /** Resolve once no subscriber wants the track, or it closed. */ export async function unwanted(demand: Demand): Promise { - while (demand.wanted.peek() !== undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() !== undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } } @@ -13,8 +14,8 @@ export async function unwanted(demand: Demand): Promise { * attaches no subscriber, so demand that never arrived is not abandonment. */ export async function abandoned(demand: Demand): Promise { - while (demand.wanted.peek() === undefined && demand.closed.peek() === undefined) { - await Signal.race(demand.wanted, demand.closed); + while (demand.wanted.peek() === undefined && hooks.demandReleased(demand).peek() === undefined) { + await Signal.race(demand.wanted, hooks.demandReleased(demand)); } for (;;) { await unwanted(demand); @@ -24,5 +25,5 @@ export async function abandoned(demand: Demand): Promise { /** Whether no subscriber wants the track now, or it closed. */ export function isAbandoned(demand: Demand): boolean { - return demand.closed.peek() !== undefined || demand.wanted.peek() === undefined; + return hooks.demandReleased(demand).peek() !== undefined || demand.wanted.peek() === undefined; } diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index f7ce74a3b3c..e2fa6760c7c 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -611,6 +611,7 @@ test("a failed encoder stays failed until the capture source changes", async () } finally { encoder.close(); capture.close(); + track.finish(); track.close(); await settle(); error.mockRestore(); diff --git a/js/publish/src/audio/encoder.test.ts b/js/publish/src/audio/encoder.test.ts index 2e040d5b01b..41de623f6e6 100644 --- a/js/publish/src/audio/encoder.test.ts +++ b/js/publish/src/audio/encoder.test.ts @@ -283,7 +283,10 @@ async function setup(order = new Order(), codec?: Codec, groupDuration?: Milli) const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const feed = new Feed(); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 0342be1de9a..5683694769a 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -423,7 +423,7 @@ export class Encoder { // A dead encoder can't serve anyone, so the current subscriber and every later one get // the real error rather than a track that stays silent. - if (fatal) track?.close(fatal); + if (fatal) track?.abort(fatal); effect.set(this.#out.active, enabled && !!format && !!track && !fatal, false); }); @@ -745,7 +745,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - this.#live?.track.close(cause); + this.#live?.track.abort(cause); } /** Stop and release everything this holds. */ diff --git a/js/publish/src/broadcast.test.ts b/js/publish/src/broadcast.test.ts index f9d90dad24d..01909482aea 100644 --- a/js/publish/src/broadcast.test.ts +++ b/js/publish/src/broadcast.test.ts @@ -167,6 +167,7 @@ test("subscriber demand hands the producer to the rendition and clears it when t // Closing the producer (encoder error / teardown) clears the signal, with no lingering per-subscription // effect watching it. + track?.finish(); track?.close(); await settle(); expect(rendition.track.peek()).toBeUndefined(); @@ -339,7 +340,7 @@ test("replacing a closed rendition track keeps the broadcast and publishes a fre // The encoder wrote, then failed. track.appendGroup().finish(); - track.close(new Error("encoder failed")); + track.abort(new Error("encoder failed")); video.close(); await settle(); @@ -435,7 +436,7 @@ test("registering a sibling after an encoder failure keeps the network broadcast await settle(); const track = video.track.peek(); if (!track) throw new Error("expected demanded video track"); - track.close(new Error("encoder failed")); + track.abort(new Error("encoder failed")); await settle(); broadcast.audio("audio/new").config.set(audioConfig); await settle(); @@ -600,7 +601,7 @@ test("a replacement track name is never reused, even one never served", async () const fail = async (name: string) => { const sub = net.track(name).subscribe(); await settle(); - video.track.peek()?.close(new Error("encoder failed")); + video.track.peek()?.abort(new Error("encoder failed")); video.close(); await settle(); sub.close(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 8e5562f058f..85d7c80facc 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -366,6 +366,7 @@ export class Broadcast { const kinds = new Map(); this.#kinds = kinds; effect.cleanup(() => { + for (const { track } of tracks.values()) track.finish(); for (const { track } of tracks.values()) track.close(); if (this.#kinds === kinds) this.#kinds = new Map(); }); @@ -397,7 +398,10 @@ export class Broadcast { maxAge: Milli(Number.MAX_SAFE_INTEGER), priority: Catalog.PRIORITY.catalog, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); this.catalog.serve(track, effect, { compression }); } } @@ -417,11 +421,12 @@ export class Broadcast { let entry = tracks.get(rendition.name); const cause = entry && errors.get(entry.owner)?.peek(); - if (entry && cause) entry.track.close(cause); + if (entry && cause) entry.track.abort(cause); const name = names[rendition.name] ?? rendition.name; if (entry && entry.track.name !== name) { // The catalog moved this rendition to a new name for new media. End the old track // cleanly on its last identity, so its subscribers follow the catalog. + entry.track.finish(); entry.track.close(); entry = undefined; } else if (entry && entry.owner !== rendition && entry.track.closed.peek() !== undefined) { @@ -448,7 +453,7 @@ export class Broadcast { if (error) { inner.run((failure) => { const cause = failure.get(error); - if (cause) served.close(cause); + if (cause) served.abort(cause); }); } inner.run((demand) => { diff --git a/js/publish/src/video/capture.test.ts b/js/publish/src/video/capture.test.ts index 0e46e581472..a2dd356f559 100644 --- a/js/publish/src/video/capture.test.ts +++ b/js/publish/src/video/capture.test.ts @@ -458,6 +458,7 @@ test("an encoder resuming on a still source encodes a keyframe of the current pi } finally { encoder.close(); capture.close(); + track.finish(); track.close(); clock.mockRestore(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); @@ -556,6 +557,7 @@ test("an encoder torn down while a frame is in flight closes that frame", async } finally { encoder.close(); capture.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); diff --git a/js/publish/src/video/encoder-stall.test.ts b/js/publish/src/video/encoder-stall.test.ts index b0902272fbd..8441cfb25f5 100644 --- a/js/publish/src/video/encoder-stall.test.ts +++ b/js/publish/src/video/encoder-stall.test.ts @@ -109,7 +109,14 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l const order = new Order(); // An on-time audio rendition shares the broadcast, writing each capture as it happens. const audio = new Estimator(); - const rendition = { config: new Signal(undefined), track: new Signal(track), close: () => track.close() }; + const rendition = { + config: new Signal(undefined), + track: new Signal(track), + close: () => { + track.finish(); + track.close(); + }, + }; const encoder = new Encoder("video", { broadcast: { video: () => rendition, order } as never, capture: { @@ -164,6 +171,7 @@ test("a slower-than-realtime encoder drops input instead of ratcheting catalog l } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); await clock.advance(Nano.zero); installed.close(); diff --git a/js/publish/src/video/encoder.test.ts b/js/publish/src/video/encoder.test.ts index c5a663372b2..92b261a3941 100644 --- a/js/publish/src/video/encoder.test.ts +++ b/js/publish/src/video/encoder.test.ts @@ -116,7 +116,10 @@ test("encoding tracks encoder config in its child effect", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const broadcast = { video: () => rendition, order: new Order() }; const capture = { @@ -154,7 +157,10 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op const rendition = { config: new Signal(undefined), track: live, - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal(undefined) }, @@ -181,11 +187,13 @@ test("a demand gap marks a discontinuity and leaves the broadcast-owned track op expect(track.closed.peek()).toBeUndefined(); discontinuity.mockClear(); + track.finish(); track.close(); encoder.close(); expect(discontinuity).not.toHaveBeenCalled(); } finally { encoder.close(); + track.finish(); track.close(); discontinuity.mockRestore(); } @@ -203,7 +211,10 @@ test("a bandwidth estimate updates the bitrate without blanking the config or re const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -285,7 +296,10 @@ test("every published config fits a probe for its own codec", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { @@ -615,7 +629,10 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const source = new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }); const capture = { @@ -671,6 +688,7 @@ test("disabling encodes one black keyframe and keeps the rendition with enabled: } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -778,7 +796,10 @@ test("the probe encodes with the frame rate and bitrate ceiling", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, @@ -951,7 +972,10 @@ async function recordKeyframes( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -975,6 +999,7 @@ async function recordKeyframes( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1219,7 +1244,10 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) } as never) }, @@ -1270,6 +1298,7 @@ test("a shrinking capture resizes in band below the advertised ceiling", async ( } finally { encoder.close(); fanout.close(); + track.finish(); track.close(); if (original) Object.defineProperty(globalThis, "VideoEncoder", original); else Reflect.deleteProperty(globalThis, "VideoEncoder"); @@ -1287,7 +1316,10 @@ test("the catalog bitrate and frame rate only rise", async () => { const rendition = { config: new Signal(undefined), track: new Signal(track), - close: () => track.close(), + close: () => { + track.finish(); + track.close(); + }, }; const capture = { in: { source: new Signal({ getSettings: () => ({ frameRate: 30 }), getConstraints: () => ({}) }) }, diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 6828af26c58..804fc462aae 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -561,7 +561,7 @@ export class Encoder { const cause = error instanceof Error ? error : new Error(String(error)); this.#failure.peek().set(cause); this.#out.error.set(cause); - track.close(cause); + track.abort(cause); } // Publishes the catalog for the probe: its codec string, the coded-size ceiling, and the bitrate and diff --git a/js/room/src/chat.test.ts b/js/room/src/chat.test.ts index 4b342004491..ceeece0e18a 100644 --- a/js/room/src/chat.test.ts +++ b/js/room/src/chat.test.ts @@ -45,7 +45,7 @@ test("chat rejects non-string window records and propagates track failures", asy timestamp: Time.Timestamp.now(), }); await expect(subscriber.recv()).rejects.toThrow("chat record must be a string"); - track.close(new Error("chat aborted")); + track.abort(new Error("chat aborted")); await expect(subscriber.recv()).rejects.toThrow("chat aborted"); } finally { subscriber.close(); diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 1c7067a0854..f4d6e3a4a17 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -159,7 +159,10 @@ function serveSnapshot( maxAge: Milli(86_400_000), priority: PRIORITY, }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const producer = new Json.Snapshot.Producer({ track }); effect.cleanup(() => producer.finish()); diff --git a/js/watch/src/audio/decoder.test.ts b/js/watch/src/audio/decoder.test.ts index 4abb76e2486..80599b43212 100644 --- a/js/watch/src/audio/decoder.test.ts +++ b/js/watch/src/audio/decoder.test.ts @@ -940,6 +940,7 @@ async function livePlayback(burst: number) { }, // Rename the rendition, publishing the rest of the timeline on the new track. rename(name: string, patch: Record = {}) { + track.finish(); track.close(); track = producer.createTrack(name, { timescale: Moq.Time.Timescale.MILLI }); sequence = 0; diff --git a/js/watch/src/audio/playout.test.ts b/js/watch/src/audio/playout.test.ts index adc680ac30b..5ee4387f408 100644 --- a/js/watch/src/audio/playout.test.ts +++ b/js/watch/src/audio/playout.test.ts @@ -112,6 +112,7 @@ async function measure(t: Arrival[], budget: (target: Milli) => Milli): Promise< subscriber.update({ maxDelay: budget(current) }); } } finally { + track.finish(); track.close(); consumer.close(); await reader; diff --git a/js/watch/src/audio/replay.ts b/js/watch/src/audio/replay.ts index 58eda7488ba..d164695f3a1 100644 --- a/js/watch/src/audio/replay.ts +++ b/js/watch/src/audio/replay.ts @@ -287,6 +287,7 @@ export async function* replay(trace: Arrival[], options: Options): AsyncGenerato delivery.resolve(); abort.abort(); consumer.close(); + track.finish(); track.close(); await decode; signals.close(); diff --git a/js/watch/src/media.test.ts b/js/watch/src/media.test.ts index 9dd5b22c67b..7a5605f325c 100644 --- a/js/watch/src/media.test.ts +++ b/js/watch/src/media.test.ts @@ -53,7 +53,7 @@ for (const end of [ const consumer = new Container.Consumer(track.subscribe(), { format: new Container.Legacy.Format("data") }); try { const pending = nextMedia(consumer); - track.close(end); + track.abort(end); if (end instanceof Moq.Error.Stream) expect(await pending).toBeUndefined(); else await expect(pending).rejects.toBe(end); } finally { diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts index dd4c12354a4..75393e8eceb 100644 --- a/js/watch/src/video/decoder.test.ts +++ b/js/watch/src/video/decoder.test.ts @@ -962,6 +962,7 @@ it("a renamed video track keeps the delay, so audio neither skips nor goes silen const before = { inserted, played, buffered: ring.length }; silent = 0; + (playback.track as Moq.Track.Producer).finish(); (playback.track as Moq.Track.Producer).close(); playback.switchRendition("video/1"); video.replace(playback.producer.createTrack("video/1", { timescale: Moq.Time.Timescale.MILLI })); diff --git a/test/audio-quality/clients/js/src/capture.test.ts b/test/audio-quality/clients/js/src/capture.test.ts index 9e3a0d1053e..d33382742ee 100644 --- a/test/audio-quality/clients/js/src/capture.test.ts +++ b/test/audio-quality/clients/js/src/capture.test.ts @@ -32,7 +32,7 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(20_000), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(20_000)), }); - late.close(); + late.finish(); await read(late.consume()); expect(captured).toEqual([[10, 20, 1, 20]]); now = 50; @@ -40,15 +40,15 @@ test("arrival capture stamps a later group before a stalled earlier group", asyn timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - early.close(); + early.finish(); await waiting; expect(captured).toEqual([ [10, 20, 1, 20], [50, 0, 0, 20], ]); } finally { - early.close(); - late.close(); + early.finish(); + late.finish(); clock.mockRestore(); } }); @@ -59,7 +59,7 @@ test("arrival capture excludes duration markers", async () => { timestamp: Time.Timestamp.fromMicros(100_000), payload: Legacy.encodeFrame(new Uint8Array(), Micro(100_000)), }); - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) captured.push(arrival); @@ -187,7 +187,7 @@ test("closing the observer waits for in-flight groups and keeps their decode fai }); test("a reset group truncates its arrivals without failing capture", async () => { - const error = await captureGroupEnd((group) => group.close(new NetError.TooFarBehind())); + const error = await captureGroupEnd((group) => group.abort(new NetError.TooFarBehind())); expect(error).toBeUndefined(); }); @@ -203,7 +203,7 @@ test("arrival durations come from Opus packets despite missing or differently si payload: Legacy.encodeFrame(new Uint8Array([toc, 0]), Micro(timestamp)), }); } - group.close(); + group.finish(); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config })) { captured.push(arrival); @@ -221,7 +221,7 @@ test("arrival capture uses AAC's codec duration when the container leaves it imp timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([1]), Micro(0)), }); - group.close(); + group.finish(); const aac = Catalog.AudioConfigSchema.parse({ ...config, codec: "mp4a.40.2", sampleRate: 44100 }); const captured: Arrival[] = []; for await (const arrival of arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config: aac })) { @@ -236,7 +236,7 @@ test("arrival capture refuses a packet with no usable duration", async () => { timestamp: Time.Timestamp.fromMicros(0), payload: Legacy.encodeFrame(new Uint8Array([0x7b]), Micro(0)), }); - group.close(); + group.finish(); const reader = arrivals(group.consume(), { decoder: new Legacy.Format("audio"), config }); await expect(reader.next()).rejects.toThrow("no encoded frame duration for opus"); }); diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 8493f3d4c4e..c6db7e9610e 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -99,12 +99,12 @@ if (mode === "status") { assert.equal((await commands.next()).value, "probe", "missing queued-request barrier"); const group = first.appendGroup(); group.writeFrame({ payload: new Uint8Array([1]) }); - group.close(); + group.finish(); const demand = second.demand(); while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const next = second.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("request credit resumed"); } finally { diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index e44b127ac69..a29ca196458 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -31,7 +31,7 @@ const marker = origin.createBroadcast(Moq.Path.from("replacement")); const ready = marker.createTrack("ready"); const markerGroup = ready.appendGroup(); markerGroup.writeFrame({ payload: new Uint8Array([42]) }); -markerGroup.close(); +markerGroup.finish(); marker.announce(); type Socket = { @@ -173,7 +173,7 @@ if (mode === "credit") { try { const probe = firstTrack.appendGroup(); probe.writeFrame({ payload: new Uint8Array([1]) }); - probe.close(); + probe.finish(); const data = await first.recvGroup(); assert(data); assert.deepEqual((await data.readFrame())?.payload, new Uint8Array([1])); @@ -182,7 +182,7 @@ if (mode === "credit") { await queued; const next = secondTrack.appendGroup(); next.writeFrame({ payload: new Uint8Array([2]) }); - next.close(); + next.finish(); const resumed = await second.recvGroup(); assert(resumed); assert.deepEqual((await resumed.readFrame())?.payload, new Uint8Array([2])); @@ -215,7 +215,7 @@ if (mode === "credit") { while (demand.wanted.peek() === undefined) await demand.wanted.changed(); const group = video.appendGroup(); group.writeFrame({ payload: new Uint8Array(32) }); - group.close(); + group.finish(); assert.equal((await commands.next()).value, "first group"); await initial.control.writer.u53(GoAway.id); await new GoAway({ @@ -227,7 +227,7 @@ if (mode === "credit") { initial.connection.abort(); const next = video.appendGroup(); next.writeFrame({ payload: new Uint8Array(32).fill(1) }); - next.close(); + next.finish(); assert.equal((await commands.next()).value, "done"); console.error("js goaway redirect resumed"); } finally { diff --git a/test/interop/clients/js-native/tail.ts b/test/interop/clients/js-native/tail.ts index a3cdc4154ec..23f6ebb9aa5 100644 --- a/test/interop/clients/js-native/tail.ts +++ b/test/interop/clients/js-native/tail.ts @@ -62,14 +62,15 @@ try { for (let sequence = 0; sequence < GROUPS; sequence++) { const group = track.appendGroup(); if (drop && sequence === 1) { - group.close(new Error("cut")); + group.abort(new Error("cut")); continue; } for (const size of compress ? [BYTES / 2, 0, BYTES / 2] : [BYTES]) { group.writeFrame({ payload: new Uint8Array(size).fill(sequence) }); } - group.close(); + group.finish(); } + track.finish(); track.close(); } } diff --git a/test/interop/clients/js/open-gop/main.ts b/test/interop/clients/js/open-gop/main.ts index d851fc3dd8c..87d02089059 100644 --- a/test/interop/clients/js/open-gop/main.ts +++ b/test/interop/clients/js/open-gop/main.ts @@ -64,7 +64,7 @@ window.runRecovery = async (packets) => { let group: Moq.Group.Producer | undefined; for (const packet of packets) { if (group?.sequence !== packet.group) { - group?.close(); + group?.finish(); group = new Moq.Group.Producer(packet.group); track.writeGroup(group); } @@ -75,7 +75,8 @@ window.runRecovery = async (packets) => { timestamp: Moq.Time.Timestamp.fromMicros(timestamp), }); } - group?.close(); + group?.finish(); + track.finish(); track.close(); const media = producer.consume(); const config = Catalog.VideoConfigSchema.parse({ diff --git a/test/interop/clients/js/src/finish.ts b/test/interop/clients/js/src/finish.ts index 57c4b99b751..2f5344dc542 100644 --- a/test/interop/clients/js/src/finish.ts +++ b/test/interop/clients/js/src/finish.ts @@ -142,7 +142,8 @@ export function finish( const healthTrack = health.createTrack("live"); const group = healthTrack.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("session-alive") }); - group.close(); + group.finish(); + healthTrack.finish(); healthTrack.close(); health.announce(); state.ended = true; diff --git a/test/p2p/src/main.ts b/test/p2p/src/main.ts index 84dea2b2e91..bea74b9be3d 100644 --- a/test/p2p/src/main.ts +++ b/test/p2p/src/main.ts @@ -110,6 +110,7 @@ window.checkTransport = async () => { client?.abort(); server?.abort(); transport.close(); + track.finish(); track.close(); broadcast.close(); publishing.close(); diff --git a/test/receive-credit/sender.ts b/test/receive-credit/sender.ts index b2fb5d7b3cb..05e304230b9 100644 --- a/test/receive-credit/sender.ts +++ b/test/receive-credit/sender.ts @@ -60,19 +60,21 @@ try { } else if (command === "high") { const group = high.appendGroup(); group.writeFrame({ payload: new Uint8Array(window / 2).fill(2) }); - group.close(); + group.finish(); } else if (command === "after") { const group = low.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("after") }); - group.close(); + group.finish(); } else { throw new Error(`unknown command: ${command}`); } } } finally { // End the live tracks, or the close waits out its deadline for them. - partial?.close(); + partial?.finish(); + low.finish(); low.close(); + high.finish(); high.close(); await session.close(); origin.close(); diff --git a/test/video/src/captions.ts b/test/video/src/captions.ts index a28f6a03349..727ca911525 100644 --- a/test/video/src/captions.ts +++ b/test/video/src/captions.ts @@ -22,7 +22,7 @@ export function captions() { const track = producer.createTrack(name); const group = track.appendGroup(); group.writeFrame({ payload: Legacy.encodeFrame(new TextEncoder().encode(text), Micro(1_000_000)) }); - group.close(); + group.finish(); } producer.announce(); const broadcast = new Broadcast({ origin, name: Path.from("captions"), catalogFormat: "manual", catalog }); diff --git a/test/video/src/poster.ts b/test/video/src/poster.ts index 18a50b0bd11..893d70e919b 100644 --- a/test/video/src/poster.ts +++ b/test/video/src/poster.ts @@ -122,8 +122,8 @@ async function poster(audio: boolean, buffered: boolean) { }, close() { player.close(); - group.close(); - audioGroup?.close(); + group.finish(); + audioGroup?.finish(); producer.close(); origin.close(); canvas.remove(); diff --git a/test/wasm/baseline/publisher.ts b/test/wasm/baseline/publisher.ts index 2a0ade8def1..85701f07bd7 100644 --- a/test/wasm/baseline/publisher.ts +++ b/test/wasm/baseline/publisher.ts @@ -18,6 +18,7 @@ async function prepare(config: Workload): Promise { websocket: { enabled: false }, }); cleanup.push(() => { + track.finish(); track.close(); broadcast.close(); connection.abort(); @@ -31,7 +32,7 @@ async function prepare(config: Workload): Promise { const group = new Moq.Group.Producer(config.sequence); for (const [frame, bytes] of fixtures.entries()) group.writeFrame({ payload: bytes, timestamp: Moq.Time.Timestamp.fromMillis(frame) }); - group.close(); + group.finish(); track.writeGroup(group); // Keep the track and broadcast served until every reader completes. No timing arm or tail-close race. })(), diff --git a/test/wasm/src/main.ts b/test/wasm/src/main.ts index 2412828ded7..55ae14368dc 100644 --- a/test/wasm/src/main.ts +++ b/test/wasm/src/main.ts @@ -172,22 +172,25 @@ async function withPublisher( for (const frame of FIXTURE) { group.writeFrame({ payload: frame, timestamp: Moq.Time.Timestamp.now() }); } - group.close(); + group.finish(); } + track.finish(); track.close(); })(); const rejecting = (async () => { while (missingUsed.peek() === undefined && missing.closed.peek() === undefined) { await Promise.race([missingUsed.changed(), missing.closed]); } - if (missingUsed.peek() !== undefined) missing.close(new Error("no such track: missing")); + if (missingUsed.peek() !== undefined) missing.abort(new Error("no such track: missing")); })(); try { return await run(advance); } finally { stopped = true; + track.finish(); track.close(); + missing.finish(); missing.close(); broadcast.close(); // Teardown, not a graceful end: the case is over and the harness is not waiting From 66661b719171e71d6ec482551a77bfbe79ff3e7e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:39:27 -0700 Subject: [PATCH 07/30] feat(net)!: JS Lister.closed, unused rejects on close, readers drop end() Lister.ended is renamed closed. Group and broadcast Demand.unused now reject once the handle closes, as Rust's do, instead of reading a closed handle as unused. Subscriber.end() and Ordered.end() are removed: readers await finished(); publishers read the snapshot through a private hook. Co-Authored-By: Claude Opus 5.5 --- js/net/src/broadcast.test.ts | 3 ++- js/net/src/broadcast.ts | 6 ++++-- js/net/src/group.test.ts | 14 ++++++++++++++ js/net/src/group.ts | 7 +++++-- js/net/src/ietf/publisher.ts | 4 ++-- js/net/src/ietf/subscriber.ts | 4 ++-- js/net/src/ietf/tail.test.ts | 9 +++++---- js/net/src/integration.test.ts | 6 +++++- js/net/src/internal.ts | 8 ++++++++ js/net/src/listing.test.ts | 2 +- js/net/src/listing.ts | 4 ++-- js/net/src/lite/publisher.ts | 4 ++-- js/net/src/lite/subscriber.ts | 23 ++++++++++++++++------- js/net/src/lite/tail.test.ts | 10 +++++----- js/net/src/resume.test.ts | 10 +++++----- js/net/src/resume.ts | 2 +- js/net/src/tail.test.ts | 4 ++-- js/net/src/track.test.ts | 20 ++++++++++---------- js/net/src/track.ts | 18 ++---------------- 19 files changed, 93 insertions(+), 65 deletions(-) diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index 09073066205..210d398fb08 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -925,7 +925,8 @@ test("broadcast closure clears active demand and subscriptions", async () => { expect(demand.used.peek()).toBe(true); broadcast.close(); expect(demand.used.peek()).toBe(false); - await demand.unused(); + // Like Rust, a closed broadcast rejects rather than reporting it unused. + await expect(demand.unused()).rejects.toThrow("broadcast is closed"); subscriber.close(); track.finish(); track.close(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 9a26745509f..30e28d94858 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -346,9 +346,11 @@ export class Demand { return this.#state.used; } - /** Wait until every track becomes unused or the broadcast closes. */ + /** Wait until every track becomes unused. Throws once the broadcast closes. */ async unused(): Promise { - while (this.#state.used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + if (this.#state.closed.peek() !== undefined) throw new Error("broadcast is closed"); + if (!this.#state.used.peek()) return; await Signal.race(this.#state.used, this.#state.closed); } } diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 95f37d45f99..020236d849e 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -29,6 +29,20 @@ test("used reflects mirror demand and unused resolves when the last reader leave expect(producer.demand().used.peek()).toBe(false); }); +test("unused rejects once the group closes, even while read", async () => { + const producer = new Producer(0); + const reader = producer.mirror(); + const pending = producer.demand().unused(); + const abort = new Error("boom"); + producer.abort(abort); + await expect(pending).rejects.toBe(abort); + + const finished = new Producer(1); + finished.finish(); + await expect(finished.demand().unused()).rejects.toThrow("group is closed"); + reader.close(); +}); + function pair(sequence: number) { const producer = new Producer(sequence); return { producer, consumer: producer.consume() }; diff --git a/js/net/src/group.ts b/js/net/src/group.ts index d9e34e2c251..7dd9f330b19 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -138,9 +138,12 @@ export class Demand { return this.#used; } - /** Wait until all mirror readers leave or the group closes. */ + /** Wait until all mirror readers leave. Throws once the group closes: its abort error, or a clean-close error. */ async unused(): Promise { - while (this.#used.peek() && this.#state.closed.peek() === undefined) { + for (;;) { + const closed = this.#state.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("group is closed"); + if (!this.#used.peek()) return; await Signal.race(this.#used, this.#state.closed); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 882537e4bff..baa0070f5a3 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -998,7 +998,7 @@ export class Publisher { // Draft 14 on has no end location in PUBLISH_DONE: an END_OF_TRACK object is what // tells the subscriber where the track ended. - const final = track.end(); + const final = hooks.subscriberEnd(track); if (ended && !publishError && final !== undefined && !streams.terminalSent) { await this.#runEndOfTrack({ requestId: msg.requestId, @@ -1184,7 +1184,7 @@ export class Publisher { // The track ends right after this group's last object, so the marker rides this // stream rather than a separate one. - const end = track.end(); + const end = hooks.subscriberEnd(track); const marked = end?.group === group.sequence && end.frame === next; if (marked) { await new Frame({ endOfTrack: true }).encode( diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index b359681defb..763c1d329c7 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -745,8 +745,8 @@ export class Subscriber { try { for (;;) { while (!lister.used.peek()) { - if (lister.ended.peek() !== undefined) return; - await race([Signal.race(lister.used, lister.ended)]); + if (lister.closed.peek() !== undefined) return; + await race([Signal.race(lister.used, lister.closed)]); } lister.abort(new Error("track listing requires moq-lite-07 or newer")); } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index 23a16bf55f8..7e4203bb3d1 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; +import { hooks } from "../internal.ts"; import { GRACE } from "../ledger.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; @@ -182,7 +183,7 @@ test("END_OF_TRACK after a group's last object ends the track after that group", await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 4, frame: 1 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 4, frame: 1 }); }); test("END_OF_TRACK at object 0 ends the track before its group, which never exists", async () => { @@ -198,7 +199,7 @@ test("END_OF_TRACK at object 0 ends the track before its group, which never exis await done(TRACK_ENDED, 2n); expect((await reader.recvGroup())?.sequence).toBe(0); expect(await reader.recvGroup()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async () => { @@ -207,7 +208,7 @@ test("a standalone END_OF_TRACK at object 5 preserves its terminal group", async end.write(new Uint8Array([5, 0, 0x4])); end.finish(); await end.handled; - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); await done(TRACK_ENDED, 1n); expect(await reader.recvGroup()).toBeUndefined(); }); @@ -227,7 +228,7 @@ test("a compact stream starting at object 5 is subgroup 5, refused without a gro huge.write(new Uint8Array([...new Array(9).fill(0xff), 1, 42])); huge.finish(); await huge.handled; - expect(reader.end()).toBeUndefined(); + expect(hooks.subscriberEnd(reader)).toBeUndefined(); const group = groupStream(subscriber, 6, { ...FLAGS, hasSubgroupObject: true }); group.write(object("6.0")); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 52a163525ec..9047f886e47 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -2820,7 +2820,11 @@ async function runSessionDeath( client.abort(); server.abort(); origin.close(); - return [closed, await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), track.end()]; + return [ + closed, + await withTimeout(groupEnd, Nano.fromMilli(Milli(2000)), "the group never ended"), + hooks.subscriberEnd(track), + ]; } for (const [name, protocol, version] of [ diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 14504ac7fd2..dcc47126632 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -411,6 +411,11 @@ export const hooks: { producerLargest: (producer: Producer) => Getter; /** Re-evaluate a live-edge join at this subscriber's copy. */ + /** + * The track's exclusive end as this subscriber's copy knows it now, for a publisher + * forwarding it. Readers await `finished()` instead. Assigned by `track.ts`. + */ + subscriberEnd: (subscriber: Subscriber) => Position | undefined; joinSubscriber: (subscriber: Subscriber) => void; /** Re-evaluate a live-edge join at a producer. */ joinProducer: (producer: Producer) => void; @@ -585,6 +590,9 @@ export const hooks: { joinProducer: () => { throw new Error("track.ts not loaded"); }, + subscriberEnd: () => { + throw new Error("track.ts not loaded"); + }, joinSubscriber: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index 47475f4c87f..f1bc8291c7a 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -404,5 +404,5 @@ test("a lister sees the broadcast end", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); broadcast.close(); - expect(lister.ended.peek()).toBeNull(); + expect(lister.closed.peek()).toBeNull(); }); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index 45a8d2e5342..9f56711dbc6 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -429,8 +429,8 @@ export class Lister { return this.#listing.used; } - /** Settles once the broadcast ended. */ - get ended(): GetPromise { + /** Settles once the broadcast ended, as every listing of it does. */ + get closed(): GetPromise { return this.#listing.closed; } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 089effdb8b6..96eefef6105 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1177,7 +1177,7 @@ export class Publisher { // Lite cannot name a frame, so a mid-group end rounds up past its group, whose own // stream FIN says where it stops. const boundary = () => { - const end = track.end(); + const end = hooks.subscriberEnd(track); return end ? finalSequence(end) : (hooks.latestSequence(track) ?? -1) + 1; }; @@ -1320,7 +1320,7 @@ export class Publisher { } } // A declared end goes out while lower groups are still being produced. - if (!endSent && track.end() !== undefined) { + if (!endSent && hooks.subscriberEnd(track) !== undefined) { if (!(await sendEnd())) return false; continue; } diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 1911a5a7df0..a4a2494718d 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -666,9 +666,9 @@ export class Subscriber { async #runListing(broadcast: Path.Valid, consumed: Consumed, lister: Lister) { try { for (;;) { - if (lister.ended.peek() !== undefined || this.#closed.signal.aborted) return; + if (lister.closed.peek() !== undefined || this.#closed.signal.aborted) return; if (!lister.used.peek()) { - await untilAborted(race([Signal.race(lister.used, lister.ended)]), this.#closed.signal).catch( + await untilAborted(race([Signal.race(lister.used, lister.closed)]), this.#closed.signal).catch( () => {}, ); continue; @@ -703,8 +703,8 @@ export class Subscriber { ?.ready(broadcast, "subscribe", { signal: setup, until: async () => { - while (lister.used.peek() && lister.ended.peek() === undefined) { - await Signal.race(lister.used, lister.ended); + while (lister.used.peek() && lister.closed.peek() === undefined) { + await Signal.race(lister.used, lister.closed); } }, }) @@ -1349,7 +1349,10 @@ export class Subscriber { let unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); // A decode consumes its frame whenever it lands, so one outstanding across a re-arm is // kept and awaited again rather than abandoned with its frame. let pending: Promise | undefined; @@ -1365,7 +1368,10 @@ export class Subscriber { unused = group .demand() .unused() - .then((): typeof idle => idle); + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ); continue; } // Abandoned mid-group: the truncated group must never end clean. @@ -1838,7 +1844,10 @@ async function untilAbandoned(group: netGroup.Producer, step: Promise): Pr group .demand() .unused() - .then((): typeof idle => idle), + .then( + (): typeof idle => idle, + (): typeof idle => idle, + ), ]), ); if (value !== idle) return value as T; diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index 88e2f7bed09..f3fff8cf33b 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -139,7 +139,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await readAll(await reader.recvGroup())).toEqual(["1.0"]); expect(await reader.recvGroup()).toBeUndefined(); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); }); test("a group read across the subscribe stream's FIN is delivered whole", async () => { @@ -221,7 +221,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver await settle(); expect(ended).toBe(false); expect(reader.closed.peek()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 2, frame: 0 }); // The grace gives up on group 0, and the settled subscription ends the reader. now = GRACE; @@ -309,7 +309,7 @@ describe.each([Version.DRAFT_05, Version.DRAFT_06, Version.DRAFT_07])("%s", (ver expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 0, frame: 0 }); }); // lite-05 specified an inclusive end, so there the group costs only its own stream. @@ -436,7 +436,7 @@ test("lite-07 settles a dropped range without grace", async () => { expect(await settlesWithin(reader.recvGroup(), 1000)).toBe(true); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 3, frame: 0 }); }); for (const started of [false, true]) { @@ -474,7 +474,7 @@ test("lite-07 FIN waits for the skipped and reset final range to settle", async finished = true; }); // Wait for SUBSCRIBE_END to be decoded without advancing any timers. - while (reader.end()?.group !== 3) await Promise.resolve(); + while (hooks.subscriberEnd(reader)?.group !== 3) await Promise.resolve(); expect(finished).toBe(false); last.reset(); await last.handled; diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 8ca55fa468e..a5e41f7ff9b 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -2167,14 +2167,14 @@ test("an end a replaced copy declared holds across a swap", async () => { // b serves before its subscription learns the end. front.serve(b.consume()); await settle(); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); expect(await sub.finished()).toEqual({ group: 2, frame: 0 }); // A copy declaring another end claims different content. tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); expect(sub.closed.peek()).toBeInstanceOf(Error); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); // Refused, the reader holds no copy. expect(ta.producer.demand().wanted.peek()).toBeUndefined(); expect(tb.producer.demand().wanted.peek()).toBeUndefined(); @@ -2199,7 +2199,7 @@ test("an end a replaced copy declared holds once the copy is let go in the same // A datagram read lets go of the replaced copy before its end is reported. ta.producer.finishAt({ group: 2, frame: 0 }); expect(hooks.tryRecvDatagram(sub).kind).toBe("idle"); - expect(sub.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 2, frame: 0 }); tb.producer.finishAt({ group: 3, frame: 0 }); await settle(); @@ -2231,7 +2231,7 @@ test("a replaced copy whose session closed locally binds no end", async () => { tb.write(1); await settle(); expect(drain(sub)).toEqual([1]); - expect(sub.end()).toBeUndefined(); + expect(hooks.subscriberEnd(sub)).toBeUndefined(); expect(sub.closed.peek()).toBeUndefined(); sub.close(); @@ -2440,7 +2440,7 @@ test("a retained copy declaring another end after a clean end fails the reads", // The copy still holding group 1 claims another end: its content is refused. ta.producer.finishAt({ group: 5, frame: 0 }); await settle(); - expect(sub.end()).toEqual({ group: 3, frame: 0 }); + expect(hooks.subscriberEnd(sub)).toEqual({ group: 3, frame: 0 }); await expect(sub.recvGroup()).rejects.toThrow("changed its properties"); } finally { sub.close(); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 912c9b037c1..5fbac752882 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -890,7 +890,7 @@ class Reader implements ResumeSource { // refuses an end below what it received. #learnEnd(): track.Position | undefined { for (const copy of this.#copies) { - const end = copy.sub.end(); + const end = hooks.subscriberEnd(copy.sub); if (end) this.#declare(end); } const declared = this.#declared; diff --git a/js/net/src/tail.test.ts b/js/net/src/tail.test.ts index 1e0cd097928..4a78dee9a44 100644 --- a/js/net/src/tail.test.ts +++ b/js/net/src/tail.test.ts @@ -73,7 +73,7 @@ test.each([Lite.ALPN_05, Lite.ALPN_06])( video.close(); expect(await readAll(reader)).toEqual(["2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 3, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 3, frame: 0 }); } finally { close(); } @@ -100,7 +100,7 @@ test.each([Ietf.ALPN.DRAFT_16, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_20])( expect(await readAll(reader)).toEqual(["1", "2"]); expect(await reader.closed).toBeNull(); - expect(reader.end()).toEqual({ group: 4, frame: 0 }); + expect(await reader.finished()).toEqual({ group: 4, frame: 0 }); // Every counted stream arrived, so nothing waited out the grace. expect(performance.now() - ended).toBeLessThan(TAIL_GRACE_MS); } finally { diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index f7b24c8dccc..79ebaa8ad40 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2098,13 +2098,13 @@ test("a closed track still delivers a group parked above the cap once the cap is test("end reports the head after the highest produced sequence, datagrams included", async () => { const producer = new TrackProducer("test"); const track = producer.subscribe(); - expect(track.end()).toBeUndefined(); + expect(hooks.subscriberEnd(track)).toBeUndefined(); producer.writeGroup(new GroupProducer(0)); producer.appendDatagram(Timestamp.fromMillis(1), enc.encode("x")); producer.finish(); producer.close(); - expect(track.end()).toEqual({ group: 2, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 2, frame: 0 }); }); // 0 is the only encoding for "nothing produced"; an abort declares no boundary at all. @@ -2113,12 +2113,12 @@ test("end is group 0 for an empty track and undefined after an abort", async () const emptyTrack = empty.subscribe(); empty.finish(); empty.close(); - expect(emptyTrack.end()).toEqual({ group: 0, frame: 0 }); + expect(hooks.subscriberEnd(emptyTrack)).toEqual({ group: 0, frame: 0 }); const aborted = new TrackProducer("test"); const abortedTrack = aborted.subscribe(); aborted.abort(new Error("boom")); - expect(abortedTrack.end()).toBeUndefined(); + expect(hooks.subscriberEnd(abortedTrack)).toBeUndefined(); }); test("readFrame does not livelock when a sole group finishes before the next arrives", async () => { @@ -2531,7 +2531,7 @@ test("finishAt declares an end ahead of the live edge without ending the track", expect(first?.sequence).toBe(5); producer.finishAt({ group: 8, frame: 0 }); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); expect(await track.finished()).toEqual({ group: 8, frame: 0 }); // Not terminal: groups below the end still arrive, including a straggler. @@ -2548,10 +2548,10 @@ test("finishAt declares an end ahead of the live edge without ending the track", producer.finish(); producer.close(); expect(await track.recvGroup()).toBeUndefined(); - expect(track.end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(track)).toEqual({ group: 8, frame: 0 }); // A late subscriber sees the same end. - expect(producer.subscribe().end()).toEqual({ group: 8, frame: 0 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 8, frame: 0 }); }); for (const ordered of [false, true]) { @@ -3225,7 +3225,7 @@ test("a mid-group end survives eviction and a filtered late reader", async () => // The cap excludes the only group, so nothing is within it. const reader = producer.subscribe().withRange({ end: { excluded: 5 } }); expect(await reader.largest()).toBeUndefined(); - expect(reader.end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(reader)).toEqual({ group: 5, frame: 5 }); reader.close(); } finally { producer.finish(); @@ -3240,7 +3240,7 @@ test("a wire end repeats or refines but never moves", () => { hooks.endTrack(producer, { group: 6, frame: 0 }); hooks.endTrack(producer, { group: 5, frame: 5 }); hooks.endTrack(producer, { group: 5, frame: 5 }); - expect(producer.subscribe().end()).toEqual({ group: 5, frame: 5 }); + expect(hooks.subscriberEnd(producer.subscribe())).toEqual({ group: 5, frame: 5 }); for (const moved of [ { group: 5, frame: 4 }, { group: 6, frame: 0 }, @@ -3260,7 +3260,7 @@ test("a mid-group end refuses to cut existing frames", () => { producer.writeGroup(group); for (let i = 0; i < 6; i++) group.writeFrame({ payload: new Uint8Array([i]) }); expect(() => producer.finishAt({ group: 5, frame: 5 })).toThrow(ProtocolViolation); - expect(producer.subscribe().end()).toBeUndefined(); + expect(hooks.subscriberEnd(producer.subscribe())).toBeUndefined(); producer.finishAt({ group: 5, frame: 6 }); producer.finish(); producer.close(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 7413e5a2011..47635c526d7 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -2144,6 +2144,8 @@ export class Subscriber { static { makeSubscriber = (name, state) => new Subscriber(name, state); + hooks.subscriberEnd = (subscriber) => + subscriber.#resume ? subscriber.#resume.end() : subscriber.#state.end.peek(); hooks.joinSubscriber = (subscriber) => { if (subscriber.#resume) subscriber.#resume.join(); else if (subscriber.#state.producer) hooks.joinProducer(subscriber.#state.producer); @@ -2317,17 +2319,6 @@ export class Subscriber { return this.#state.update; } - /** - * The track's exclusive end: the one {@link Producer.finishAt} declared, which can be - * ahead of the live edge, or the head of the sequence after the highest produced once the - * producer closes cleanly (`{ group: 0, frame: 0 }` for a track that produced none). - * Groups and datagrams share the sequence namespace, so it can sit past the last group. - * Undefined until declared, and after an abort that declared none. Mirrors the Rust `end`. - */ - end(): Position | undefined { - return this.#resume ? this.#resume.end() : this.#state.end.peek(); - } - /** * Resolve with the track's exclusive end once it is known, mirroring the Rust `finished`. * @@ -2959,11 +2950,6 @@ export class Ordered { return this.#subscriber.largest(); } - /** The track's exclusive end; see {@link Subscriber.end}. */ - end(): Position | undefined { - return this.#subscriber.end(); - } - /** Resolve with the track's exclusive end once known; see {@link Subscriber.finished}. */ finished(): Promise { return this.#subscriber.finished(); From 7349c26a2b29df1ec89656a3c91510c9c99e3abe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:43:07 -0700 Subject: [PATCH 08/30] feat(net)!: JS group Consumer.close releases only its handle A consume() handle shares the producer's state, so its close() ended the group for every reader. close() now takes no error and releases only this handle: a shared handle ends its own reads, a mirror closes its own copy. Wire and track fan-out fail a reader's copy through a private hook. Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/group.ts | 5 +++-- js/net/src/group.test.ts | 17 +++++++++++++++ js/net/src/group.ts | 42 +++++++++++++++++++++++++++--------- js/net/src/ietf/publisher.ts | 4 ++-- js/net/src/internal.ts | 8 +++++++ js/net/src/lite/publisher.ts | 12 +++++------ js/net/src/resume.ts | 2 +- js/net/src/track.ts | 10 ++++----- 8 files changed, 74 insertions(+), 26 deletions(-) diff --git a/js/e2ee/src/group.ts b/js/e2ee/src/group.ts index 395dd11fbb7..b723f34be0a 100644 --- a/js/e2ee/src/group.ts +++ b/js/e2ee/src/group.ts @@ -171,7 +171,8 @@ export class Consumer { return { timestamp: frame.timestamp, plaintext }; } catch (err) { if (err instanceof Failure && (err.code === "authentication" || err.code === "identity")) { - this.#inner.close(this.#reader.terminal.fail()); + this.#reader.terminal.fail(); + this.#inner.close(); } throw err; } @@ -181,7 +182,7 @@ export class Consumer { #check(): void { const failed = this.#reader.terminal.failed; if (!failed) return; - this.#inner.close(failed); + this.#inner.close(); throw failed; } diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 020236d849e..2de98500243 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -48,6 +48,23 @@ function pair(sequence: number) { return { producer, consumer: producer.consume() }; } +test("closing a consumer releases only that handle", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + + consumer.close(); + expect(await consumer.closed).toBeNull(); + expect(producer.closed.peek()).toBeUndefined(); + + // The group stays writable, and another reader sees every frame and the end. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + producer.finish(); + expect((await other.readFrame())?.payload[0]).toBe(1); + expect((await other.readFrame())?.payload[0]).toBe(2); + expect(await other.readFrame()).toBeUndefined(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 7dd9f330b19..3649b1ebf7a 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -270,7 +270,7 @@ export class Producer { /** A read handle for this group. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer(this.#state, false); } /** @@ -297,7 +297,7 @@ export class Producer { const closed = this.#state.closed.peek(); if (closed !== undefined) { dst.closed.set(closed); - return makeConsumer(dst); + return makeConsumer(dst, true); } this.#mirrors ??= new Set(); @@ -313,7 +313,7 @@ export class Producer { dispose(); }); - return makeConsumer(dst); + return makeConsumer(dst, true); } /** Watch the mirror readers attached to this group. */ @@ -424,7 +424,7 @@ export class Producer { } } -let makeConsumer: (state: GroupState) => Consumer; +let makeConsumer: (state: GroupState, owned: boolean) => Consumer; // A replacement copy asked for on behalf of one consumer, settled or still out. interface Attempt { @@ -626,9 +626,13 @@ export class Consumer { #expired?: Expired; // Closed by its reader, which ends it whatever is left unread. #released = false; + // Whether this consumer alone reads `#state` (a mirror), so releasing it closes that state. + // A `consume()` handle shares the producer's state and only lets go of its own view. + #owned: boolean; - private constructor(state: GroupState) { + private constructor(state: GroupState, owned: boolean) { this.#state = state; + this.#owned = owned; this.sequence = state.sequence; } @@ -676,7 +680,8 @@ export class Consumer { } static { - makeConsumer = (state) => new Consumer(state); + makeConsumer = (state, owned) => new Consumer(state, owned); + hooks.releaseGroupReader = (group, abort) => group.#release(abort); hooks.groupTimestamp = (group) => (group.#state.unverified ? undefined : group.#state.timestamp); hooks.groupLatest = (group) => (group.#state.unverified ? undefined : group.#state.latest); hooks.unverifyGroup = (group) => { @@ -833,6 +838,7 @@ export class Consumer { // The copy left behind stops teeing into the state this consumer read. if (this.#state.closed.peek() === undefined) this.#state.closed.set(null); this.#state = next; + this.#owned = replacement.group.#owned; this.#expiry = replacement.expiry; this.#skip = index; this.#closed?.refresh(); @@ -933,7 +939,7 @@ export class Consumer { this.#closed?.refresh(); return; } - if (this.#expire()) return; + if (this.#released || this.#expire()) return; if (this.#recover?.wants(undefined)) { await this.#resume(undefined); continue; @@ -986,6 +992,8 @@ export class Consumer { return; } + // A released shared handle ends here; the group itself stays open for others. + if (this.#released) return; // Nothing buffered and the group is still open: this wait is the stall the // drift budget bounds, and the only place it applies. if (this.#expire()) continue; @@ -1016,8 +1024,17 @@ export class Consumer { return frame ? frame.payload[0] === 1 : undefined; } - /** Closes the group, optionally with an error to abort readers. Idempotent. */ - close(abort?: Error) { + /** + * Release this handle, like dropping a Rust consumer: it reads what is buffered and then + * ends. Never changes the group for other readers. Idempotent. + */ + close(): void { + this.#release(undefined); + } + + // Release this reader, failing it with `abort` if given. A mirror closes its own state, + // which drops its demand; a shared handle ends only its own view. + #release(abort: Error | undefined): void { // A copy failure recovery was continuing past ends with the abort instead. if (abort && this.#recover && this.#state.closed.peek() instanceof Error) { this.#terminal ??= abort; @@ -1028,7 +1045,12 @@ export class Consumer { this.#recover = undefined; // Closing is terminal for this reader, even with frames still unread. this.#released = true; - if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + if (this.#owned) { + if (this.#state.closed.peek() === undefined) this.#state.closed.set(abort ?? null); + } else if (this.#state.closed.peek() === undefined) { + if (abort) this.#terminal ??= abort; + if (this.#verdict.peek() === undefined) this.#verdict.set(this.#terminal ?? null); + } this.#closed?.refresh(); } } diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index baa0070f5a3..778f11c95cd 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1105,7 +1105,7 @@ export class Publisher { opened.resolve(); } if (!stream) { - group.close(failure ?? new Error("no stream slot")); + hooks.releaseGroupReader(group, failure ?? new Error("no stream slot")); return; } streams.opened += 1; @@ -1220,7 +1220,7 @@ export class Publisher { } } catch (err: unknown) { // A failed open has no stream to reset, but still ends only this group. - group.close(error(err)); + hooks.releaseGroupReader(group, error(err)); } finally { group.close(); } diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index dcc47126632..cce7a228322 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -531,6 +531,11 @@ export const hooks: { /** Keep a mirror of a group accepted unverified below its track's eviction watermark out of media-time expiry. */ unverifyGroup: (group: GroupConsumer) => void; /** Return a group's first timestamp, retained even after its first frame is read. */ + /** + * Release a group reader, failing it with `abort` if given: a mirror closes its own copy, + * a shared handle ends only itself. Assigned by `group.ts`. + */ + releaseGroupReader: (group: GroupConsumer, abort?: Error) => void; groupTimestamp: (group: GroupConsumer) => Timestamp | undefined; groupLatest: (group: GroupConsumer) => Timestamp | undefined; /** Return when a group joined its track at this hop, on the installed clock. */ @@ -716,6 +721,9 @@ export const hooks: { arriveGroup: () => { throw new Error("group.ts not loaded"); }, + releaseGroupReader: () => { + throw new Error("group.ts not loaded"); + }, groupTimestamp: () => { throw new Error("group.ts not loaded"); }, diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 96eefef6105..6036e2f1cff 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1001,7 +1001,7 @@ export class Publisher { let fetched: group.Consumer | undefined; const watch = this.#gate.watch(msg.broadcast, () => { console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`); - fetched?.close(revoked); + if (fetched) hooks.releaseGroupReader(fetched, revoked); stream.abort(revoked); }); try { @@ -1075,7 +1075,7 @@ export class Publisher { serving(group); // Revoked while the group resolved: the watch already reset the stream. if (watch.revoked()) { - group.close(unauthorized(msg.broadcast)); + hooks.releaseGroupReader(group, unauthorized(msg.broadcast)); return; } await this.#runFetchGroup(group, stream.writer, { @@ -1093,7 +1093,7 @@ export class Publisher { console.warn( `fetch error: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group} error=${reason(e)}`, ); - group?.close(e); + if (group) hooks.releaseGroupReader(group, e); stream.abort(e); } } finally { @@ -1733,13 +1733,13 @@ export class Publisher { } if (!stream) { const failed = failure ?? new Error("no stream slot"); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } return stream; } catch (err: unknown) { const failed = error(err); - group.close(failed); + hooks.releaseGroupReader(group, failed); return failed; } } @@ -1818,7 +1818,7 @@ export class Publisher { } catch (err: unknown) { const e = error(err); stream.reset(e); - group.close(e); + hooks.releaseGroupReader(group, e); return e; } finally { priority.remove(stream); diff --git a/js/net/src/resume.ts b/js/net/src/resume.ts index 5fbac752882..938e1463965 100644 --- a/js/net/src/resume.ts +++ b/js/net/src/resume.ts @@ -1042,7 +1042,7 @@ class Reader implements ResumeSource { for (const dispose of this.#dispose) dispose(); this.#dispose = []; // A recovery in flight settles with the group rather than adopting a replacement. - for (const group of this.#open) group.close(abort); + for (const group of this.#open) hooks.releaseGroupReader(group, abort); this.#open.clear(); for (const copy of this.#copies) this.#drop(copy, abort); this.#copies = []; diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 47635c526d7..28955de944a 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1116,11 +1116,11 @@ export class Producer { for (const entry of this.#cache) { const mirror = entry.mirrors.get(sink); if (mirror) { - mirror.close(abort); + hooks.releaseGroupReader(mirror, abort); entry.mirrors.delete(sink); } } - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); dispose(); }); } @@ -1808,7 +1808,7 @@ export class Producer { this.#uncache(entry); } for (const sink of this.#sinks) { - for (const group of sink.groups.peek()) group.close(abort); + for (const group of sink.groups.peek()) hooks.releaseGroupReader(group, abort); closeTrackState(sink, abort); } this.#sinks.clear(); @@ -2455,10 +2455,10 @@ export class Subscriber { closeTrackState(this.#state, abort); this.#resume?.close(abort); this.#state.groups.mutate((groups) => { - for (const group of groups) group.close(abort); + for (const group of groups) hooks.releaseGroupReader(group, abort); groups.length = 0; }); - this.#frameGroup?.close(abort); + if (this.#frameGroup) hooks.releaseGroupReader(this.#frameGroup, abort); this.#frameGroup = undefined; this.#state.timeline.length = 0; this.#wall.timer?.abort(); From 8553cadbf1030c8a04897829a5e7a310c8b85b17 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 02:45:40 -0700 Subject: [PATCH 09/30] docs(net): migration notes and JS docs for the lifecycle vocabulary Co-Authored-By: Claude Opus 5.5 --- doc/lib/js/net.md | 4 ++-- doc/lib/js/publish.md | 5 ++++- js/net/MIGRATE.md | 9 +++++++++ rs/moq-net/MIGRATE.md | 7 +++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index bb0294939f4..781ff514574 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -29,13 +29,13 @@ const broadcast = origin.createBroadcast(Moq.Path.from("chat.room")); const track = broadcast.createTrack("messages", { timescale: Moq.Time.Timescale.MILLI }); const group = track.appendGroup(); group.writeFrame({ payload: new TextEncoder().encode("hello"), timestamp: Moq.Time.Timestamp.now() }); -group.close(); +group.finish(); broadcast.announce(); ``` - **The origin holds the broadcasts, not the connection.** Closing a session unannounces them and leaves them created for the next one. A broadcast is invisible, locally and remotely, until `announce()`. - **Answer claimed paths with `using`.** `dynamic(prefix)` claims a prefix and yields each requested path. Use `using prepared = request.prepare(epoch)`, fill its broadcast, then `prepared.publish()` to answer with a new publisher instance. Leaving the scope without publishing, including on a throw, refuses the request as unroutable. Explicit `prepared.close()` or closing its unpublished broadcast does the same; disposal after publishing leaves the returned producer live. Reject before preparing with `request.reject(err)`. A retained, unclosed prepared answer keeps the request pending; JavaScript garbage collection provides no prompt cleanup guarantee. -- **End content by closing tracks.** A broadcast has no finished state. When the input ends, close each track cleanly; the broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. +- **End content by finishing tracks.** A broadcast has no finished state. When the input ends, `finish()` each track, then `close()` it; closing a track without finishing aborts it. The broadcast stays announced and keeps serving them until you `close()` it. How long to keep it up for late readers is yours to decide. - **Requests can pin an epoch.** `consumer.request(path, { epoch })` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs). The resolved broadcast's `epoch` names the route it came through, pinned or not. - **Follow restarts.** `origin.request(path, { announced: true })` follows whichever publisher instance serves the path: its `active` changes to a fresh broadcast on a restart, clears on an end, and resolves again on the next start, so read it reactively and close the request when done. Instances are never stitched, so subscribe again to each new broadcast. Another route of the same epoch taking over (epochs travel only on `moq-lite-07-wip`) keeps `active` as is, and each track subscription resumes there from the first group it lacks. A refusal ends the request. Add `epoch` to pin one instance; a plain `request(path)` keeps its accepted instance, can resolve blindly, and ends once that instance stops serving. To list what is live, iterate `announced(scope)`. - **Discovery follows what you watch.** A `consume` origin asks the relay for announcements only under the prefixes its readers watch with `announced(scope)` or `request(path, { announced: true })`, stops asking about a prefix 2s after nothing at or beneath it is watched, and asks again on reconnect. A plain `request(path)` asks nothing and subscribes blind, but once it resolves through a discovered route it watches its path until closed, so it outlives the listing that found it. A relay that refuses an announce request turns that session's announced requests blind too. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 70a92171d94..c9b0756f0c7 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -95,7 +95,10 @@ signals.run((effect) => { timescale: Moq.Time.Timescale.MILLI, maxAge: Milli(86_400_000), }); - effect.cleanup(() => track.close()); + effect.cleanup(() => { + track.finish(); + track.close(); + }); const meta = new Json.Snapshot.Producer({ track }); meta.update({ value: current, at: Moq.Time.Timestamp.now() }); diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 2bdc084a66e..60eb5eae5d9 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -4,6 +4,15 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. +`Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. +`Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. +`Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. +The wire layer's `Lister.ended` is `closed`. +`Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. + ### Published names refuse navigation segments Broadcast creation and route publication now refuse literal `.` and `..` segments, including segments in the producer's root. diff --git a/rs/moq-net/MIGRATE.md b/rs/moq-net/MIGRATE.md index 4c6429b1d5e..0b1d6a0d9f1 100644 --- a/rs/moq-net/MIGRATE.md +++ b/rs/moq-net/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### One lifecycle vocabulary ([#6750](https://github.com/moq-dev/moq/pull/6750)) + +`closed()` on `group::Producer`, `group::Demand`, `track::Producer`, and `track::Demand` now waits for the outcome and returns `Result<()>`: `Ok(())` once the end settles (a finished group; a track whose declared end was reached with every group below it finished), or the abort error. Match on the result where you awaited `()` or treated `Error::Dropped` as a clean end; `poll_closed` returns `Poll>`. +`group::Producer::abort` after `finish` only releases the cache: readers keep the clean end. +`track::Producer::close()` is gone; call `abort(err)`. A session closing locally aborts its copies with `Error::Stream(StreamError::Session(SessionError::Cancel))`, which resume and origin treat as a dead route rather than an end. +`track::Producer::end()` is `finished()`. + ### `stats::Refusal` is `stats::RefusalKind`, and `time::now` is gone (#6510) Rename `moq_net::stats::Refusal` to `moq_net::stats::RefusalKind`, so it no longer shares a name with `moq_auth::Refusal`; its variants and `Refusals` are unchanged. From 1d1e2360736dd262da21bcf382bb27e9b71b29fe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 03:15:21 -0700 Subject: [PATCH 10/30] fix(net): finish the remaining callers and narrow the quest moq-audio's capture logs a finished track's Ok outcome, the drain and compat clients finish their groups, rustfmt, and the quest now covers only the JS consumers whose close() still ends shared state. Co-Authored-By: Claude Opus 5.5 --- quest/m1/api-audit/lifecycle.md | 79 +++++++++++------------------- rs/moq-audio/src/encode/capture.rs | 17 ++++--- rs/moq-net/src/ietf/subscriber.rs | 15 ++++-- rs/moq-net/src/lite/subscriber.rs | 9 +++- rs/moq-net/src/model/group.rs | 5 +- rs/moq-net/src/model/origin.rs | 5 +- rs/moq-net/src/model/track.rs | 9 +++- test/drain/drain.ts | 2 +- test/interop/compat/client.ts | 2 +- 9 files changed, 72 insertions(+), 71 deletions(-) diff --git a/quest/m1/api-audit/lifecycle.md b/quest/m1/api-audit/lifecycle.md index 60e1acd57e2..a158d0b9390 100644 --- a/quest/m1/api-audit/lifecycle.md +++ b/quest/m1/api-audit/lifecycle.md @@ -1,58 +1,35 @@ -# [L] One lifecycle vocabulary +# [M] One lifecycle vocabulary ## Goal Every model handle in moq-net and `@moq/net` (broadcast, track, group, their -demand, `Finish`, listings) uses each lifecycle word with one meaning, and -Rust and JS agree on when each observer fires. +demand, listings) uses each lifecycle word with one meaning, and Rust and JS +agree on when each observer fires. ## Plan -Decided 2026-10-23 (API audit follow-up, maintainer), from a matrix of every -handle's writer verbs and observers: - -- **`finish()` / `finish_at(end)`** declare a clean end. Not terminal: lower - writes stay possible. JS gains `Track.Producer.finish()`. -- **`abort(err)`** ends with an error and is terminal. An abort after the - outcome settled (a finished group, a track whose end was reached) only - releases cache: readers keep the clean outcome. Tracks behave this way - today; groups change to match and record no `cut` unless the abort is - eviction policy. -- **`closed()`** on producers, `Finish`, and demand handles resolves once the - outcome is final: a settled finish, an abort, or the last writer dropping. - Rust returns `Result<(), Error>` (`Ok` for clean), JS `Error | null`. It - never fires because readers left; `unused` reports that. Today Rust - `closed()` ignores finish at every layer and reads `Dropped` after a clean - close. -- **`close()`** is JS-only and releases that handle, like a Rust drop. It - never changes state other handles share. Today a JS group, announce, or - broadcast consumer's `close()` ends it for every reader, and the last - broadcast consumer's close ends the producer; check what the JS wire layer - relies on before changing it. `broadcast::Producer::close` keeps its name: - a broadcast has no abort, and it means "drop every clone now". - -Changes: - -- Rust `track::Producer::close()`, crate-private after - [session-only items stay private](/quest/m1/api-audit/model-private.md), - folds into `abort` with the clean-close error - ([moq-dev/moq#6482](https://github.com/moq-dev/moq/pull/6482) makes it - `Stream(Session(Cancel))`). Readers that drained a closed copy to a clean - end now get that error: update `resume.rs`'s clean-end mapping and the - sessions that call it; failover is unchanged since `route_failed` already - accepts it. -- Rust `group::Producer::closed`/`poll_closed` fire on finish; sessions that - need abort-only get a private poll. -- JS `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split - into `finish()` and `abort(err)`. -- JS `Finish.closed` also fires when a newer epoch supersedes it, as Rust does. -- JS `Lister.ended` becomes `closed`. JS `Demand.unused` rejects on close as - Rust's does. -- Rust `track::Producer::end()` becomes `finished()` (the declared end, a - snapshot). JS `Subscriber.end()` and `Ordered.end()` go: readers await - `finished()`. - -Behavior changes get tests on mock time in both languages. - -Public API: moq-net and `@moq/net` breaking (observer semantics, JS producer -verbs, renames). Wire: none. +Decided 2026-10-23 (API audit follow-up, maintainer): `finish()` declares a +clean end, `abort(err)` is terminal, `closed()` resolves on the final outcome, +and JS `close()` releases only that handle, like a Rust drop. +[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed the +producer verbs, `closed()` semantics in both languages, group consumer +`close()`, `Lister.closed`, `Demand.unused`, and the reader `end()` removal. + +What remains is JS consumers whose `close()` still ends state other handles +share. The JS wire layer and origin rely on it today: + +- `Announce.Consumer.close(abort?)` closes the shared announcement queue, which + is how a reader cancels the stream. Pick: count consumers and give the + producer an `unused()` the session watches (recommended), or keep one reader + per queue and document `close()` as cancel. +- The last `Broadcast.Consumer.close()` closes the broadcast, including a + producer's. The origin relies on it: superseding a published broadcast + closes its producer once no consumer holds it. Pick: the producer stays open + and publishers watch `demand().unused()`, as in Rust (recommended), or keep + it for wire-owned broadcasts only and close superseded producers explicitly. + +`@moq/e2ee` wraps the old verbs (`Group.Producer.close(abort?)`); mirror the +net split there. + +Public API: `@moq/net` breaking (consumer `close()` semantics), plus +`@moq/e2ee`. Wire: none. diff --git a/rs/moq-audio/src/encode/capture.rs b/rs/moq-audio/src/encode/capture.rs index 8a7c29091bc..d6ca13f8218 100644 --- a/rs/moq-audio/src/encode/capture.rs +++ b/rs/moq-audio/src/encode/capture.rs @@ -553,8 +553,8 @@ impl Driver { tokio::select! { biased; changed = desired_changed(&self.desired, revision) => changed.is_some(), - err = track.closed() => { - log_track_ended(err); + closed = track.closed() => { + log_track_ended(closed); false } } @@ -811,7 +811,7 @@ async fn until_wanted(demand: &mut moq_net::track::Demand, wanted: bool) -> bool Ok(next) if next.is_some() == wanted => return true, Ok(_) => {} Err(err) => { - log_track_ended(err); + log_track_ended(Err(err)); return false; } } @@ -1193,9 +1193,14 @@ impl Supervisor { } } -/// An application dropping or closing its producer ends publishing normally; -/// a specific abort (such as a transport reset) stays visible in diagnostics. -fn log_track_ended(err: moq_net::Error) { +/// A finished track, or an application dropping or closing its producer, ends +/// publishing normally; a specific abort (such as a transport reset) stays +/// visible in diagnostics. +fn log_track_ended(outcome: Result<(), moq_net::Error>) { + let Err(err) = outcome else { + tracing::debug!("audio track finished; stopping capture"); + return; + }; if matches!(err, moq_net::Error::Dropped | moq_net::Error::Closed) { tracing::debug!("audio track no longer announced; stopping capture"); } else { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 815595cccfe..34963730765 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -14631,7 +14631,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_fill(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// A subgroup object without a Timestamp on a timed track makes the whole track @@ -14660,7 +14663,10 @@ mod stitch_tests { let mut stream = h.stream().await; let res = h.subscriber.clone().recv_group(&mut stream).await; assert!(matches!(res, Err(Error::MalformedTrack)), "{res:?}"); - assert!(matches!(h.track.closed().now_or_never(), Some(Err(Error::MalformedTrack)))); + assert!(matches!( + h.track.closed().now_or_never(), + Some(Err(Error::MalformedTrack)) + )); } /// END_OF_TRACK after a group's last object ends the track right after that group. @@ -16518,7 +16524,10 @@ mod joining_fetch_tests { } if !complete { assert!( - matches!(observed.poll_closed(&kio::Waiter::noop()), Poll::Ready(Err(Error::Cancel))), + matches!( + observed.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + ), "partial group must abort" ); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index f710438e36c..2881e926013 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -565,7 +565,9 @@ impl SubscriptionCleanup { for (_, entry) in self.0.lock().drain() { let _ = match entry.tail.read().is_ended() { true => entry.producer.finish(), - false => entry.producer.abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), + false => entry + .producer + .abort(Error::Stream(crate::StreamError::Session(crate::SessionError::Cancel))), }; } } @@ -2986,7 +2988,10 @@ mod tests { } _ => { assert!(matches!(res, Err(Error::ProtocolViolation)), "{version:?}: {res:?}"); - assert!(matches!(track.closed().now_or_never(), Some(Err(Error::ProtocolViolation)))); + assert!(matches!( + track.closed().now_or_never(), + Some(Err(Error::ProtocolViolation)) + )); } } } diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 6559583386d..24e484d800a 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -4645,7 +4645,10 @@ mod test { producer.finish().unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); assert!(matches!(demand.poll_closed(&waiter), Poll::Ready(Ok(())))); - assert!(handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing"); + assert!( + handle.poll_aborted(&waiter).is_pending(), + "a finish alone frees nothing" + ); assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); producer.abort(Error::Cancel).unwrap(); diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index a195c6c49bd..cbd198d9bb6 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3880,10 +3880,7 @@ async fn serve_front(task: FrontTask) -> Vec { let Some(io) = tracks.get_mut(&name) else { continue }; io.copy = None; io.held = None; - io.closed = matches!( - result, - Err(Error::Stream(StreamError::Session(SessionError::Cancel))) - ); + io.closed = matches!(result, Err(Error::Stream(StreamError::Session(SessionError::Cancel)))); Event::TrackEnded { track: name, source, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index a3b1c9952ff..a9ed6b7d8b2 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -13074,7 +13074,9 @@ mod test { group .write_frame(Timestamp::ZERO, bytes::Bytes::from_static(b"b")) .unwrap(); - producer.abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))).unwrap(); + producer + .abort(Error::Stream(StreamError::Session(crate::SessionError::Cancel))) + .unwrap(); assert!(matches!( consumer.poll_complete(&kio::Waiter::noop()), Poll::Ready(Err(_)) @@ -13098,7 +13100,10 @@ mod test { producer.append_group().unwrap().finish().unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); - assert!(producer.poll_aborted(&waiter).is_pending(), "lower groups may still arrive"); + assert!( + producer.poll_aborted(&waiter).is_pending(), + "lower groups may still arrive" + ); producer.clone().abort(Error::Cancel).unwrap(); assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); diff --git a/test/drain/drain.ts b/test/drain/drain.ts index 834381e909a..a59cb248256 100644 --- a/test/drain/drain.ts +++ b/test/drain/drain.ts @@ -126,7 +126,7 @@ const ticker = setInterval(() => { payload: new TextEncoder().encode(String(group.sequence)), timestamp: Moq.Time.Timestamp.now(), }); - group.close(); + group.finish(); lastPublished = group.sequence; }, GROUP_INTERVAL_MS); diff --git a/test/interop/compat/client.ts b/test/interop/compat/client.ts index eddb6343fd9..df6fb32942a 100644 --- a/test/interop/compat/client.ts +++ b/test/interop/compat/client.ts @@ -311,7 +311,7 @@ if (command === "sign") { payload: new TextEncoder().encode("compat-fetch"), timestamp: Moq.Time.Timestamp.fromMicros(1 as TimestampMicros), }); - group.close(); + group.finish(); await connection.closed; } else if (command === "subscribe") { // The flags of the interop subscriber this stands in for. From 46bd7e465e304d10c41abe35b5ef46e2fa0e0868 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:47:52 -0700 Subject: [PATCH 11/30] feat(net)!: consumer close releases only its handle for announcements and broadcasts Announce.Consumer.close() no longer closes the shared queue: consumers are counted and sessions watch the producer's unused(). The last Broadcast.Consumer.close() no longer closes a produced broadcast; the origin closes a superseded or released broadcast once its demand is unused, and a session's producer-less broadcast still retires once no handle holds it, as Rust sessions do with close_unheld. Broadcast.Producer.demand() is public. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 2 ++ js/net/api/index.api.md | 23 ++++++++---- js/net/api/zod.api.md | 11 +++--- js/net/src/announced.test.ts | 27 ++++++++++++++ js/net/src/announced.ts | 35 +++++++++++++++--- js/net/src/broadcast.test.ts | 21 ++++++++++- js/net/src/broadcast.ts | 29 ++++++++------- js/net/src/connection/forward.test.ts | 8 ++++- js/net/src/connection/pool.ts | 6 +++- js/net/src/connection/reload.ts | 6 +++- js/net/src/ietf/publisher.test.ts | 6 ++-- js/net/src/ietf/subscriber.ts | 8 ++++- js/net/src/lite/subscriber.ts | 8 ++++- js/net/src/mock.ts | 12 +++++++ js/net/src/origin-demand.test.ts | 7 ++-- js/net/src/origin.test.ts | 32 +++++++++++------ js/net/src/origin.ts | 52 +++++++++++++++++++-------- quest/m1/api-audit/README.md | 2 +- quest/m1/api-audit/lifecycle.md | 34 ++++++------------ 19 files changed, 238 insertions(+), 91 deletions(-) diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 60eb5eae5d9..1dd9bfb7353 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -9,6 +9,8 @@ How to update your code for each breaking change, newest first. `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. `Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. `Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. +`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches `unused()`, which resolves once the last consumer closes, instead of its `closed`. +The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. A publisher that closed its broadcast once readers left watches `demand().unused()` instead. A broadcast created on an origin and then superseded by a new `createBroadcast` at its path closes once nobody subscribes to it, rather than once no consumer handle holds it. `Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. The wire layer's `Lister.ended` is `closed`. `Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index dcb352605a4..f61daaf7b3e 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -124,6 +124,7 @@ declare namespace Broadcast { export { Tracks, TracksEvent, + Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -133,6 +134,7 @@ declare namespace Broadcast_2 { export { Tracks, TracksEvent, + Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -243,14 +245,14 @@ class ConnectTimeout extends Error { class Consumer { [Symbol.asyncIterator](): AsyncGenerator; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; next(): Promise; get synced(): Getter; } class Consumer_2 { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -345,6 +347,12 @@ class Demand_2 { get used(): Getter; } +class Demand_3 { + get closed(): GetPromise; + unused(): Promise; + get used(): Getter; +} + class Duplicate extends Error { constructor(message?: string); } @@ -608,7 +616,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -737,13 +744,15 @@ interface Probe { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; + finish(): void; finishAt(end: Position_2): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -758,10 +767,11 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer_2; demand(): Demand_2; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -779,6 +789,7 @@ class Producer_3 { get closed(): GetPromise; consume(): Consumer_4; createTrack(name: string, info?: Partial): track.Producer; + demand(): Demand_3; removeTrack(name: string): void; get route(): Route | undefined; track(name: string): track.Consumer; @@ -790,6 +801,7 @@ class Producer_4 { close(abort?: Error): void; get closed(): GetPromise; consume(): Consumer; + unused(): Promise; } class Producer_5 implements Table { @@ -1073,7 +1085,6 @@ function stripPrefix(prefix: Valid, path: Valid): Valid | null; class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/api/zod.api.md b/js/net/api/zod.api.md index 953b970143b..f88e7587111 100644 --- a/js/net/api/zod.api.md +++ b/js/net/api/zod.api.md @@ -18,7 +18,7 @@ type Bound = { }; class Consumer { - close(abort?: Error): void; + close(): void; get closed(): GetPromise; get done(): boolean; get frameCount(): number; @@ -116,7 +116,6 @@ type Order = "ascending" | "descending"; class Ordered { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; @@ -143,13 +142,15 @@ interface Position_2 { class Producer { constructor(name: string); + abort(error: Error): void; accept(info?: Partial): this; appendDatagram(timestamp: Timestamp | undefined, payload: Uint8Array): number; appendGroup(): Producer_2; - close(abort?: Error): void; + close(): void; get closed(): GetPromise; demand(): Demand; dropGroups(start: number, end: number, error: Error): void; + finish(): void; finishAt(end: Position_2): void; info(): Promise; insertDatagram(sequence: number, timestamp: Timestamp | undefined, payload: Uint8Array): void; @@ -164,10 +165,11 @@ class Producer { class Producer_2 { constructor(sequence: number); - close(abort?: Error): void; + abort(error: Error): void; get closed(): GetPromise; consume(): Consumer; demand(): Demand_2; + finish(): void; get isClosed(): boolean; readonly sequence: number; writeFrame(frame: Frame): void; @@ -198,7 +200,6 @@ class Staged { class Subscriber { close(abort?: Error): void; get closed(): GetPromise; - end(): Position_2 | undefined; finished(): Promise; info(): Promise; largest(): Promise; diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 70569c6f12d..dcd482fbffa 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -65,6 +65,33 @@ test("closing resolves next with undefined", async () => { expect(await consumer.next()).toBeUndefined(); }); +test("closing a consumer releases only that handle", async () => { + const producer = new Announce.Producer(); + const first = producer.consume(); + const second = producer.consume(); + const unused = producer.unused(); + + first.close(); + first.close(); + expect(await first.next()).toBeUndefined(); + expect(producer.closed.peek()).toBeUndefined(); + + // The other reader keeps the stream: an event still reaches it. + const route = Route.default; + producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); + expect((await second.next())?.prefix).toBe(p("a")); + + // The last one leaving is what the producer watches for, and a reader parked on next() ends. + const parked = second.next(); + second.close(); + expect(await parked).toBeUndefined(); + await unused; + expect(producer.closed.peek()).toBeUndefined(); + + producer.close(); + await expect(producer.unused()).rejects.toThrow("closed"); +}); + test("aborting rejects next", async () => { const producer = new Announce.Producer(); const consumer = producer.consume(); diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 35ba251bf62..1bf6a973c4c 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -68,6 +68,8 @@ class AnnounceState { // A reader holds the last event it took before syncing until it asks for the next // one, so whatever it does with that event lands before synced flips. reading = false; + // Open consumer handles. The stream is unused once the last one closes. + consumers = new Signal(0); } // Synced once nothing is owed and the reader has come back for more. @@ -109,6 +111,20 @@ export class Producer { return makeConsumer(this.#state); } + /** + * Resolves once no consumer holds the stream: the last one {@link Consumer.close}d. Rejects + * once the stream closes. + */ + async unused(): Promise { + const state = this.#state; + for (;;) { + const closed = state.closed.peek(); + if (closed !== undefined) throw closed ?? new Error("announcements are closed"); + if (state.consumers.peek() === 0) return; + await Signal.race(state.consumers, state.closed); + } + } + /** Writes an event to the queue. */ append(event: Event) { if (this.#state.closed.peek() !== undefined) throw new Error("announcements are closed"); @@ -162,9 +178,12 @@ let makeConsumer: (state: AnnounceState) => Consumer; */ export class Consumer { #state: AnnounceState; + // Guards against a double close() on this handle over-decrementing the consumer count. + #released = false; private constructor(state: AnnounceState) { this.#state = state; + state.consumers.update((count) => count + 1); } /** Settles once the stream closes; see {@link Producer.closed}. */ @@ -198,12 +217,13 @@ export class Consumer { } } - /** Returns the next event, or undefined once the stream closes. */ + /** Returns the next event, or undefined once the stream or this handle closes. */ async next(): Promise { const state = this.#state; state.reading = false; settle(state); for (;;) { + if (this.#released) return undefined; const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; @@ -215,12 +235,17 @@ export class Consumer { if (closed instanceof Error) throw closed; if (closed !== undefined) return undefined; - await Signal.race(this.#state.queue, this.#state.closed); + await Signal.race(state.queue, state.closed, state.consumers); } } - /** Closes the reader. Idempotent. */ - close(abort?: Error) { - closeState(this.#state, abort); + /** + * Release this handle, like dropping a Rust handle: other consumers keep reading, and the + * producer sees the stream {@link Producer.unused} once the last one closes. Idempotent. + */ + close() { + if (this.#released) return; + this.#released = true; + this.#state.consumers.update((count) => count - 1); } } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index a7517bf9e51..c8e245a68b5 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -112,7 +112,7 @@ test("a replacement producer starts over", async () => { const group = new GroupProducer(8); first.writeGroup(group); group.writeFrame(textFrame("a", Timestamp.fromMillis(1000))); - group.close(); + group.finish(); firstSubscriber.close(); first.finish(); first.close(); @@ -427,6 +427,25 @@ test("a consumer clone shares the broadcast until every handle closes", () => { expect(clone.closed.peek()).toBeDefined(); }); +test("closing every consumer leaves a produced broadcast to its producer", async () => { + const producer = new BroadcastProducer(); + const track = producer.createTrack("video"); + const consumer = producer.consume(); + const subscriber = consumer.track("video").subscribe(); + consumer.close(); + expect(producer.closed.peek()).toBeUndefined(); + + // The producer watches demand instead: the last subscriber leaving is what it sees. + const unused = producer.demand().unused(); + subscriber.close(); + await unused; + expect(producer.closed.peek()).toBeUndefined(); + + track.close(); + producer.close(); + expect(producer.closed.peek()).toBeNull(); +}); + test("double-closing a consumer handle does not prematurely close the broadcast", () => { const consumer = new TestConsumer(); const clone = consumer.clone(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index a41c8ba1314..da79b742a77 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -62,9 +62,11 @@ class BroadcastState { // publisher that has started pulling them. The counterpart of a live Rust `broadcast::Dynamic`; // without one, a track nobody publishes is `NotFound` rather than a request nobody will answer. served = false; - // Live consumer handles sharing this state (see {@link Consumer.clone}). The broadcast - // closes once the last one closes, so a shared consumer can be handed to several callers. + // Live consumer handles sharing this state (see {@link Consumer.clone}). consumers = 0; + // A wire layer's broadcast, which has no producer: it ends once no consumer holds it, as a + // Rust session retires a source nothing holds. A produced broadcast ends only on its close. + unheld = false; used = new Signal(false); active = 0; // The demand watcher of each open track, disposed when it closes or leaves the broadcast. @@ -326,9 +328,9 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info let makeDemand: (state: BroadcastState) => Demand; /** - * A watch-only view of demand for any track in a broadcast. + * A watch-only view of demand for any track in a broadcast, mirroring the Rust `broadcast::Demand`. * - * @internal Tests only; nothing outside the package consumes it. + * @public */ export class Demand { #state: BroadcastState; @@ -411,9 +413,8 @@ export class Producer { } /** - * Watch demand for the broadcast's tracks. - * - * @internal Tests only; nothing outside the package consumes it. + * Watch demand for the broadcast's tracks: a publisher that should stop once nobody reads + * waits on its {@link Demand.unused}, since closing every consumer leaves the broadcast open. */ demand(): Demand { return this.#demand; @@ -569,6 +570,7 @@ export class Consumer { // A standalone consumer is a wire layer's, which serves every track on demand. this.#state = new BroadcastState(); this.#state.served = true; + this.#state.unheld = true; } this.#state.consumers++; registerWire(this, { @@ -623,8 +625,9 @@ export class Consumer { * Settles with `null` once the broadcast closes; a broadcast end carries no cause. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. * - * Shared by every {@link clone}: it settles once the last handle closes. The subscribing - * wire layer peeks it to evict a closed entry from its per-path consume cache. + * Shared by every {@link clone}. A produced broadcast settles once its producer closes; a + * broadcast consumed from a session, once the session ends it or the last handle closes. + * The subscribing wire layer peeks it to evict a closed entry from its per-path consume cache. */ get closed(): GetPromise { return this.#state.closed; @@ -634,7 +637,7 @@ export class Consumer { * Return another handle to the same broadcast, reference-counted with this one. * * Both handles read the same tracks, carry the same {@link path}, and share one {@link closed} - * state; the broadcast closes only once *every* handle has {@link close}d. Used by the connection's per-path + * state. Used by the connection's per-path * consume cache to share one subscription across callers. Subclasses that resolve info over * the wire override this to preserve their type (see the wire layer's consumed broadcast). */ @@ -679,13 +682,13 @@ export class Consumer { } /** - * Release this handle. The broadcast is closed once this was the last live handle; - * while other {@link clone}s remain open it stays live. + * Release this handle, like dropping a Rust handle. A produced broadcast stays open until + * its producer closes; one consumed from a session ends once no {@link clone} holds it. */ close(): void { if (this.#closed) return; this.#closed = true; - if (--this.#state.consumers > 0) return; + if (--this.#state.consumers > 0 || !this.#state.unheld) return; closeState(this.#state); } } diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 9a737265a5e..e177d54929e 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -64,7 +64,13 @@ class FakeSession { const stream = new Announce.Producer(); this.requests.push({ scope: scope.text, hidden: options?.hidden, stream }); if (this.syncedAtOnce) stream.synced(); - return stream.consume(); + const consumer = stream.consume(); + // The last reader leaving cancels the request, as a session's does. + void stream.unused().then( + () => stream.close(), + () => {}, + ); + return consumer; } /** The scopes of the announce requests still open. */ diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index e6f09fe9b01..4b1913fc401 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -354,7 +354,11 @@ export class Connection { pumpAnnounced(effect, origin.announced(scope, options), producer); }); - void consumer.closed.then(stop); + void producer.unused().then( + () => producer.close(), + () => {}, + ); + void producer.closed.then(stop); return consumer; } diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index 849f663417f..a1b2bdf9a1c 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -719,7 +719,11 @@ export class Reload { }); this.#signals.cleanup(() => pump.close()); - void consumer.closed.then(() => pump.close()); + void producer.unused().then( + () => producer.close(), + () => {}, + ); + void producer.closed.then(() => pump.close()); return consumer; } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 365b7b47752..277b37c07bf 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -8,7 +8,7 @@ import { error, ProtocolViolation, SessionCode, SessionError, StreamCode, unauth import { Producer as GroupProducer, MAX_GROUP_FRAMES } from "../group.ts"; import { type Hop, HopSchema } from "../hop.ts"; import { hooks } from "../internal.ts"; -import { createMockTransportPair } from "../mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; @@ -435,7 +435,7 @@ test("a cancelled TRACK_STATUS releases its demand on every draft", async () => await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); @@ -485,7 +485,7 @@ test("a SUBSCRIBE withdrawn during setup releases its demand on every draft", as await resolving; expect(early.written).toEqual([]); // Withdrawn, so a late answer is closed rather than served. - const answer = new BroadcastProducer().consume(); + const answer = new SessionBroadcast(); if (pending) hooks.acceptRequest(pending, answer); expect(answer.closed.peek()).toBeNull(); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 763c1d329c7..9b2ee1af076 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -355,13 +355,19 @@ export class Subscriber { }); } this.#announcedConsumers.set(announced, filter); + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + void announced.unused().then( + () => announced.close(), + () => {}, + ); void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); announced.close(); }); - return announced.consume(); + return consumer; } /** diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index a4a2494718d..a1b1aa8a263 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -258,8 +258,14 @@ export class Subscriber { announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { const announced = new announce.Producer(); // The wire speaks announce interest by prefix, and echoes suffixes beneath it. + const consumer = announced.consume(); + // The last reader leaving ends the stream, which cancels the request. + void announced.unused().then( + () => announced.close(), + () => {}, + ); void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false); - return announced.consume(); + return consumer; } async #runAnnounced( diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index 826842e81cf..ba6e74ef78b 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -4,6 +4,7 @@ * Creates paired client/server transports connected via TransformStreams. */ +import { Consumer as BroadcastConsumer } from "./broadcast.ts"; import type { TransportStats } from "./connection/stats.ts"; import type { Frame } from "./group.ts"; import type { SendStream } from "./stream.ts"; @@ -14,6 +15,17 @@ export function textFrame(text: string, timestamp = Timestamp.now()): Frame { return { payload: new TextEncoder().encode(text), timestamp }; } +/** + * A broadcast as a session hands it out: no producer, so it ends once no handle holds it, + * which is how a test sees the origin let go of an answer. + */ +export class SessionBroadcast extends BroadcastConsumer { + // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public + constructor() { + super(); + } +} + // High watermark to prevent writes from blocking on backpressure. // Real WebTransport has kernel buffers; we simulate this with a large queue. const WRITABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; diff --git a/js/net/src/origin-demand.test.ts b/js/net/src/origin-demand.test.ts index 89d2f5e207c..0b5fb78344d 100644 --- a/js/net/src/origin-demand.test.ts +++ b/js/net/src/origin-demand.test.ts @@ -12,7 +12,7 @@ import { Publisher as LitePublisher } from "./lite/publisher.ts"; import { Subscribe as LiteSubscribe } from "./lite/subscribe.ts"; import { Track } from "./lite/track.ts"; import { Version as LiteVersion } from "./lite/version.ts"; -import { createMockTransportPair } from "./mock.ts"; +import { createMockTransportPair, SessionBroadcast } from "./mock.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; import { readsFrom } from "./resume.test-util"; @@ -170,9 +170,8 @@ test.each([true, false])( local.close(); } await settle(); - const late = new Broadcast(); - const front = late.consume(); - hooks.acceptRequest(old, front); + const late = new SessionBroadcast(); + hooks.acceptRequest(old, late); expect(late.closed.peek()).toBeNull(); const next = origin.request(path); const fresh = (await requests.next()).value; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 7f157c7520d..ca385798563 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -7,6 +7,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; +import { SessionBroadcast } from "./mock.ts"; import type { Consumer, Table } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -239,25 +240,32 @@ test("a republish closes the superseded broadcast", async () => { origin.close(); }); -test("a consumer clone keeps a superseded broadcast alive", async () => { +test("a subscriber keeps a superseded broadcast alive", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); const first = publish(origin, path); + const track = first.createTrack("video"); const mine = await routed(consumer, path); - expect(mine).toBeDefined(); + const subscriber = mine?.track("video").subscribe(); + expect(subscriber).toBeDefined(); + // Demand lands a microtask after the subscribe. + await settle(); publish(origin, path); await settle(); - // The application's clone holds the old broadcast open even though it is unpublished. + // The subscriber holds the old broadcast open even though it is unpublished. expect(first.closed.peek()).toBeUndefined(); - mine?.close(); + // A handle alone is no demand: the last subscriber leaving closes it. + subscriber?.close(); await settle(); - expect(first.closed.peek()).not.toBeUndefined(); + expect(first.closed.peek()).toBeNull(); + mine?.close(); + track.close(); origin.close(); }); @@ -481,10 +489,10 @@ test("a request resolves once a front answers, and survives its withdrawal", asy expect(request.active.peek()).toBeUndefined(); // A session answers (simulated): the slot's front resolves the request. - const upstream = new BroadcastProducer(); + const upstream = new SessionBroadcast(); const slot = wireOf(origin).requests.peek()?.get(path); expect(slot).toBeDefined(); - expect(wireOf(origin).answer(path, upstream.consume())).toBeDefined(); + expect(wireOf(origin).answer(path, upstream)).toBeDefined(); expect(request.active.peek()).toBeDefined(); // A second request for the same path shares the answer, each through a handle of its @@ -1971,16 +1979,21 @@ test("a broadcast a derived handle released cannot announce again", async () => const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const path = Path.from("room"); const broadcast = publish(scoped, path); + const track = broadcast.createTrack("video"); const held = await routed(consumer, path); + const subscriber = held?.track("video").subscribe(); + await settle(); scoped.close(); - // The application's clone keeps the broadcast open, but the released handle no longer routes it. + // A subscriber keeps the broadcast open, but the released handle no longer routes it. expect(broadcast.closed.peek()).toBeUndefined(); expect(() => broadcast.announce()).toThrow("broadcast is closed"); expect((await announcedView(origin)).peek().has(path)).toBe(false); + subscriber?.close(); held?.close(); + track.close(); origin.close(); }); @@ -2516,7 +2529,7 @@ test("an unread resolved request retains its broadcast until its last handle clo const dynamic = origin.dynamicRoute(Path.from("live"), { epoch: EPOCH }); const requests = dynamic.requested(); const held = origin.consume().request(path); - const old = new BroadcastProducer(); + const old = new SessionBroadcast(); hooks.acceptRequest((await requests.next()).value, old); // Change epochs before the active getter or its subscription can clone the answer. dynamic.updateRoute({ epoch: NEWER }); @@ -2527,7 +2540,6 @@ test("an unread resolved request retains its broadcast until its last handle clo expect(held.active.peek()).toBeUndefined(); await requests.return?.(); dynamic.close(); - old.close(); origin.close(); }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 2c54c2cb456..a8e0736028e 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -480,6 +480,25 @@ function publishPath(path: Path.Valid): void { } } +// A broadcast created through the origin: its producer, and the handle the origin routes. +interface Created { + producer: broadcast.Producer; + front: broadcast.Consumer; +} + +// Let go of a created broadcast the origin no longer routes. Only a producer ends a broadcast, as +// in Rust, so it closes once nobody subscribes to it rather than under a reader still draining it. +function retire({ producer, front }: Created): void { + front.close(); + const demand = producer.demand(); + if (!demand.used.peek()) producer.close(); + else + void demand.unused().then( + () => producer.close(), + () => {}, + ); +} + /** Reactive backing state shared by origin producers and consumers. */ class OriginState { // Both tables decouple the application producing into the origin from the @@ -493,7 +512,7 @@ class OriginState { // newest first. Local and routes stay separate so a session can never announce a // received entry back to a peer, which is what makes an origin shared by both // directions echo-free. - created: Map | undefined = new Map(); + created: Map | undefined = new Map(); local = new VersionedSignal | undefined>(new Map()); advertisedLocal = new VersionedSignal | undefined>(new Map()); routes = new VersionedSignal | undefined>(new Map()); @@ -1188,8 +1207,8 @@ export class Producer implements Table { * withdraws it from everyone again. * * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin drops its handle on the old one, which closes it unless the - * application still holds a consumer clone. Announce with a {@link Route.epoch} + * broadcast: the origin retracts the old one and closes it once nobody subscribes to it. + * Announce with a {@link Route.epoch} * (`Epoch.mint()` per run) so a restart replaces the old broadcast rather than resuming * into it; at the same epoch, a local broadcast competes on cost and wins ties. */ @@ -1213,20 +1232,21 @@ export class Producer implements Table { : undefined, }); + const entry = { producer, front }; const previous = created.get(path); - created.set(path, front); + created.set(path, entry); if (previous) { - this.#retractExact(path, previous); - previous.close(); + this.#retractExact(path, previous.front); + retire(previous); } // Drop it when the broadcast closes, unless a recreate already replaced it: a stale // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce, since a consumer clone can keep the broadcast open past this close. + // later announce, since a subscriber can keep the broadcast open past this release. const release = this.#hold(() => { this.#retractExact(path, front); - if (this.#state.created?.get(path) === front) this.#state.created.delete(path); - front.close(); + if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); + retire(entry); }); void front.closed.then(release); @@ -1235,7 +1255,7 @@ export class Producer implements Table { #advertiseExact(path: Path.Valid, front: broadcast.Consumer, route: Route): void { if (!this.#state.local.peek()) throw new Error("origin is closed"); - if (this.#state.created?.get(path) !== front) throw new Error("broadcast is closed"); + if (this.#state.created?.get(path)?.front !== front) throw new Error("broadcast is closed"); // Both maps move together, so every reader sees the broadcast and its route at once. this.#state.local.mutate((broadcasts) => { broadcasts?.set(path, front); @@ -1487,9 +1507,7 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const watch of this.#state.watchers.values()) watch.version.update((n) => n + 1); - for (const front of this.#state.created?.values() ?? []) { - front.close(); - } + for (const created of this.#state.created?.values() ?? []) retire(created); this.#state.created = undefined; this.#state.local.update(() => undefined); this.#state.advertisedLocal.update(() => undefined); @@ -1967,7 +1985,13 @@ export class Consumer { const patterns = this.#scope.patterns(scope); void producer.closed.then(this.#state.watch([...patterns].map(scopeHead))); void this.#runAnnounced(producer, patterns, options?.hidden ?? false); - return producer.consume(); + const consumer = producer.consume(); + // The last reader leaving ends the stream and its watch. + void producer.unused().then( + () => producer.close(), + () => {}, + ); + return consumer; } /** The routes visible to one announcement watcher. */ diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index f382640a3e4..8cd39700aca 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -71,7 +71,7 @@ Rejected in planning: - [Session-only items stay private](/quest/m1/api-audit/model-private.md) - model items only the built-in sessions use are private - [A request's source enforces its validity](/quest/m1/api-audit/answer.md) - a stale source refuses new track requests by itself, with no public additions - [moq-transport stays private](/quest/m1/api-audit/ietf-private.md) - no public moq-net item exists only for moq-transport, and a client names its request target once as a URL -- [One lifecycle vocabulary](/quest/m1/api-audit/lifecycle.md) - finish, abort, closed, and close mean one thing on every handle, and Rust and JS observers fire alike +- [One lifecycle vocabulary in @moq/e2ee](/quest/m1/api-audit/lifecycle.md) - @moq/e2ee ends tracks and groups with finish and abort, as @moq/net does - [moq-net names say one thing](/quest/m1/api-audit/net-names.md) - demand says `wanted`, colliding stats and transport names are renamed, and Rust and JS info lookups mirror - [Request handlers are named requests](/quest/m1/api-audit/requests.md) - `dynamic()` and `Dynamic` become `requests()` and `Requests` at every layer diff --git a/quest/m1/api-audit/lifecycle.md b/quest/m1/api-audit/lifecycle.md index a158d0b9390..4aa41d96977 100644 --- a/quest/m1/api-audit/lifecycle.md +++ b/quest/m1/api-audit/lifecycle.md @@ -1,35 +1,21 @@ -# [M] One lifecycle vocabulary +# [S] One lifecycle vocabulary in @moq/e2ee ## Goal -Every model handle in moq-net and `@moq/net` (broadcast, track, group, their -demand, listings) uses each lifecycle word with one meaning, and Rust and JS -agree on when each observer fires. +`@moq/e2ee` uses the lifecycle words the way `@moq/net` does, so a publisher +wrapping its tracks and groups in encryption ends them with the same calls. ## Plan Decided 2026-10-23 (API audit follow-up, maintainer): `finish()` declares a clean end, `abort(err)` is terminal, `closed()` resolves on the final outcome, and JS `close()` releases only that handle, like a Rust drop. -[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed the -producer verbs, `closed()` semantics in both languages, group consumer -`close()`, `Lister.closed`, `Demand.unused`, and the reader `end()` removal. +[moq-dev/moq#6750](https://github.com/moq-dev/moq/pull/6750) landed this in +moq-net and `@moq/net`, including consumer `close()` releasing only its handle +for groups, announcements, and broadcasts. -What remains is JS consumers whose `close()` still ends state other handles -share. The JS wire layer and origin rely on it today: +What remains: `@moq/e2ee` still wraps the old verbs +(`Group.Producer.close(abort?)`, `Track.Producer.close(abort?)`). Split them +into `finish()` and `abort(err)` as in `@moq/net`. -- `Announce.Consumer.close(abort?)` closes the shared announcement queue, which - is how a reader cancels the stream. Pick: count consumers and give the - producer an `unused()` the session watches (recommended), or keep one reader - per queue and document `close()` as cancel. -- The last `Broadcast.Consumer.close()` closes the broadcast, including a - producer's. The origin relies on it: superseding a published broadcast - closes its producer once no consumer holds it. Pick: the producer stays open - and publishers watch `demand().unused()`, as in Rust (recommended), or keep - it for wire-owned broadcasts only and close superseded producers explicitly. - -`@moq/e2ee` wraps the old verbs (`Group.Producer.close(abort?)`); mirror the -net split there. - -Public API: `@moq/net` breaking (consumer `close()` semantics), plus -`@moq/e2ee`. Wire: none. +Public API: `@moq/e2ee` breaking. Wire: none. From 08854f91b0d87c52ac59c44928906a69b7243ea6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 08:58:54 -0700 Subject: [PATCH 12/30] test(e2ee): observe a consumer's release on a session broadcast Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/e2ee.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/js/e2ee/src/e2ee.test.ts b/js/e2ee/src/e2ee.test.ts index 22c7df742f7..a186320abfa 100644 --- a/js/e2ee/src/e2ee.test.ts +++ b/js/e2ee/src/e2ee.test.ts @@ -246,15 +246,32 @@ test("a broadcast speaks semantic names and puts physical names on the wire", as net.close(); }); +// A broadcast as a session hands it out: no producer, so it ends once no handle holds it. +class SessionBroadcast extends Broadcast.Consumer { + // biome-ignore lint/complexity/noUselessConstructor: widens the protected base constructor to public + constructor() { + super(); + } +} + test("closing a protected consumer releases its broadcast handle", () => { const generation = credential().produce(); - const net = new Broadcast.Producer(); - const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch)); + const net = new SessionBroadcast(); + const consumer = new Protected.Consumer(net, generation.credential.consume(generation.epoch)); expect(consumer.closed.peek()).toBeUndefined(); consumer.close(); consumer.close(); expect(consumer.closed.peek()).toBeNull(); +}); + +test("closing a protected consumer leaves a produced broadcast to its producer", () => { + const generation = credential().produce(); + const net = new Broadcast.Producer(); + const consumer = new Protected.Consumer(net.consume(), generation.credential.consume(generation.epoch)); + consumer.close(); + expect(net.closed.peek()).toBeUndefined(); net.close(); + expect(consumer.closed.peek()).toBeNull(); }); test("closing a protected producer ends its broadcast", () => { From b91d74b78126da405315f88e03a5c5da5e303722 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 09:57:18 -0700 Subject: [PATCH 13/30] fix(net): settle a track's outcome after its groups, and end a released reader's parked read Rust track closed() resolved Ok as soon as the declared end was reached, while a group below it was still open and could abort. It now waits for those groups to finish, as JS does. JS Group.Consumer.close() on a shared handle now wakes its own parked readFrame()/readable(), and done/isClosed reflect the release. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 19 +++++++++++++++ js/net/src/group.ts | 13 ++++++---- rs/moq-net/src/model/track.rs | 45 +++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 2de98500243..009f464b443 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -65,6 +65,25 @@ test("closing a consumer releases only that handle", async () => { expect(await other.readFrame()).toBeUndefined(); }); +test("closing a consumer ends its own parked reads", async () => { + const { producer, consumer } = pair(0); + const other = producer.consume(); + const reading = consumer.readFrame(); + const waiting = consumer.readable(); + consumer.close(); + expect(await reading).toBeUndefined(); + await waiting; + expect(consumer.isClosed).toBe(true); + expect(consumer.done).toBe(true); + + // The other reader is still parked on the open group, and reads the next frame. + const next = other.readFrame(); + producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); + expect((await next)?.payload[0]).toBe(1); + expect(other.isClosed).toBe(false); + producer.finish(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 3649b1ebf7a..7cd6dc85db2 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -756,7 +756,8 @@ export class Consumer { // The next change a parked read waits for. #changed(): GetPromise { - const changed: Getter[] = [this.#state.frames, this.#state.closed]; + // The verdict too: releasing a shared handle settles only it, and must end a parked read. + const changed: Getter[] = [this.#state.frames, this.#state.closed, this.#verdict]; if (this.#expiry) changed.push(...this.#expiry.changed); if (this.#recover) changed.push(this.#recover.changed); return Signal.race(...changed); @@ -878,14 +879,16 @@ export class Consumer { return { sequence, frame, inflated: slice?.inflated }; } - /** True once no further frames can be read: the group has closed and every buffered frame is read. */ + /** True once no further frames can be read: this handle or the group has closed, and every buffered frame is read. */ get done(): boolean { - return this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && this.#settled()); + return ( + this.#terminal !== undefined || (this.#bufferedFrames().length === 0 && (this.#released || this.#settled())) + ); } - /** True once the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ + /** True once this handle or the group has been closed, regardless of whether buffered frames remain unread. Synchronous complement to the {@link closed} promise. */ get isClosed(): boolean { - return this.#terminal !== undefined || this.#settled(); + return this.#terminal !== undefined || this.#released || this.#settled(); } // Whether the copy being read ended, for good: a failure recovery may continue is not an end. diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index e92e6909c54..f08af848bc7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2501,10 +2501,23 @@ impl TrackState { .map(|()| producer.read().abort.clone().unwrap_or(Error::Dropped)) } - /// The track's outcome once final: `Ok` once its declared end is reached, which a later - /// abort keeps, or the abort error. + /// The track's outcome once final: `Ok` once its declared end is reached with every + /// cached group below it finished, which a later abort keeps, or the abort error. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { if self.is_complete() { + // An abort after the end settled keeps the clean end; until then a group below it + // may still abort, which would cut the track off, so wait for each to finish. One + // that aborts leaves the outcome to the track's own abort or drop. + if self.abort.is_none() + && let Some(fin) = self.final_sequence() + { + for (_, slot) in self.lookup.range(..fin) { + if !slot.group.is_finished() { + let _ = slot.group.poll_closed(waiter); + return Poll::Pending; + } + } + } Poll::Ready(Ok(())) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) @@ -13000,6 +13013,34 @@ mod test { assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); } + /// A group still open below a reached end keeps the outcome pending: it finishing settles + /// the end and wakes the wait, while an abort before then cuts the track off. + #[test] + #[cfg(feature = "async")] + fn closed_waits_for_open_groups_below_the_end() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let mut producer = track_producer("test", Info::timed()); + let mut open = producer.append_group().unwrap(); + producer.finish_at(Position::group(1)).unwrap(); + let woken = Arc::new(AtomicBool::new(false)); + let waiter = kio::Waiter::new(futures::task::waker(Arc::new(FlagWake(woken.clone())))); + assert!(producer.poll_closed(&waiter).is_pending(), "group 0 may still abort"); + open.finish().unwrap(); + assert!(woken.load(Ordering::SeqCst), "the group finishing wakes the wait"); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let mut cut = track_producer("cut", Info::timed()); + let _open = cut.append_group().unwrap(); + cut.finish_at(Position::group(1)).unwrap(); + assert!(cut.poll_closed(&kio::Waiter::noop()).is_pending()); + cut.clone().abort(Error::Cancel).unwrap(); + assert!(matches!( + cut.poll_closed(&kio::Waiter::noop()), + Poll::Ready(Err(Error::Cancel)) + )); + } + /// An abort short of the end, or the last producer leaving without one, is the outcome. #[test] #[cfg(feature = "async")] From f8979320a2e0349e21f08c740adeb6d34337cd71 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:18:58 -0700 Subject: [PATCH 14/30] fix(net): count a received end's tail before settling, and list an open finished track as finished A wire end declared behind a hole settled the JS track's outcome before the pending tail was recorded, so closed reported a clean end with groups owed. A broadcast closing now reports a track whose producer finished but stays open as finished, as the Rust listing does. Co-Authored-By: Claude Opus 5.5 --- js/net/src/internal.ts | 5 +++++ js/net/src/listing.test.ts | 11 +++++++++++ js/net/src/listing.ts | 8 ++++++-- js/net/src/track.test.ts | 20 ++++++++++++++++++++ js/net/src/track.ts | 4 +++- 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/js/net/src/internal.ts b/js/net/src/internal.ts index 7a11ff61e99..af837c4b186 100644 --- a/js/net/src/internal.ts +++ b/js/net/src/internal.ts @@ -356,6 +356,8 @@ export const hooks: { trackReleased: (producer: Producer) => GetPromise; /** {@link trackReleased} for a demand handle. Assigned by `track.ts`. */ demandReleased: (demand: Demand) => GetPromise; + /** Whether the producer declared the track's end, released or not. Assigned by `track.ts`. */ + trackFinished: (producer: Producer) => boolean; /** The producer's largest reported location and its subsequent changes. */ producerLargest: (producer: Producer) => Getter; @@ -536,6 +538,9 @@ export const hooks: { demandReleased: () => { throw new Error("track.ts not loaded"); }, + trackFinished: () => { + throw new Error("track.ts not loaded"); + }, producerLargest: () => { throw new Error("track.ts not loaded"); }, diff --git a/js/net/src/listing.test.ts b/js/net/src/listing.test.ts index f1bc8291c7a..a74c0f92f77 100644 --- a/js/net/src/listing.test.ts +++ b/js/net/src/listing.test.ts @@ -94,6 +94,17 @@ test("a finished track is listed while the table holds it", async () => { expect(await tracks.drain()).toEqual(["end"]); }); +test("a track that finished but stays open ends the listing as finished", async () => { + const broadcast = new BroadcastProducer(); + const a = broadcast.createTrack("a"); + broadcast.createTrack("b"); + a.finish(); + const tracks = list(broadcast); + expect(await tracks.drain()).toEqual(["+a", "+b", "synced"]); + broadcast.close(); + expect(await tracks.drain()).toEqual(["~a", "end"]); +}); + test("a lister finishes or removes a track", async () => { const broadcast = new BroadcastProducer(); const lister = wireOf(broadcast).lister(); diff --git a/js/net/src/listing.ts b/js/net/src/listing.ts index b7c5c6dd128..05f612d0533 100644 --- a/js/net/src/listing.ts +++ b/js/net/src/listing.ts @@ -35,9 +35,13 @@ export type TracksEvent = | { kind: "removed"; name: string } | { kind: "finished"; name: string }; -/** Whether `own` closed cleanly, so its track leaves the listing as finished. */ +/** + * Whether `own` leaves the listing as finished: it closed cleanly, or it declared its end and is + * still open, as the Rust listing counts a producer that finished but still holds its track. + */ function finished(own: track.Producer): boolean { - return hooks.trackReleased(own).peek() === null; + const released = hooks.trackReleased(own).peek(); + return released === null || (released === undefined && hooks.trackFinished(own)); } /** One listed track and who lists it. */ diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index f1e06cc780e..3e05e5e62c0 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -3519,6 +3519,26 @@ async function settled(read: Promise): Promise { return done; } +// The end arrives after a higher group did, with lower ones still owed: it must not settle. +test("a received end behind a hole settles only once the hole fills", () => { + const producer = new TrackProducer("test"); + const reader = producer.subscribe({ maxDelay: Milli(60_000) }); + hooks.startTrack(producer, { start: 0 }); + const late = new GroupProducer(2); + late.finish(); + producer.writeGroup(late); + hooks.endTrack(producer, { group: 3, frame: 0 }); + expect(producer.closed.peek()).toBeUndefined(); + + for (const sequence of [0, 1]) { + const group = new GroupProducer(sequence); + group.finish(); + producer.writeGroup(group); + } + expect(producer.closed.peek()).toBeNull(); + reader.close(); +}); + test("a pending tail holds readers at a hole until it fills", async () => { const { producer, reader, write } = pendingTail(3); write(2); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 3fffc5ac404..07936253d46 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -815,6 +815,7 @@ export class Producer { hooks.endTrack = (producer, end) => producer.#endPending(end); hooks.releaseTrack = (producer) => producer.#close(undefined); hooks.trackReleased = (producer) => producer.#state.closed; + hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.startTrack = (producer, served) => { producer.#served = served; producer.#owe(); @@ -1562,6 +1563,8 @@ export class Producer { // The wire's end: repeating it is a no-op, and a mid-group end may refine the group // boundary it falls just before. Mirrors the Rust `finish_at` on a `received()` copy. #endPending(end: Position): void { + // Before the end lands, so settling it already counts what the tail still owes. + this.#tailPending = true; const declared = this.#state.end.peek(); if (declared === undefined) { this.finishAt(end); @@ -1571,7 +1574,6 @@ export class Producer { } this.#declareEnd(end); } - this.#tailPending = true; this.#owe(); } From 9420bff80b7af8447bfa1760b498e7dfd1fcbd14 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:45:28 -0700 Subject: [PATCH 15/30] feat(net)!: announcements say wanted, and the origin closes what it retires Per the newer maintainer decision: Announce.Producer exposes a wanted getter instead of unused(), matching the wanted()/wanted_changed() plan. Broadcast demand stays internal, and the origin closes a superseded or released producer explicitly rather than retiring it on demand. A released announce consumer ends its own parked next() while others still read. Co-Authored-By: Claude Opus 5.5 --- js/net/MIGRATE.md | 4 +-- js/net/src/announced.test.ts | 16 ++++++----- js/net/src/announced.ts | 39 +++++++++++++-------------- js/net/src/broadcast.test.ts | 5 ---- js/net/src/broadcast.ts | 9 ++++--- js/net/src/connection/forward.test.ts | 6 ++--- js/net/src/connection/pool.ts | 6 ++--- js/net/src/connection/reload.ts | 6 ++--- js/net/src/ietf/subscriber.ts | 6 ++--- js/net/src/lite/subscriber.ts | 6 ++--- js/net/src/origin.test.ts | 34 +++++++++-------------- js/net/src/origin.ts | 24 ++++++----------- js/net/src/util/announce.ts | 12 +++++++++ 13 files changed, 77 insertions(+), 96 deletions(-) create mode 100644 js/net/src/util/announce.ts diff --git a/js/net/MIGRATE.md b/js/net/MIGRATE.md index 1dd9bfb7353..07e5ef15230 100644 --- a/js/net/MIGRATE.md +++ b/js/net/MIGRATE.md @@ -9,8 +9,8 @@ How to update your code for each breaking change, newest first. `Group.Producer.close(abort?)` and `Track.Producer.close(abort?)` split by meaning: call `finish()` for a clean end and `abort(err)` for an error. On a group, `finish()` is terminal and an `abort` after it is a no-op. On a track, `finish()` only declares the end, so groups below it stay writable; then call `close()` to release the producer, which aborts with a `Cancel` stream error if no end was declared. Replace `track.close()` with `track.finish(); track.close();`. `Track.Producer.closed` and `Track.Demand.closed` settle once the outcome is final: `null` when the declared end is reached with every group below it finished, even while the producer stays open, or the abort error. `Group.Consumer.close()` takes no error and releases only that handle; other readers and the producer are unaffected. -`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches `unused()`, which resolves once the last consumer closes, instead of its `closed`. -The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. A publisher that closed its broadcast once readers left watches `demand().unused()` instead. A broadcast created on an origin and then superseded by a new `createBroadcast` at its path closes once nobody subscribes to it, rather than once no consumer handle holds it. +`Announce.Consumer.close()` takes no error and releases only that handle. A hand-made `Announce.Producer` watches its new `wanted` getter, which turns false once the last consumer closes, instead of its `closed`. +The last `Broadcast.Consumer.close()` no longer closes a produced broadcast; only its producer's `close()` does. Close the producer when you are done with it. A broadcast created on an origin closes when a new `createBroadcast` supersedes it at its path, or when the origin or scope that created it closes, even while a consumer handle holds it. Tracks already subscribed keep serving until they end. `Group.Demand.unused()` and `Broadcast.Demand.unused()` reject once the group or broadcast closes, instead of resolving. The wire layer's `Lister.ended` is `closed`. `Track.Subscriber.end()` and `Track.Ordered.end()` are gone; `await finished()` instead. diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index dcd482fbffa..62ccb950968 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -67,13 +67,18 @@ test("closing resolves next with undefined", async () => { test("closing a consumer releases only that handle", async () => { const producer = new Announce.Producer(); + expect(producer.wanted.peek()).toBe(false); const first = producer.consume(); const second = producer.consume(); - const unused = producer.unused(); + expect(producer.wanted.peek()).toBe(true); + // A reader parked on next() ends when its own handle closes, while another still wants it. + const early = first.next(); first.close(); first.close(); + expect(await early).toBeUndefined(); expect(await first.next()).toBeUndefined(); + expect(producer.wanted.peek()).toBe(true); expect(producer.closed.peek()).toBeUndefined(); // The other reader keeps the stream: an event still reaches it. @@ -81,15 +86,12 @@ test("closing a consumer releases only that handle", async () => { producer.append({ prefix: p("a"), captures: undefined, kind: "start", route }); expect((await second.next())?.prefix).toBe(p("a")); - // The last one leaving is what the producer watches for, and a reader parked on next() ends. - const parked = second.next(); + // The last one leaving is what the producer watches for. + const unwanted = producer.wanted.changed(); second.close(); - expect(await parked).toBeUndefined(); - await unused; + expect(await unwanted).toBe(false); expect(producer.closed.peek()).toBeUndefined(); - producer.close(); - await expect(producer.unused()).rejects.toThrow("closed"); }); test("aborting rejects next", async () => { diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index 1bf6a973c4c..4fd53dab3e7 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -68,8 +68,9 @@ class AnnounceState { // A reader holds the last event it took before syncing until it asks for the next // one, so whatever it does with that event lands before synced flips. reading = false; - // Open consumer handles. The stream is unused once the last one closes. - consumers = new Signal(0); + // Open consumer handles; the stream is wanted while any is. + consumers = 0; + wanted = new Signal(false); } // Synced once nothing is owed and the reader has come back for more. @@ -112,17 +113,11 @@ export class Producer { } /** - * Resolves once no consumer holds the stream: the last one {@link Consumer.close}d. Rejects - * once the stream closes. + * Whether any consumer still holds the stream: false before the first {@link consume} and + * once the last one {@link Consumer.close}s, which is how a reader cancels it. */ - async unused(): Promise { - const state = this.#state; - for (;;) { - const closed = state.closed.peek(); - if (closed !== undefined) throw closed ?? new Error("announcements are closed"); - if (state.consumers.peek() === 0) return; - await Signal.race(state.consumers, state.closed); - } + get wanted(): Getter { + return this.#state.wanted; } /** Writes an event to the queue. */ @@ -178,12 +173,13 @@ let makeConsumer: (state: AnnounceState) => Consumer; */ export class Consumer { #state: AnnounceState; - // Guards against a double close() on this handle over-decrementing the consumer count. - #released = false; + // Set once this handle closes, ending its own reads and guarding the consumer count. + #released = new Signal(false); private constructor(state: AnnounceState) { this.#state = state; - state.consumers.update((count) => count + 1); + state.consumers++; + state.wanted.set(true); } /** Settles once the stream closes; see {@link Producer.closed}. */ @@ -223,7 +219,7 @@ export class Consumer { state.reading = false; settle(state); for (;;) { - if (this.#released) return undefined; + if (this.#released.peek()) return undefined; const announce = state.queue.peek().shift(); if (announce) { if (state.owed) state.owed -= 1; @@ -235,17 +231,18 @@ export class Consumer { if (closed instanceof Error) throw closed; if (closed !== undefined) return undefined; - await Signal.race(state.queue, state.closed, state.consumers); + await Signal.race(state.queue, state.closed, this.#released); } } /** * Release this handle, like dropping a Rust handle: other consumers keep reading, and the - * producer sees the stream {@link Producer.unused} once the last one closes. Idempotent. + * producer sees the stream no longer {@link Producer.wanted} once the last one closes. + * Idempotent. */ close() { - if (this.#released) return; - this.#released = true; - this.#state.consumers.update((count) => count - 1); + if (this.#released.peek()) return; + this.#released.set(true); + if (--this.#state.consumers === 0) this.#state.wanted.set(false); } } diff --git a/js/net/src/broadcast.test.ts b/js/net/src/broadcast.test.ts index c8e245a68b5..e3f957b7e86 100644 --- a/js/net/src/broadcast.test.ts +++ b/js/net/src/broadcast.test.ts @@ -433,12 +433,7 @@ test("closing every consumer leaves a produced broadcast to its producer", async const consumer = producer.consume(); const subscriber = consumer.track("video").subscribe(); consumer.close(); - expect(producer.closed.peek()).toBeUndefined(); - - // The producer watches demand instead: the last subscriber leaving is what it sees. - const unused = producer.demand().unused(); subscriber.close(); - await unused; expect(producer.closed.peek()).toBeUndefined(); track.close(); diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index da79b742a77..e9c2dc52a3e 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -328,9 +328,9 @@ function adopt(state: BroadcastState, producer: track.Producer, info: track.Info let makeDemand: (state: BroadcastState) => Demand; /** - * A watch-only view of demand for any track in a broadcast, mirroring the Rust `broadcast::Demand`. + * A watch-only view of demand for any track in a broadcast. * - * @public + * @internal Tests only; nothing outside the package consumes it. */ export class Demand { #state: BroadcastState; @@ -413,8 +413,9 @@ export class Producer { } /** - * Watch demand for the broadcast's tracks: a publisher that should stop once nobody reads - * waits on its {@link Demand.unused}, since closing every consumer leaves the broadcast open. + * Watch demand for the broadcast's tracks. + * + * @internal Tests only; nothing outside the package consumes it. */ demand(): Demand { return this.#demand; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index e177d54929e..ea1e4e40b18 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -10,6 +10,7 @@ import * as Path from "../path.ts"; import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; +import { closeUnwanted } from "../util/announce.ts"; const LINGER = 2000; @@ -66,10 +67,7 @@ class FakeSession { if (this.syncedAtOnce) stream.synced(); const consumer = stream.consume(); // The last reader leaving cancels the request, as a session's does. - void stream.unused().then( - () => stream.close(), - () => {}, - ); + closeUnwanted(stream); return consumer; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 4b1913fc401..14279b4ac03 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -31,6 +31,7 @@ import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; +import { closeUnwanted } from "../util/announce.ts"; /** How long an unreferenced shared connection lingers before it actually closes. */ const LINGER_MS = Milli(2000); @@ -354,10 +355,7 @@ export class Connection { pumpAnnounced(effect, origin.announced(scope, options), producer); }); - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); void producer.closed.then(stop); return consumer; diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index a1b2bdf9a1c..d355f7cd398 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -20,6 +20,7 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; +import { closeUnwanted } from "../util/announce.ts"; /** * Exponential backoff settings for the reconnect loop. @@ -719,10 +720,7 @@ export class Reload { }); this.#signals.cleanup(() => pump.close()); - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); void producer.closed.then(() => pump.close()); return consumer; diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 9b2ee1af076..1f7a5ddc656 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -81,6 +81,7 @@ import { } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; import { Version } from "./version.ts"; +import { closeUnwanted } from "../util/announce.ts"; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust @@ -357,10 +358,7 @@ export class Subscriber { this.#announcedConsumers.set(announced, filter); const consumer = announced.consume(); // The last reader leaving ends the stream, which cancels the request. - void announced.unused().then( - () => announced.close(), - () => {}, - ); + closeUnwanted(announced); void this.#runAnnounced(announced, prefix, filter.hidden && this.#hidden).finally(() => { this.#announcedConsumers.delete(announced); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index a1b1aa8a263..637acce93a4 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -80,6 +80,7 @@ import { Version, waitsForSubscriberFin, } from "./version.ts"; +import { closeUnwanted } from "../util/announce.ts"; // A peer's buffered TRACKS events decode without reaching a task boundary, and a flood of adds // and removals of one name never reaches the announces cap: yield so it cannot starve the host. @@ -260,10 +261,7 @@ export class Subscriber { // The wire speaks announce interest by prefix, and echoes suffixes beneath it. const consumer = announced.consume(); // The last reader leaving ends the stream, which cancels the request. - void announced.unused().then( - () => announced.close(), - () => {}, - ); + closeUnwanted(announced); void this.#runAnnounced(announced, scopeHead(scope), scope, options?.hidden ?? false); return consumer; } diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index ca385798563..6c8a296b30e 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -7,7 +7,7 @@ import { StreamCode, StreamError } from "./error.ts"; import { Producer as GroupProducer } from "./group.ts"; import { HopSchema, Route } from "./hop.ts"; import { hooks, spreadHash } from "./internal.ts"; -import { SessionBroadcast } from "./mock.ts"; +import { SessionBroadcast, textFrame } from "./mock.ts"; import type { Consumer, Table } from "./origin.ts"; import { Producer } from "./origin.ts"; import * as Path from "./path.ts"; @@ -240,31 +240,28 @@ test("a republish closes the superseded broadcast", async () => { origin.close(); }); -test("a subscriber keeps a superseded broadcast alive", async () => { +test("a superseded broadcast closes, and its subscribers keep reading", async () => { const origin = new Producer(); const consumer = origin.consume(); const path = Path.from("room"); const first = publish(origin, path); - const track = first.createTrack("video"); + const track = first.createTrack("video", { timescale: Timescale.MILLI }); const mine = await routed(consumer, path); const subscriber = mine?.track("video").subscribe(); - expect(subscriber).toBeDefined(); - // Demand lands a microtask after the subscribe. - await settle(); + if (!subscriber) throw new Error("expected a subscriber"); publish(origin, path); - await settle(); - - // The subscriber holds the old broadcast open even though it is unpublished. - expect(first.closed.peek()).toBeUndefined(); - - // A handle alone is no demand: the last subscriber leaving closes it. - subscriber?.close(); - await settle(); + // The origin closes the old producer itself, whoever still holds a handle on it. expect(first.closed.peek()).toBeNull(); + // A track already subscribed is not the broadcast: it keeps serving until it ends. + track.writeFrame(textFrame("late")); + expect((await subscriber.recvGroup())?.sequence).toBe(0); + + subscriber.close(); mine?.close(); + track.finish(); track.close(); origin.close(); }); @@ -1979,21 +1976,16 @@ test("a broadcast a derived handle released cannot announce again", async () => const scoped = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.all()])); const path = Path.from("room"); const broadcast = publish(scoped, path); - const track = broadcast.createTrack("video"); const held = await routed(consumer, path); - const subscriber = held?.track("video").subscribe(); - await settle(); scoped.close(); - // A subscriber keeps the broadcast open, but the released handle no longer routes it. - expect(broadcast.closed.peek()).toBeUndefined(); + // The released handle closes what it created, whoever still holds a handle on it. + expect(broadcast.closed.peek()).toBeNull(); expect(() => broadcast.announce()).toThrow("broadcast is closed"); expect((await announcedView(origin)).peek().has(path)).toBe(false); - subscriber?.close(); held?.close(); - track.close(); origin.close(); }); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index a8e0736028e..792d1f85d85 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -37,6 +37,7 @@ import { sameInstance, wireOf, } from "./wire.ts"; +import { closeUnwanted } from "./util/announce.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; @@ -486,17 +487,11 @@ interface Created { front: broadcast.Consumer; } -// Let go of a created broadcast the origin no longer routes. Only a producer ends a broadcast, as -// in Rust, so it closes once nobody subscribes to it rather than under a reader still draining it. +// End a created broadcast the origin no longer routes. Consumer handles never end a produced +// broadcast, so the origin closes its producer; tracks already subscribed keep serving. function retire({ producer, front }: Created): void { front.close(); - const demand = producer.demand(); - if (!demand.used.peek()) producer.close(); - else - void demand.unused().then( - () => producer.close(), - () => {}, - ); + producer.close(); } /** Reactive backing state shared by origin producers and consumers. */ @@ -1207,8 +1202,8 @@ export class Producer implements Table { * withdraws it from everyone again. * * Close the producer to drop it. Creating a path again supersedes the previous - * broadcast: the origin retracts the old one and closes it once nobody subscribes to it. - * Announce with a {@link Route.epoch} + * broadcast: the origin retracts and closes the old one, and tracks already subscribed + * keep serving until they end. Announce with a {@link Route.epoch} * (`Epoch.mint()` per run) so a restart replaces the old broadcast rather than resuming * into it; at the same epoch, a local broadcast competes on cost and wins ties. */ @@ -1242,7 +1237,7 @@ export class Producer implements Table { // Drop it when the broadcast closes, unless a recreate already replaced it: a stale // broadcast closing must not unpublish the live one. Clearing `created` also stops a - // later announce, since a subscriber can keep the broadcast open past this release. + // later announce. const release = this.#hold(() => { this.#retractExact(path, front); if (this.#state.created?.get(path) === entry) this.#state.created.delete(path); @@ -1987,10 +1982,7 @@ export class Consumer { void this.#runAnnounced(producer, patterns, options?.hidden ?? false); const consumer = producer.consume(); // The last reader leaving ends the stream and its watch. - void producer.unused().then( - () => producer.close(), - () => {}, - ); + closeUnwanted(producer); return consumer; } diff --git a/js/net/src/util/announce.ts b/js/net/src/util/announce.ts new file mode 100644 index 00000000000..71f3760a1b8 --- /dev/null +++ b/js/net/src/util/announce.ts @@ -0,0 +1,12 @@ +import { Signal } from "@moq/signals"; +import type { Producer } from "../announced.ts"; + +/** Close an announcement stream once no consumer wants it: how a reader cancels the request. */ +export function closeUnwanted(producer: Producer): void { + void (async () => { + while (producer.wanted.peek() && producer.closed.peek() === undefined) { + await Signal.race(producer.wanted, producer.closed); + } + producer.close(); + })(); +} From 1497ca5c22aa53e9cbce6971b3a3a8e295f2113b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:51:02 -0700 Subject: [PATCH 16/30] chore(net): regenerate the API report and format Co-Authored-By: Claude Opus 5.5 --- js/net/api/index.api.md | 11 +---------- js/net/src/connection/forward.test.ts | 2 +- js/net/src/connection/pool.ts | 2 +- js/net/src/connection/reload.ts | 2 +- js/net/src/ietf/subscriber.ts | 2 +- js/net/src/lite/subscriber.ts | 2 +- js/net/src/origin.ts | 2 +- 7 files changed, 7 insertions(+), 16 deletions(-) diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index 54708d6760a..4c6654e3e4c 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -121,7 +121,6 @@ declare namespace Broadcast { export { Tracks, TracksEvent, - Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -131,7 +130,6 @@ declare namespace Broadcast_2 { export { Tracks, TracksEvent, - Demand_3 as Demand, Producer_3 as Producer, Consumer_4 as Consumer } @@ -344,12 +342,6 @@ class Demand_2 { get used(): Getter; } -class Demand_3 { - get closed(): GetPromise; - unused(): Promise; - get used(): Getter; -} - class Duplicate extends Error { constructor(message?: string); } @@ -786,7 +778,6 @@ class Producer_3 { get closed(): GetPromise; consume(): Consumer_4; createTrack(name: string, info?: Partial): track.Producer; - demand(): Demand_3; removeTrack(name: string): void; get route(): Route | undefined; track(name: string): track.Consumer; @@ -798,7 +789,7 @@ class Producer_4 { close(abort?: Error): void; get closed(): GetPromise; consume(): Consumer; - unused(): Promise; + get wanted(): Getter; } class Producer_5 implements Table { diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index ea1e4e40b18..757ab26bf66 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -7,10 +7,10 @@ import * as Epoch from "../epoch.ts"; import { Route } from "../hop.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { registerWire, wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; import { forwardAnnounced } from "./forward.ts"; -import { closeUnwanted } from "../util/announce.ts"; const LINGER = 2000; diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 14279b4ac03..cdbcfbb5e5e 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -11,6 +11,7 @@ import type * as Auth from "../auth.ts"; import type { Handle } from "../bandwidth.ts"; import * as Origin from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { after } from "../util/timer.ts"; import { type AcceptProps as AcceptPropsType, accept } from "./accept.ts"; @@ -31,7 +32,6 @@ import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { pumpAnnounced, Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; import type { Transport as TransportType } from "./transport.ts"; -import { closeUnwanted } from "../util/announce.ts"; /** How long an unreferenced shared connection lingers before it actually closes. */ const LINGER_MS = Milli(2000); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index d355f7cd398..d9a6862e8c5 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -7,6 +7,7 @@ import { Allocator } from "../bandwidth.ts"; import { error, reason, SessionCode, SessionError } from "../error.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Follow } from "../util/observed.ts"; import { wireOf } from "../wire.ts"; import { @@ -20,7 +21,6 @@ import type { Established } from "./established.ts"; import { DISCOVERY_REMOVED } from "./forward.ts"; import { DEFAULT_HANDOVER, type Drain, dialed, type GoawayProps, handover, pinnedTransport, target } from "./goaway.ts"; import type { Probe, Stats } from "./stats.ts"; -import { closeUnwanted } from "../util/announce.ts"; /** * Exponential backoff settings for the reconnect loop. diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 0dcdd2c4502..09f0e727b52 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -37,6 +37,7 @@ import { Tail } from "../tail.ts"; import type { Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, requestOptions } from "../util/deadline.ts"; import { abandoned as demandAbandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -81,7 +82,6 @@ import { } from "./subscribe_namespace.ts"; import { MalformedAuthToken } from "./token.ts"; import { Version } from "./version.ts"; -import { closeUnwanted } from "../util/announce.ts"; // Wire ceiling (2^62-1). A draining session stamps it on every live route so any other // candidate outranks it, while the route stays selectable as the last path. Matches Rust diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 637acce93a4..20493e4e71a 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -32,6 +32,7 @@ import { Tail } from "../tail.ts"; import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { untilAborted } from "../util/abort.ts"; +import { closeUnwanted } from "../util/announce.ts"; import { Deadline, REQUEST_TIMEOUT_MS, requestOptions } from "../util/deadline.ts"; import { abandoned, isAbandoned, unwanted } from "../util/demand.ts"; import { LIMITS, type Limits, SlotMap, Slots } from "../util/limits.ts"; @@ -80,7 +81,6 @@ import { Version, waitsForSubscriberFin, } from "./version.ts"; -import { closeUnwanted } from "../util/announce.ts"; // A peer's buffered TRACKS events decode without reaching a task boundary, and a flood of adds // and removals of one name never reaches the announces cap: yield so it cannot starve the host. diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 130717454be..610f45508f3 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -28,6 +28,7 @@ import { } from "./internal.ts"; import * as Path from "./path.ts"; import { Front } from "./resume.ts"; +import { closeUnwanted } from "./util/announce.ts"; import { type Advertised, type Advertisements, @@ -37,7 +38,6 @@ import { sameInstance, wireOf, } from "./wire.ts"; -import { closeUnwanted } from "./util/announce.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; From 2c9e1a13bf6247a78b1d1f2e7a618d3e679a83a5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 10:52:42 -0700 Subject: [PATCH 17/30] test(net): abort with the lifecycle verb in a resume test from main Co-Authored-By: Claude Opus 5.5 --- js/net/src/resume.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/net/src/resume.test.ts b/js/net/src/resume.test.ts index 48d859f8a0d..f1a9a7b534c 100644 --- a/js/net/src/resume.test.ts +++ b/js/net/src/resume.test.ts @@ -1155,8 +1155,8 @@ test("a drop a replaced route held back is no verdict on the route serving next" expect(recvDisposition(sub)).toBe("idle"); front.serve(c.consume()); await settle(); - ta.producer.close(new StreamError(StreamCode.SessionClosed)); - tb.producer.close(new StreamError(StreamCode.SessionClosed)); + ta.producer.abort(new StreamError(StreamCode.SessionClosed)); + tb.producer.abort(new StreamError(StreamCode.SessionClosed)); await settle(); expect(recvDisposition(sub)).toBe("idle"); tc.write(1); From 8d896d6d4c341f9c1bfecf03c856049c48267fc2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 11:02:48 -0700 Subject: [PATCH 18/30] fix(net): a released shared group consumer reads nothing more Its queue is the producer's, so reading after release took frames from the other readers. A track's own read handle still drains what it buffered. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 7 +++++++ js/net/src/group.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 009f464b443..6cf1174335e 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -81,6 +81,13 @@ test("closing a consumer ends its own parked reads", async () => { producer.writeFrame({ payload: new Uint8Array([1]), timestamp: Timestamp.now() }); expect((await next)?.payload[0]).toBe(1); expect(other.isClosed).toBe(false); + + // A later write never reaches the released handle, so it takes nothing from the other reader. + producer.writeFrame({ payload: new Uint8Array([2]), timestamp: Timestamp.now() }); + expect(consumer.done).toBe(true); + expect(consumer.tryReadFrame()).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect((await other.readFrame())?.payload[0]).toBe(2); producer.finish(); }); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 7cd6dc85db2..84e126ad0d8 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -849,6 +849,8 @@ export class Consumer { // The unread frames. Every look at the buffer goes through here, so readiness, reads and // `done` agree on what is left. #bufferedFrames(): Frame[] { + // A released shared handle reads nothing more: the queue it shares belongs to the other readers. + if (this.#released && !this.#owned) return []; const frames = this.#state.frames.peek(); // A resumed copy repeats the frames an earlier one already delivered. while (this.#state.start < this.#skip && frames.length > 0) { @@ -1028,8 +1030,10 @@ export class Consumer { } /** - * Release this handle, like dropping a Rust consumer: it reads what is buffered and then - * ends. Never changes the group for other readers. Idempotent. + * Release this handle, like dropping a Rust consumer. Never changes the group for other + * readers. A track's read handle drains what it buffered and then ends; one from + * {@link Producer.consume} ends at once, since its queue is shared with the producer's other + * readers. Idempotent. */ close(): void { this.#release(undefined); From 29792cf72f427d7aa357b11e066b0d86531a6f60 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 11:36:29 -0700 Subject: [PATCH 19/30] fix(net): a settled track end is final Latch the settled end before any write that could reopen it (a late group below the end, a datagram, a fresh grace for the tail) as well as before an abort, so `closed()` and readers never see a clean outcome revised. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 60 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 0a9049a0457..23419bef488 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -458,8 +458,9 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, - // Whether the declared end still stands after an abort: every group below it was - // produced and finished before the abort landed. See [`Self::is_complete`]. + // Whether the declared end settled before a write that could reopen it or an abort: + // every group below it was produced and finished then, so the clean end stands for + // good. See [`Self::latch_settled`]. settled: bool, // Active subscriptions, in their own [`kio::Lock`] so a read-only `Consumer` @@ -1021,6 +1022,7 @@ impl TrackState { /// Billed before the debt is settled, so its bytes accrue debt, but pushed after, so the /// newcomer is never a victim of its own write. fn push_datagram(&mut self, datagram: super::datagram::Stored) { + self.latch_settled(); let datagram = super::datagram::Buffered::new(datagram, &self.cache); self.charge_debt(); if self.datagrams.len() == MAX_DATAGRAMS { @@ -1852,6 +1854,7 @@ impl TrackState { /// groups reach subscribers, fetched backfill is served by sequence only. A group /// counts as content only once [`Self::reveal`] reports its first frame or finish. fn insert_group(&mut self, group: &group::Handle, visible: bool) { + self.latch_settled(); let sequence = group.sequence; self.next_stamp = self.next_stamp.wrapping_add(1); let stamp = self.next_stamp; @@ -2184,6 +2187,7 @@ impl TrackState { /// or above it, and a pending tail above the last arrival, is waited for a fresh grace: /// the feed may only now be sending it. fn set_start(&mut self, start_sequence: Option, pending: bool) { + self.latch_settled(); self.start_sequence = start_sequence; self.start_pending = pending; if let Some(start) = start_sequence { @@ -2250,13 +2254,15 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - // `sealed` is a declared end the last producer dropped short of. An abort still wins - // unless that end had already settled: a group below it was still open. + if self.settled { + return true; + } + // `sealed` is a declared end the last producer dropped short of. let reached = self.sealed || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && (self.abort.is_none() || self.settled) + reached && self.abort.is_none() } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2343,18 +2349,21 @@ impl TrackState { /// Whether the declared end is reached, the pending tail owes nothing below it, and /// every cached group below it finished, so nothing the end promised is still in flight. - /// - /// Decided as an abort lands, which ends any wait for the tail: a group the tail still - /// owed was cut off by the abort, so readers get the error rather than a clean end. fn is_settled(&self) -> bool { let Some(fin) = self.final_sequence() else { return false; }; let reached = self.sealed || self.reaches(fin); - reached - && (self.abort.is_none() || self.settled) - && !self.owes(fin) - && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + reached && !self.owes(fin) && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) + } + + /// Record that the end settled before a write that could reopen it: an abort, a late + /// group below the end, or a fresh grace for the tail. Whoever saw the clean end, a + /// reader or `closed()`, keeps it; a group the end still waited on was cut off instead. + fn latch_settled(&mut self) { + if !self.settled && self.abort.is_none() && self.is_settled() { + self.settled = true; + } } /// Where a replacement route should pick this track up: one past the last frame @@ -2508,7 +2517,7 @@ impl TrackState { // An abort after the end settled keeps the clean end; until then a group below it // may still abort, which would cut the track off, so wait for each to finish. One // that aborts leaves the outcome to the track's own abort or drop. - if self.abort.is_none() + if !self.settled && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { @@ -2621,7 +2630,7 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) -> OpenGroups { // past the grace was lost before the abort rather than cut off by it. let now = state.cache.pool().now_wall(); state.fold_arrivals(now); - state.settled = state.is_settled(); + state.latch_settled(); state.abort = Some(err.clone()); // Keep what finished for consumers still draining: they get it, then the abort (or // the clean end, when the end settled). Clearing here would abort finished groups a @@ -13041,6 +13050,29 @@ mod test { )); } + /// A settled end is final: a late group below it and a later abort leave the outcome + /// `closed()` already saw. + #[test] + #[cfg(feature = "async")] + fn a_settled_end_survives_a_late_group_and_an_abort() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", Info::timed()); + let demand = producer.demand(); + producer + .create_group(group::Info { sequence: 5 }) + .unwrap() + .finish() + .unwrap(); + producer.finish_at(Position::group(6)).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _late = producer.create_group(group::Info { sequence: 4 }).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + assert!(matches!(demand.closed().now_or_never(), Some(Ok(())))); + } + /// An abort short of the end, or the last producer leaving without one, is the outcome. #[test] #[cfg(feature = "async")] From c70f314fcbc9e73aeb2fd2295020de4b39a061b3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 12:23:47 -0700 Subject: [PATCH 20/30] fix(net): a settled end keeps its clean outcome in JS too Settle the JS outcome before a write that could reopen the end (a late group, a datagram, a new start or subscription), and keep the clean end when a later abort lands, as Rust does. Rust readers go back to waiting for a widened tail: the latch fixes the outcome, not what is still served. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 18 ++++++++++++++++++ js/net/src/track.ts | 10 ++++++++-- rs/moq-net/src/model/track.rs | 18 ++++++++++-------- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 3e05e5e62c0..b12cbc8726d 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2794,6 +2794,24 @@ test("an abort after the declared end reached by a datagram ends clean", () => { expect(producer.closed.peek()).toBeNull(); }); +test("a settled end survives a late group and an abort", async () => { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const subscriber = producer.subscribe(); + const last = new GroupProducer(5); + producer.writeGroup(last); + last.finish(); + producer.finishAt({ group: 6, frame: 0 }); + await settle(); + expect(producer.closed.peek()).toBeNull(); + + producer.writeGroup(new GroupProducer(4)); + producer.abort(new Error("boom")); + expect(producer.closed.peek()).toBeNull(); + expect((await subscriber.recvGroup())?.sequence).toBe(5); + // The abort cut nothing the end promised, so the subscriber ends cleanly. + expect(await subscriber.recvGroup()).toBeUndefined(); +}); + test("finish is not terminal: closed settles once the groups below the end finish", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index f4dd846a849..57d06dc906c 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -817,6 +817,7 @@ export class Producer { hooks.trackReleased = (producer) => producer.#state.closed; hooks.trackFinished = (producer) => producer.#state.end.peek() !== undefined; hooks.startTrack = (producer, served) => { + producer.#settle(); producer.#served = served; producer.#owe(); for (const state of [producer.#state, ...producer.#sinks]) state.startChanged.update((rev) => rev + 1); @@ -1096,6 +1097,7 @@ export class Producer { // the aggregate. The wire layer observes this signal and emits SUBSCRIBE_UPDATE. #updateSubscription(): void { const combined = combineSubscriptions(this.#sinks); + this.#settle(); const retained = this.#state.info.peek()?.maxAge; if (combined && retained !== undefined) combined.maxDelay = Milli(Math.min(combined.maxDelay ?? Milli.zero, retained)); @@ -1322,6 +1324,7 @@ export class Producer { // frame 0 passes its checked `start` and whether it is `late`; any other checks frame 0 // against this track when it lands. #publish(group: GroupProducer, start?: Timestamp, late = false): void { + this.#settle(); const entry: CachedGroup = { group, mirrors: new Map(), arrived: now() }; this.#undrop(group.sequence); group.closed.then((closed) => { @@ -1476,6 +1479,7 @@ export class Producer { // Late subscribers do NOT replay old datagrams (best-effort, unlike the group cache). A // compressed track's rings share only `slice`: each holds its own lazy view of it. #publishDatagram(datagram: Datagram, slice?: Stored): void { + this.#settle(); const { sequence, timestamp } = datagram; this.#advance(sequence, false); this.#arrive(sequence); @@ -1600,7 +1604,9 @@ export class Producer { this.#settle(); } - // Settle the outcome cleanly once the declared end is reached and settled. + // Settle the outcome cleanly once the declared end is reached and settled. Also run before a + // write that could reopen it (a late group below the end, a fresh grace for the tail): that + // write is still served, but the clean outcome stays final. Mirrors the Rust `latch_settled`. #settle(): void { if (this.#state.outcome.peek() === undefined && this.#settled()) this.#state.outcome.set(null); } @@ -1710,7 +1716,7 @@ export class Producer { if (this.#state.closed.peek() !== undefined) return; // A gap past the grace was lost before the abort rather than cut off by it. if (abort && this.#tailPending) this.#arrivals.expire(now(), this.#grace()); - if (abort && this.#settled()) abort = undefined; + if (abort && (this.#state.outcome.peek() === null || this.#settled())) abort = undefined; // Nobody will finish these, so a subscriber that has not taken one yet never sees it. // Not evicted: a reader already holding one keeps its frames and sees the abort. const open = abort ? this.#cache.filter((entry) => entry.group.closed.peek() === undefined) : []; diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 23419bef488..594cf80b202 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2254,15 +2254,13 @@ impl TrackState { /// An abort before the end settled wins over it: a group below the boundary was /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - if self.settled { - return true; - } - // `sealed` is a declared end the last producer dropped short of. + // `sealed` is a declared end the last producer dropped short of. An abort still wins + // unless that end had already settled: a group below it was still open. let reached = self.sealed || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && self.abort.is_none() + reached && (self.abort.is_none() || self.settled) } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2358,8 +2356,9 @@ impl TrackState { } /// Record that the end settled before a write that could reopen it: an abort, a late - /// group below the end, or a fresh grace for the tail. Whoever saw the clean end, a - /// reader or `closed()`, keeps it; a group the end still waited on was cut off instead. + /// group below the end, or a fresh grace for the tail. The clean outcome `closed()` saw + /// then stands, and a later abort ends readers cleanly. A late group or a wider + /// subscription is still served; it just cannot turn the outcome into an error. fn latch_settled(&mut self) { if !self.settled && self.abort.is_none() && self.is_settled() { self.settled = true; @@ -2513,11 +2512,14 @@ impl TrackState { /// The track's outcome once final: `Ok` once its declared end is reached with every /// cached group below it finished, which a later abort keeps, or the abort error. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { + if self.settled { + return Poll::Ready(Ok(())); + } if self.is_complete() { // An abort after the end settled keeps the clean end; until then a group below it // may still abort, which would cut the track off, so wait for each to finish. One // that aborts leaves the outcome to the track's own abort or drop. - if !self.settled + if self.abort.is_none() && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { From 757a1332a18c9971ad39832a5e4f1759b09cd275 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:10:41 -0700 Subject: [PATCH 21/30] fix(net): keep the lifecycle docs and helpers clean in the no-async shape The feature-shape gate from main documents and builds moq-net without async, where track::Producer::closed does not exist and only sessions call poll_aborted. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 9540eeb5a70..4d65c0da53f 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -3267,13 +3267,15 @@ impl Producer { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the track's outcome is final; see [`Self::closed`]. + /// Poll until the track's outcome is final: `Ok` once its declared end is reached, or + /// the abort error, or [`Error::Dropped`] once every producer left without either. pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll> { TrackState::settle(self.state.poll_ref(waiter, |state| state.poll_outcome(waiter))) } /// Poll until the track is aborted, or every producer left; ready with the cause. // Sessions stop serving on an abort, but keep taking lower groups past a finish. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn poll_aborted(&self, waiter: &kio::Waiter) -> Poll { TrackState::poll_aborted(&self.state, waiter) } From b8bf0fb5bfaf1d2ecf7e5b65d3e2eddae3d4ec7f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:22:38 -0700 Subject: [PATCH 22/30] fix(net): a request whose readers left blocks an overlapping one Main's PREFIX_OVERLAP gate treats a request as on its way out once its producer closes. Closing the last consumer now closes the producer a turn later, through closeUnwanted, so a broader request opened in the same turn went out beside it. It is leaving as soon as nobody wants it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/subscriber.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 940c3d4c3a5..e3abf64e56f 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -355,10 +355,11 @@ export class Subscriber { // The overlapping requests on their way out, such as the narrower ones an exclusive // discovery just closed for this one. Those still open overlap it only where the - // caller holds both on purpose. + // caller holds both on purpose. One whose last reader just closed is leaving even + // before its producer closes. const blockers: Promise[] = []; for (const [other, { prefix: asked, gone }] of this.#announcedConsumers) { - if (gone === undefined || other.closed.peek() === undefined) continue; + if (gone === undefined || (other.wanted.peek() && other.closed.peek() === undefined)) continue; if (Path.hasPrefix(asked, prefix) || Path.hasPrefix(prefix, asked)) blockers.push(gone); } From beff2e0a9d6df040d090bbe355f281b45bfeba8f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 16:54:03 -0700 Subject: [PATCH 23/30] fix(net): a track's closed() sees a group that finishes while it scans poll_outcome read is_finished() and then polled the group, so a group finishing between the two returned Ready without parking the waiter, and the scan slept on Pending. One poll decides both. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 4d65c0da53f..be8b2cb56d7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2523,8 +2523,8 @@ impl TrackState { && let Some(fin) = self.final_sequence() { for (_, slot) in self.lookup.range(..fin) { - if !slot.group.is_finished() { - let _ = slot.group.poll_closed(waiter); + // One poll, so a group finishing meanwhile is seen rather than slept on. + if !matches!(slot.group.poll_closed(waiter), Poll::Ready(Ok(()))) { return Poll::Pending; } } From abc3b975cf3fedefa845d6242c50b7254e53bb43 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 25 Oct 2026 19:15:09 -0700 Subject: [PATCH 24/30] test(net): the SUBSCRIPTION_ENDED tail test reads the missing end through finished() Subscriber.end() is gone in this PR; main's new test used it. Co-Authored-By: Claude Opus 5.5 --- js/net/src/ietf/tail.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index 03d779d5da3..27a33406830 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -176,7 +176,8 @@ test("a SUBSCRIPTION_ENDED PUBLISH_DONE drains, then aborts the track", async () expect(await readAll(await reader.recvGroup())).toEqual(["0.0"]); expect(await reader.closed).toBeInstanceOf(Error); - expect(reader.end()).toBeUndefined(); + // No end was declared: the track fails rather than finish. + await expect(reader.finished()).rejects.toBeInstanceOf(Error); }); test("an UNAUTHORIZED PUBLISH_DONE reads as the revocation a moq-lite peer sends", async () => { From fd1cc05b06b93b91805302db0e3dfe169be916b5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 16:02:27 -0700 Subject: [PATCH 25/30] fix(net): a released shared group consumer ignores a later abort A read on a closed consume() handle consulted the shared state before its own release, so a producer abort after close() failed the read while the handle's closed stayed null. The release now ends the read first; an owned mirror's release already settles its own state. Co-Authored-By: Claude Opus 5.5 --- js/net/src/group.test.ts | 10 ++++++++++ js/net/src/group.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/js/net/src/group.test.ts b/js/net/src/group.test.ts index 6cf1174335e..f67d874767a 100644 --- a/js/net/src/group.test.ts +++ b/js/net/src/group.test.ts @@ -91,6 +91,16 @@ test("closing a consumer ends its own parked reads", async () => { producer.finish(); }); +test("a released consumer ignores a later abort", async () => { + const { producer, consumer } = pair(0); + const reading = consumer.readFrame(); + consumer.close(); + producer.abort(new Error("upstream")); + expect(await reading).toBeUndefined(); + expect(await consumer.readFrame()).toBeUndefined(); + expect(await consumer.closed).toBeNull(); +}); + test("a group caps its frame count and aborts on the next write", async () => { const { producer, consumer } = pair(0); diff --git a/js/net/src/group.ts b/js/net/src/group.ts index 0752f7db3fd..4e04be14278 100644 --- a/js/net/src/group.ts +++ b/js/net/src/group.ts @@ -992,6 +992,8 @@ export class Consumer { return read; } + // A released shared handle ends here, whatever the group does next for its other readers. + if (this.#released && !this.#owned) return; const closed = this.#state.closed.peek(); if (closed instanceof Error) { if (await this.#resume(closed)) continue; @@ -1005,8 +1007,6 @@ export class Consumer { return; } - // A released shared handle ends here; the group itself stays open for others. - if (this.#released) return; // Nothing buffered and the group is still open: this wait is the stall the // drift budget bounds, and the only place it applies. if (this.#expire()) continue; From b4f093a2882ead94d070efdeadea6da293542d71 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 16:13:15 -0700 Subject: [PATCH 26/30] fix(net): release a received copy without declaring an end the publisher never sent Splitting close() mapped the wire copies' clean release to finish() and close(), which declares an end at the received count. A covered lite-07 range, an idle unsubscribe, or an unwanted track-info lookup is a released copy, not finished content, so it releases through the private releaseTrack hook, keeping only an end the publisher declared. Co-Authored-By: Claude Opus 5.5 --- js/net/src/broadcast.ts | 6 ++---- js/net/src/ietf/subscriber.ts | 4 ++-- js/net/src/lite/subscriber.test.ts | 4 +++- js/net/src/lite/subscriber.ts | 9 ++++----- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 4d559320a30..7203542e2bf 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -231,10 +231,8 @@ async function resolveTrackInfo(state: BroadcastState, name: string, hold?: Abor let answered = false; let ended = false; const release = () => { - if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) { - producer.finish(); - producer.close(); - } + if (lookups.opened && lookups.pending === 0 && producer.demand().wanted.peek() === undefined) + hooks.releaseTrack(producer); }; info.then( () => { diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 194731f2e0f..199a6ce8a10 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1009,8 +1009,8 @@ export class Subscriber { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); producer.abort(refused); } else { - producer.finish(); - producer.close(); + // Only a received end declares one; a copy nobody wants anymore is released, not finished. + hooks.releaseTrack(producer); } // Closing settled `done`, so an update still being written finishes and nothing starts diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index ecd9f48a22a..5de01e1ee53 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1992,7 +1992,8 @@ test("a publisher FIN without SUBSCRIBE_END still violates an open subscription" }); // A lite-07 publisher FINs a bounded subscription without SUBSCRIBE_END once it covered the range, -// which ends the subscription cleanly rather than violating the protocol. +// which ends the subscription cleanly rather than violating the protocol. Covering a range is +// not ending the track, so no end is declared. test("a lite-07 publisher FIN ends a covered bounded subscription", async () => { const version = Version.DRAFT_07; const { quic, streams, opened, closes } = fakeSession(); @@ -2018,6 +2019,7 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => for (const chunk of chunks) stream.inbound.enqueue(chunk); stream.inbound.close(); expect(await track.closed).toBeNull(); + await expect(track.finished()).rejects.toThrow("track closed before its end was known"); await drainReactions(); expect(closes).toEqual([]); } finally { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 946e072ccff..65f97dc0738 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -986,12 +986,11 @@ export class Subscriber { } // A publisher that covered the range before reading an update widening it sent - // fewer groups than the track has, so it fails rather than ending clean. + // fewer groups than the track has, so it fails rather than ending clean. Otherwise + // only a received SUBSCRIBE_END declares the track's end: a range covered or a + // subscription nobody wants anymore is a released copy, not finished content. if (short) producer.abort(short); - else { - producer.finish(); - producer.close(); - } + else hooks.releaseTrack(producer); // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); From fc8b671f0ad91b513d82df8e571dc9f8e69c586d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 19:28:26 -0700 Subject: [PATCH 27/30] test(net): read the group without async in abort_after_finish_keeps_the_clean_outcome Main's no-async model gate (#6851) builds tests without the async read_frame. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/group.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index d1c925e1fba..419a7f5d103 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -4799,7 +4799,7 @@ mod test { handle.poll_aborted(&waiter).is_pending(), "a finish alone frees nothing" ); - assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_some()); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(Some(_))))); producer.abort(Error::Cancel).unwrap(); assert!(matches!(handle.poll_closed(&waiter), Poll::Ready(Ok(())))); @@ -4807,7 +4807,7 @@ mod test { assert!(matches!(handle.poll_aborted(&waiter), Poll::Ready(Error::Cancel))); assert!(handle.cut_error().is_none()); assert!(!handle.fate().failed()); - assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none()); + assert!(matches!(consumer.poll_read_frame(&waiter), Poll::Ready(Ok(None)))); } /// An abort before the finish is the outcome, on every observer. From 9a856aa8ebcacb0be0e785c0a7af80cca13cc59a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 21:45:50 -0700 Subject: [PATCH 28/30] fix(net): settle a track's outcome once the cache evicts its last open group below the end A prune that evicts the open group holding the end back now re-checks the outcome, as the Rust poll_outcome does once the evicted slot leaves its lookup. Co-Authored-By: Claude Opus 5.5 --- js/net/src/track.test.ts | 23 +++++++++++++++++++++++ js/net/src/track.ts | 3 +++ 2 files changed, 26 insertions(+) diff --git a/js/net/src/track.test.ts b/js/net/src/track.test.ts index 0acfba2133c..a00b70088e4 100644 --- a/js/net/src/track.test.ts +++ b/js/net/src/track.test.ts @@ -2812,6 +2812,29 @@ test("a settled end survives a late group and an abort", async () => { expect(await subscriber.recvGroup()).toBeUndefined(); }); +// As in Rust, an evicted group no longer holds the end open. +test("closed settles once the cache evicts the last open group below the end", async () => { + const clock = manualTime(10_000); + try { + const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); + const open = producer.appendGroup(); + open.writeFrame(textFrame("open")); + const last = producer.appendGroup(); + last.writeFrame(textFrame("last")); + last.finish(); + producer.finish(); + await settle(); + expect(producer.closed.peek()).toBeUndefined(); + + await clock.advance(10_000 + CACHE_WINDOW_MS + CACHE_WINDOW_MS / 8); + await settle(); + expect(open.closed.peek()).toBeInstanceOf(TooFarBehind); + expect(producer.closed.peek()).toBeNull(); + } finally { + clock.installed.close(); + } +}); + test("finish is not terminal: closed settles once the groups below the end finish", async () => { const producer = new TrackProducer("test").accept({ timescale: Timescale.MILLI }); const subscriber = producer.subscribe(); diff --git a/js/net/src/track.ts b/js/net/src/track.ts index 4f0bc2976de..530af000e97 100644 --- a/js/net/src/track.ts +++ b/js/net/src/track.ts @@ -1260,7 +1260,10 @@ export class Producer { this.#evict(entry); } } + const evicted = retained.length < this.#cache.length; this.#cache = retained; + // An evicted group below the end no longer holds it open, as in Rust. + if (evicted) this.#settle(); // Replace the wakeup with one for the next entry due to age out. Writes settle // retention inline, so this only has to cover the case no write follows. Cheap to From 3f1a00c741330421a82e828d0c64dcb33a35cf79 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 22:15:42 -0700 Subject: [PATCH 29/30] fix(flate): finish the decoder bench's oversized group and track #6900's new bench ends them with the pre-split close(). Co-Authored-By: Claude Opus 5.5 --- js/flate/bench/decoder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/flate/bench/decoder.ts b/js/flate/bench/decoder.ts index 8afbeb09474..6a3d19c075f 100644 --- a/js/flate/bench/decoder.ts +++ b/js/flate/bench/decoder.ts @@ -40,7 +40,8 @@ for (const excess of [64 * 1024, 1024 * 1024, 8 * 1024 * 1024]) { const track = new Track.Producer("overflow-bench").accept({}); const group = track.appendGroup(); group.writeFrame({ payload: slice }); - group.close(); + group.finish(); + track.finish(); track.close(); const samples: number[] = []; for (let sample = 0; sample < 4; sample++) { From fb45912c50a2a6195a837c74fd9d003115e06e26 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 26 Oct 2026 22:20:41 -0700 Subject: [PATCH 30/30] fix(net): latch a track's clean outcome once closed() reports it A received copy's tail grace is judged when polled, so a later subscription with a longer max delay could reopen an end closed() already reported, and an abort within it turned it into an error. poll_outcome now latches the clean outcome it returns, as JS does by settling before a subscription change. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/track.rs | 50 ++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 47ffbced88b..2dd189c59d7 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -464,10 +464,11 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, - // Whether the declared end settled before a write that could reopen it or an abort: - // every group below it was produced and finished then, so the clean end stands for - // good. See [`Self::latch_settled`]. - settled: bool, + // Whether the declared end settled before a write that could reopen it or an abort, or + // `closed()` reported it: every group below it was produced and finished then, so the + // clean end stands for good. Atomic so reporting it latches through a read guard. See + // [`Self::latch_settled`]. + settled: AtomicBool, // Active subscriptions, in their own [`kio::Lock`] so a read-only `Consumer` // registers under that lock instead of writing back into the track state. @@ -1080,7 +1081,7 @@ impl TrackState { // Once nothing more is in range: an abort before the end settled cut the track // off. One after it is a clean end (see `is_complete`), as is reaching the end. let end = || match &self.abort { - Some(err) if !self.settled => Err(err.clone()), + Some(err) if !self.settled.load(Ordering::Relaxed) => Err(err.clone()), _ => Ok(None), }; @@ -2276,7 +2277,7 @@ impl TrackState { || self .final_sequence() .is_some_and(|fin| self.reaches(fin) && !self.awaits_tail(fin)); - reached && (self.abort.is_none() || self.settled) + reached && (self.abort.is_none() || self.settled.load(Ordering::Relaxed)) } /// Whether the live edge reached `fin`, or a received copy's tail decides it instead: @@ -2376,8 +2377,8 @@ impl TrackState { /// then stands, and a later abort ends readers cleanly. A late group or a wider /// subscription is still served; it just cannot turn the outcome into an error. fn latch_settled(&mut self) { - if !self.settled && self.abort.is_none() && self.is_settled() { - self.settled = true; + if self.abort.is_none() && self.is_settled() { + self.settled.store(true, Ordering::Relaxed); } } @@ -2527,8 +2528,11 @@ impl TrackState { /// The track's outcome once final: `Ok` once its declared end is reached with every /// cached group below it finished, which a later abort keeps, or the abort error. + /// + /// A clean outcome latches once reported: a received copy's tail grace is judged when + /// polled, and a later subscription asking for a longer one must not reopen it. fn poll_outcome(&self, waiter: &kio::Waiter) -> Poll> { - if self.settled { + if self.settled.load(Ordering::Relaxed) { return Poll::Ready(Ok(())); } if self.is_complete() { @@ -2545,6 +2549,7 @@ impl TrackState { } } } + self.settled.store(true, Ordering::Relaxed); Poll::Ready(Ok(())) } else if let Some(err) = &self.abort { Poll::Ready(Err(err.clone())) @@ -13514,6 +13519,33 @@ mod test { )); } + /// A received end whose tail grace passed is final once `closed()` reports it: a later + /// subscription with a longer grace, and an abort within it, leave the clean outcome. + #[test] + fn a_reported_end_survives_a_longer_grace() { + let waiter = kio::Waiter::noop(); + let mut producer = track_producer("test", None); + producer.received(); + let _short = producer.subscribe(None); + producer.start_at(0).unwrap(); + producer.finish_at(Position::group(3)).unwrap(); + for sequence in [0, 2] { + producer + .create_group(group::Info { sequence }) + .unwrap() + .finish() + .unwrap(); + } + assert!(producer.poll_closed(&waiter).is_pending(), "held at the hole"); + elapse(&producer, ledger::GRACE); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + + let _long = producer.subscribe(Subscription::default().with_max_delay(ledger::GRACE * 10)); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + producer.clone().abort(Error::Cancel).unwrap(); + assert!(matches!(producer.poll_closed(&waiter), Poll::Ready(Ok(())))); + } + /// A settled end is final: a late group below it and a later abort leave the outcome /// `closed()` already saw. #[test]