Skip to main content

moq_net/model/
group.rs

1//! A group is a stream of frames, split into a [Producer] and [Consumer] handle.
2//!
3//! A [Producer] writes an ordered stream of frames.
4//! Frames can be written all at once ([Producer::write_frame]), or in chunks
5//! ([Producer::create_frame]).
6//!
7//! A [Consumer] reads an ordered stream of frames.
8//! The reader can be cloned, in which case each reader receives a copy of each frame. (fanout)
9//!
10//! The stream is closed with [Error] when all writers or readers are dropped.
11use crate::cache;
12use crate::frame::{self, Frame, FrameBuf};
13use crate::{Timescale, stats, track};
14use std::collections::VecDeque;
15use std::mem::MaybeUninit;
16use std::sync::Arc;
17use std::task::{Poll, ready};
18
19use crate::{Error, IntoBytes, Result, Timestamp};
20
21/// Maximum total size of frames cached in a group before old frames are evicted.
22///
23/// Doubles as the per-frame size cap: a single frame can be at most this large (a
24/// larger declared size is refused before allocating), so one maximum-size frame can
25/// fill a group's cache.
26const MAX_GROUP_CACHE: u64 = 32 * 1024 * 1024; // 32 MB
27
28/// A group contains a sequence number because they can arrive out of order.
29///
30/// You can use [track::Producer::append_group] if you just want to +1 the sequence number.
31#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
32pub struct Info {
33	/// Per-track sequence number used to detect ordering and gaps. Higher numbers
34	/// supersede lower ones; consumers may skip late arrivals.
35	pub sequence: u64,
36}
37
38impl Info {
39	/// Create an untimed producer for this group.
40	///
41	/// Test-only: real groups are created via [`track::Producer`], which
42	/// supplies the parent track's [`track::Info`]. This helper exists for in-crate
43	/// tests that don't exercise timestamps.
44	#[cfg(test)]
45	pub(crate) fn produce(self) -> Producer {
46		Producer::new(self, track::Info::default())
47	}
48}
49
50impl From<usize> for Info {
51	fn from(sequence: usize) -> Self {
52		Self {
53			sequence: sequence as u64,
54		}
55	}
56}
57
58impl From<u64> for Info {
59	fn from(sequence: u64) -> Self {
60		Self { sequence }
61	}
62}
63
64impl From<u32> for Info {
65	fn from(sequence: u32) -> Self {
66		Self {
67			sequence: sequence as u64,
68		}
69	}
70}
71
72impl From<u16> for Info {
73	fn from(sequence: u16) -> Self {
74		Self {
75			sequence: sequence as u64,
76		}
77	}
78}
79
80/// The in-flight (tail) frame being written. At most one exists at a time, since a
81/// group is a single ordered stream.
82pub(crate) struct Partial {
83	timestamp: Timestamp,
84	buf: FrameBuf,
85}
86
87/// Shared group state. `pub(crate)` so [`frame`] handles can observe the abort flag
88/// while streaming a partial frame.
89#[derive(Default)]
90pub(crate) struct GroupState {
91	// Completed frames, each a contiguous payload. Evicted frames are popped from the
92	// front; `offset` tracks how many.
93	pub(crate) frames: VecDeque<Frame>,
94
95	// The single in-flight frame, if one is open.
96	pub(crate) partial: Option<Partial>,
97
98	// The number of frames evicted from the front of the group.
99	pub(crate) offset: usize,
100
101	// The total size (in bytes) of all cached frames plus any in-flight frame.
102	pub(crate) cache: u64,
103
104	// This group's registration in the track's cache pool; mirrors `cache` so the
105	// pool can evict the least-recently-read groups under memory pressure.
106	charge: cache::Charge,
107
108	// Whether the group has been finalized (no more frames).
109	pub(crate) fin: bool,
110
111	// The error that caused the group to be aborted, if any.
112	pub(crate) abort: Option<Error>,
113}
114
115impl GroupState {
116	/// Resolve the source for the frame at `index`: a completed frame (whole) or the
117	/// in-flight tail (streamed). Used by [`Consumer::poll_next_frame`].
118	fn poll_frame_source(&self, index: usize) -> Poll<Result<Option<(frame::Info, frame::Source)>>> {
119		if index < self.offset {
120			return Poll::Ready(Err(Error::Lagged));
121		}
122		let local = index - self.offset;
123		if let Some(f) = self.frames.get(local) {
124			self.charge.touch();
125			let info = frame::Info {
126				size: f.payload.len() as u64,
127				timestamp: f.timestamp,
128			};
129			return Poll::Ready(Ok(Some((info, frame::Source::Complete(f.payload.clone())))));
130		}
131		if local == self.frames.len()
132			&& let Some(p) = &self.partial
133		{
134			self.charge.touch();
135			let info = frame::Info {
136				size: p.buf.capacity() as u64,
137				timestamp: p.timestamp,
138			};
139			return Poll::Ready(Ok(Some((info, frame::Source::Partial(p.buf.clone())))));
140		}
141		// `abort` is checked before `fin`: an evicted group is both finished and
142		// aborted with its frames cleared, and the reader must see the abort rather
143		// than a clean end-of-group at the wrong index.
144		if let Some(err) = &self.abort {
145			return Poll::Ready(Err(err.clone()));
146		}
147		if self.fin {
148			return Poll::Ready(Ok(None));
149		}
150		Poll::Pending
151	}
152
153	fn poll_finished(&self) -> Poll<Result<u64>> {
154		if let Some(err) = &self.abort {
155			// Checked before `fin`: an evicted group is both finished and aborted,
156			// and its cleared frames would report a bogus count.
157			Poll::Ready(Err(err.clone()))
158		} else if self.fin {
159			Poll::Ready(Ok((self.offset + self.frames.len()) as u64))
160		} else {
161			Poll::Pending
162		}
163	}
164
165	/// Evict completed frames from the front until within the byte budget.
166	fn evict(&mut self) {
167		while self.cache > MAX_GROUP_CACHE {
168			let Some(frame) = self.frames.pop_front() else {
169				break;
170			};
171			let size = frame.payload.len() as u64;
172			self.cache -= size;
173			self.charge.sub(size);
174			self.offset += 1;
175		}
176	}
177
178	/// Drop the cached frames (and any in-flight tail) and release their pool charge.
179	fn release(&mut self) {
180		self.frames.clear();
181		self.partial = None;
182		self.cache = 0;
183		self.charge.clear();
184	}
185}
186
187fn modify(state: &kio::Producer<GroupState>) -> Result<kio::Mut<'_, GroupState>> {
188	state.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped))
189}
190
191/// The pool's eviction hook: abort the group with [`Error::Evicted`], freeing its
192/// frames immediately. A no-op once the group is already aborted or fully dropped.
193fn evict(weak: &kio::ProducerWeak<GroupState>) {
194	// Upgrade to write; a closed (finished or fully dropped) group has nothing to free.
195	let Some(producer) = weak.produce() else { return };
196	let Ok(mut state) = producer.write() else { return };
197	if state.abort.is_some() {
198		return;
199	}
200	state.abort = Some(Error::Evicted);
201	state.release();
202	state.close();
203}
204
205/// Writes frames to a group in order.
206///
207/// Each group is delivered independently over a QUIC stream.
208/// Use [Self::write_frame] for simple single-buffer frames,
209/// or [Self::create_frame] for multi-chunk streaming writes.
210pub struct Producer {
211	// Mutable stream state.
212	state: kio::Producer<GroupState>,
213
214	// The group header containing the sequence number. A small `Copy` value,
215	// inherited by each frame (see [`Self::create_frame`]).
216	info: Info,
217
218	// The parent track's info, inherited rather than passed piecemeal. Its
219	// `timescale` is used by [`Self::create_frame`] to normalize every frame's
220	// timestamp into the track scale before it enters the stream. Threaded down by
221	// value from [`track::Producer::create_group`] / `append_group`.
222	track: track::Info,
223
224	// Ingress payload meter, set by a tagged [`track::Producer`] via
225	// [`Self::with_meter`]. Empty (no-op) for an untagged group.
226	stats: stats::Meter,
227}
228
229impl std::ops::Deref for Producer {
230	type Target = Info;
231
232	fn deref(&self) -> &Self::Target {
233		&self.info
234	}
235}
236
237impl Producer {
238	/// Create a group producer bound to its parent track's [`track::Info`].
239	///
240	/// Crate-private: groups are only constructed via [`track::Producer`], which
241	/// threads its [`track::Info`] down so properties like the timescale are inherited
242	/// rather than passed in. Every frame added to this group is normalized to the
243	/// track's timescale by [`Self::create_frame`].
244	///
245	/// Registers the group in the shared cache pool reached through the track's
246	/// broadcast (`track.broadcast.origin.pool`), so its cached bytes count against
247	/// the budget and it can be evicted under memory pressure.
248	pub(crate) fn new(info: Info, track: track::Info) -> Self {
249		let state = kio::Producer::<GroupState>::default();
250		let weak = state.weak();
251		let charge = track.broadcast.origin.pool.register(Box::new(move || evict(&weak)));
252		state.write().ok().expect("a new group is open").charge = charge;
253		Self {
254			info,
255			state,
256			track,
257			stats: stats::Meter::default(),
258		}
259	}
260
261	/// Attach an ingress payload meter, counting this as one delivered group.
262	/// Called by a tagged [`track::Producer`] when it creates the group.
263	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
264		meter.group();
265		self.stats = meter;
266		self
267	}
268
269	/// The group header.
270	pub(crate) fn info(&self) -> Info {
271		self.info
272	}
273
274	/// The parent track's timescale.
275	pub fn timescale(&self) -> Timescale {
276		self.track.timescale
277	}
278
279	/// A helper method to write a frame from a single byte buffer.
280	///
281	/// If you want to write multiple chunks, use [Self::create_frame] to get a frame producer.
282	/// But an upfront size is required.
283	///
284	/// `timestamp` is converted into the parent track's timescale. For data without
285	/// a presentation time, pass [`Timestamp::now`] explicitly.
286	pub fn write_frame<B: IntoBytes>(&mut self, timestamp: Timestamp, data: B) -> Result<()> {
287		let timestamp = timestamp
288			.convert(self.track.timescale)
289			.map_err(|_| Error::TimestampMismatch)?;
290		let payload = data.into_bytes();
291		if payload.len() as u64 > MAX_GROUP_CACHE {
292			return Err(Error::FrameTooLarge);
293		}
294
295		let mut state = modify(&self.state)?;
296		if state.fin {
297			return Err(Error::Closed);
298		}
299		debug_assert!(state.partial.is_none(), "a frame is already open");
300		let size = payload.len() as u64;
301		state.cache += size;
302		state.charge.add(size);
303		state.frames.push_back(Frame { timestamp, payload });
304		state.evict();
305
306		// The pool evicts other groups' state, so trigger it only after releasing our
307		// lock. Reached via the parent chain; a no-op when the pool is unbounded.
308		drop(state);
309		self.track.broadcast.origin.pool.evict();
310
311		// Ingress payload: one whole frame written.
312		self.stats.frames(1);
313		self.stats.bytes(size);
314		Ok(())
315	}
316
317	/// Create a frame with an upfront size and presentation timestamp, streamed in
318	/// chunks. Borrows the group exclusively until the returned [`frame::Producer`]
319	/// is finished or dropped, so only one frame is open at a time.
320	///
321	/// The `timestamp` is converted into the parent track's timescale, so the scale you
322	/// build it with doesn't have to match the track. Returns [`Error::FrameTooLarge`]
323	/// if the declared size exceeds the group's byte budget (refused before allocating)
324	/// or [`Error::TimestampMismatch`] if the timestamp can't be converted (overflow).
325	pub fn create_frame(&mut self, frame: frame::Info) -> Result<frame::Producer<'_>> {
326		let timestamp = frame
327			.timestamp
328			.convert(self.track.timescale)
329			.map_err(|_| Error::TimestampMismatch)?;
330		if frame.size > MAX_GROUP_CACHE {
331			return Err(Error::FrameTooLarge);
332		}
333		let buf = FrameBuf::new(frame.size as usize);
334
335		let mut state = modify(&self.state)?;
336		if state.fin {
337			return Err(Error::Closed);
338		}
339		debug_assert!(state.partial.is_none(), "a frame is already open");
340		state.cache += frame.size;
341		state.charge.add(frame.size);
342		state.partial = Some(Partial {
343			timestamp,
344			buf: buf.clone(),
345		});
346		state.evict();
347
348		// The pool evicts other groups' state, so trigger it only after releasing our
349		// lock. Reached via the parent chain; a no-op when the pool is unbounded.
350		drop(state);
351		self.track.broadcast.origin.pool.evict();
352
353		// Ingress payload: one frame opened; its bytes are counted per chunk as the
354		// frame::Producer writes them.
355		self.stats.frames(1);
356		let meter = self.stats.clone();
357
358		let info = frame::Info {
359			size: frame.size,
360			timestamp,
361		};
362		Ok(frame::Producer::new(self, buf, info).with_meter(meter))
363	}
364
365	/// Wake consumers parked on the group channel (called after a partial write).
366	pub(crate) fn frame_notify(&self) {
367		// Taking the write lock and dropping it triggers kio's notify.
368		let _ = self.state.write();
369	}
370
371	/// Commit the in-flight frame as a completed frame (called by [`frame::Producer::finish`]).
372	pub(crate) fn frame_commit(&mut self, frame: Frame) -> Result<()> {
373		let mut state = modify(&self.state)?;
374		// Bytes were already counted against the cache (and the pool charge) when the
375		// frame was created; committing just moves the tail into the completed set.
376		state.partial = None;
377		state.frames.push_back(frame);
378		Ok(())
379	}
380
381	/// Fail the group because an in-flight frame couldn't complete (called by
382	/// [`frame::Producer::abort`] / its drop).
383	pub(crate) fn frame_abort(&mut self, err: Error) {
384		let _ = self.clone().abort(err);
385	}
386
387	/// Return the number of frames written so far (completed plus any in-flight).
388	pub fn frame_count(&self) -> usize {
389		let state = self.state.read();
390		state.offset + state.frames.len() + state.partial.is_some() as usize
391	}
392
393	/// Mark the group as complete; no more frames will be written.
394	///
395	/// Borrows rather than consumes, so a later failure can still be reported through
396	/// [`abort`](Self::abort). The handle also keeps the cached frames readable.
397	pub fn finish(&mut self) -> Result<()> {
398		let mut state = modify(&self.state)?;
399		state.fin = true;
400		Ok(())
401	}
402
403	/// Abort the group with the given error.
404	///
405	/// Consumes the handle. Drops the cached frames so a stale [`Consumer`] can't pin
406	/// their buffers in memory forever; consumers that haven't drained yet surface the
407	/// abort error instead of the leftover cache.
408	pub fn abort(self, err: Error) -> Result<()> {
409		let mut guard = modify(&self.state)?;
410		guard.abort = Some(err);
411		guard.release();
412		guard.close();
413		Ok(())
414	}
415
416	/// Whether the group has been aborted (including pool eviction). The track's
417	/// read paths treat an aborted cached group as absent.
418	pub(crate) fn is_aborted(&self) -> bool {
419		self.state.read().abort.is_some()
420	}
421
422	/// This group's cache pool registration, used by the track to pin the latest
423	/// group. `None` when the pool is detached (the unbounded default).
424	pub(crate) fn cache_entry(&self) -> Option<Arc<cache::Entry>> {
425		self.state.read().charge.entry()
426	}
427
428	/// Create a new consumer for the group.
429	pub fn consume(&self) -> Consumer {
430		Consumer {
431			info: self.info,
432			state: self.state.consume(),
433			track: self.track.clone(),
434			index: 0,
435			prefetch: Prefetch::default(),
436			// Untagged: a tagged track attaches the egress meter via `with_meter`
437			// when it hands the consumer to a subscriber/fetch.
438			stats: stats::Meter::default(),
439		}
440	}
441
442	/// Block until the group is closed or aborted.
443	pub async fn closed(&self) -> Error {
444		kio::wait(|waiter| self.poll_closed(waiter)).await
445	}
446
447	/// Poll until the group is closed or aborted; ready with the cause.
448	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<Error> {
449		self.state.poll_closed(waiter).map(|()| self.abort_reason())
450	}
451
452	/// Block until there are no active consumers.
453	pub async fn unused(&self) -> Result<()> {
454		self.state.unused().await.map_err(|_| self.abort_reason())
455	}
456
457	/// The recorded abort reason, or [`Error::Dropped`] if the group closed without one.
458	fn abort_reason(&self) -> Error {
459		self.state.read().abort.clone().unwrap_or(Error::Dropped)
460	}
461}
462
463impl Clone for Producer {
464	fn clone(&self) -> Self {
465		Self {
466			info: self.info,
467			state: self.state.clone(),
468			track: self.track.clone(),
469			stats: self.stats.clone(),
470		}
471	}
472}
473
474impl Drop for Producer {
475	fn drop(&mut self) {
476		// See track::Producer::drop: the last producer dropping without a clean finish
477		// releases the cached frames so a stale consumer can't pin their buffers forever.
478		// A finished group keeps its cache so consumers can drain.
479		if !self.state.is_last() {
480			return;
481		}
482		if let Ok(mut state) = modify(&self.state)
483			&& !state.fin
484		{
485			// Dropped without finish() or abort(), so consumers will see
486			// Error::Dropped mid-group. Deliberate ends go through finish()/abort().
487			tracing::warn!(
488				sequence = self.info.sequence,
489				"group::Producer dropped without finish() or abort()"
490			);
491			state.release();
492		}
493	}
494}
495
496/// A small inline batch of completed frames, drained from the shared group state
497/// under one lock and then handed out without re-locking.
498///
499/// Each [`Consumer::read_frame`] otherwise takes the group mutex and allocates a
500/// waker just to clone one `Bytes`; draining a batch amortizes both across `CAP`
501/// frames. Storage is inline and uninitialized (no heap), so a consumer that never
502/// reads whole frames, or drains through a higher-level buffer, pays nothing.
503struct Prefetch {
504	// Initialized, not-yet-taken frames are `frames[pos..len]`; the rest are uninitialized.
505	frames: [MaybeUninit<Frame>; Self::CAP],
506	pos: usize,
507	len: usize,
508}
509
510impl Prefetch {
511	const CAP: usize = 8;
512
513	/// Take the next buffered frame, or `None` if the batch is drained.
514	fn pop(&mut self) -> Option<Frame> {
515		if self.pos == self.len {
516			return None;
517		}
518		// SAFETY: `pos < len`, so this slot was written by `fill` and not yet taken.
519		let frame = unsafe { self.frames[self.pos].assume_init_read() };
520		self.pos += 1;
521		Some(frame)
522	}
523
524	/// Refill with up to `CAP` frames. Must be drained first (`pop` returned `None`).
525	fn fill(&mut self, frames: impl Iterator<Item = Frame>) {
526		debug_assert_eq!(self.pos, self.len, "fill on a non-empty batch would leak frames");
527		self.pos = 0;
528		self.len = 0;
529		for frame in frames.take(Self::CAP) {
530			self.frames[self.len].write(frame);
531			self.len += 1;
532		}
533	}
534
535	/// `(frame count, total payload bytes)` of the buffered, not-yet-taken frames.
536	/// Read once per fill to bump the egress payload counters for the whole batch.
537	fn buffered(&self) -> (u64, u64) {
538		let mut bytes = 0u64;
539		for slot in &self.frames[self.pos..self.len] {
540			// SAFETY: slots in `pos..len` are initialized (written by `fill`, not yet popped).
541			bytes += unsafe { slot.assume_init_ref() }.payload.len() as u64;
542		}
543		((self.len - self.pos) as u64, bytes)
544	}
545}
546
547impl Default for Prefetch {
548	fn default() -> Self {
549		Self {
550			frames: [const { MaybeUninit::uninit() }; Self::CAP],
551			pos: 0,
552			len: 0,
553		}
554	}
555}
556
557impl Drop for Prefetch {
558	fn drop(&mut self) {
559		for slot in &mut self.frames[self.pos..self.len] {
560			// SAFETY: slots in `pos..len` are initialized and were never taken.
561			unsafe { slot.assume_init_drop() };
562		}
563	}
564}
565
566/// Consume a group, frame-by-frame.
567pub struct Consumer {
568	// Shared state with the producer.
569	state: kio::Consumer<GroupState>,
570
571	// Immutable stream state.
572	info: Info,
573
574	// The parent track's info, inherited from the producer. Its `timescale` lets the
575	// wire publisher emit per-frame timestamps at the right scale for a fetched group.
576	track: track::Info,
577
578	// The number of frames we've read.
579	// NOTE: Cloned readers inherit this offset, but then run in parallel.
580	index: usize,
581
582	// A batch of completed frames drained ahead under one lock (whole-frame reads only).
583	prefetch: Prefetch,
584
585	// Egress payload meter, set by a tagged track via [`Self::with_meter`]. Empty
586	// (no-op) for an untagged group.
587	stats: stats::Meter,
588}
589
590impl Clone for Consumer {
591	fn clone(&self) -> Self {
592		// A clone shares the channel and inherits `index`, but starts with an empty
593		// prefetch: it re-reads its batch from the shared state, in parallel.
594		Self {
595			state: self.state.clone(),
596			info: self.info,
597			track: self.track.clone(),
598			index: self.index,
599			prefetch: Prefetch::default(),
600			// Inherit the meter without re-counting the group: the original already
601			// counted it when the track handed it out.
602			stats: self.stats.clone(),
603		}
604	}
605}
606
607impl std::ops::Deref for Consumer {
608	type Target = Info;
609
610	fn deref(&self) -> &Self::Target {
611		&self.info
612	}
613}
614
615impl Consumer {
616	/// Attach an egress payload meter, counting this as one delivered group.
617	/// Called by a tagged track when it hands the consumer to a subscriber or fetch.
618	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
619		meter.group();
620		self.stats = meter;
621		self
622	}
623
624	/// The parent track's timescale.
625	pub fn timescale(&self) -> Timescale {
626		self.track.timescale
627	}
628
629	// A helper to automatically apply Dropped if the state is closed without an error.
630	fn poll<F, R>(&self, waiter: &kio::Waiter, f: F) -> Poll<Result<R>>
631	where
632		F: Fn(&kio::Ref<'_, GroupState>) -> Poll<Result<R>>,
633	{
634		Poll::Ready(match ready!(self.state.poll(waiter, f)) {
635			Ok(res) => res,
636			// We try to clone abort just in case the function forgot to check for terminal state.
637			Err(state) => Err(state.abort.clone().unwrap_or(Error::Dropped)),
638		})
639	}
640
641	/// Return a consumer for the next frame for chunked reading.
642	pub async fn next_frame(&mut self) -> Result<Option<frame::Consumer>> {
643		kio::wait(|waiter| self.poll_next_frame(waiter)).await
644	}
645
646	/// Poll for the next frame, without blocking.
647	///
648	/// Returns None if the group is finished and the index is out of range.
649	pub fn poll_next_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Consumer>>> {
650		// Hand out any frames a prior read_frame prefetched before touching the tail.
651		// Their bytes were already counted at the batch fill, so the frame::Consumer
652		// carries no meter.
653		if let Some(frame) = self.prefetch.pop() {
654			self.index += 1;
655			let info = frame::Info {
656				size: frame.payload.len() as u64,
657				timestamp: frame.timestamp,
658			};
659			let source = frame::Source::Complete(frame.payload);
660			return Poll::Ready(Ok(Some(frame::Consumer::new(self.state.clone(), info, source))));
661		}
662
663		let index = self.index;
664		let Some((info, source)) = ready!(self.poll(waiter, |state| state.poll_frame_source(index))?) else {
665			return Poll::Ready(Ok(None));
666		};
667
668		self.index += 1;
669		// A direct read (not prefetched): count the frame here; the frame::Consumer
670		// counts its bytes per chunk as they're read out.
671		self.stats.frames(1);
672		Poll::Ready(Ok(Some(
673			frame::Consumer::new(self.state.clone(), info, source).with_meter(self.stats.clone()),
674		)))
675	}
676
677	/// Read the next frame (timestamp and payload) all at once, without blocking.
678	pub fn poll_read_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Frame>>> {
679		// Fast path: serve from the prefetched batch without locking or allocating a waker.
680		if let Some(frame) = self.prefetch.pop() {
681			self.index += 1;
682			return Poll::Ready(Ok(Some(frame)));
683		}
684
685		// The batch is drained: refill it under a single lock, registering the waiter if
686		// nothing is ready. Borrow the two fields disjointly so the closure can fill.
687		let index = self.index;
688		let prefetch = &mut self.prefetch;
689		let res = self.state.poll(waiter, |state| {
690			if index < state.offset {
691				return Poll::Ready(Err(Error::Lagged));
692			}
693			// `local` can run past the buffered count when frames were cleared or evicted out
694			// from under us (abort, unfinished drop, an eviction gap); clamp so `range` never
695			// panics on an out-of-bounds start. `fill` always resets the batch, so an empty
696			// range leaves `len == 0` and the terminal checks below resolve abort/fin/pending.
697			let local = (index - state.offset).min(state.frames.len());
698			prefetch.fill(state.frames.range(local..).cloned());
699			if prefetch.len > 0 {
700				// Mark the group recently read so the cache pool keeps it over staler
701				// groups. Touching once per batch fill is enough; the drained pops that
702				// follow serve from the prefetch without re-locking.
703				state.charge.touch();
704				return Poll::Ready(Ok(()));
705			}
706			// Nothing completed at `index`: an in-flight tail waits, otherwise resolve
707			// the terminal state (whole-frame reads never stream the partial).
708			if let Some(err) = &state.abort {
709				return Poll::Ready(Err(err.clone()));
710			}
711			if state.fin {
712				return Poll::Ready(Ok(()));
713			}
714			Poll::Pending
715		});
716
717		match ready!(res) {
718			Ok(Ok(())) => {}
719			Ok(Err(err)) => return Poll::Ready(Err(err)),
720			Err(state) => return Poll::Ready(Err(state.abort.clone().unwrap_or(Error::Dropped))),
721		}
722
723		// A fresh batch was just filled (empty only on a clean end). Count the whole
724		// batch once here, under no lock, so the drained pops that follow stay free.
725		let (frames, bytes) = self.prefetch.buffered();
726		self.stats.frames(frames);
727		self.stats.bytes(bytes);
728
729		Poll::Ready(Ok(self.prefetch.pop().inspect(|_| {
730			self.index += 1;
731		})))
732	}
733
734	/// Read the next frame (timestamp and payload) all at once.
735	pub async fn read_frame(&mut self) -> Result<Option<frame::Frame>> {
736		// Serve from the prefetched batch without building a future or allocating a waker.
737		if let Some(frame) = self.prefetch.pop() {
738			self.index += 1;
739			return Ok(Some(frame));
740		}
741		kio::wait(|waiter| self.poll_read_frame(waiter)).await
742	}
743
744	/// Poll for the final number of frames in the group.
745	pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll<Result<u64>> {
746		self.poll(waiter, |state| state.poll_finished())
747	}
748
749	/// Block until the group is finished, returning the number of frames in the group.
750	pub async fn finished(&mut self) -> Result<u64> {
751		kio::wait(|waiter| self.poll_finished(waiter)).await
752	}
753}
754
755/// Options for a one-shot [`track::Consumer::fetch_group`] of a past group.
756#[derive(Clone, Debug, Default)]
757#[non_exhaustive]
758pub struct Fetch {
759	/// Delivery priority for the fetched group's stream. Defaults to 0.
760	pub priority: u8,
761}
762
763impl Fetch {
764	/// Set the delivery priority, returning `self` for chaining.
765	pub fn with_priority(mut self, priority: u8) -> Self {
766		self.priority = priority;
767		self
768	}
769}
770
771#[cfg(test)]
772mod test {
773	use super::*;
774	use bytes::Bytes;
775	use futures::FutureExt;
776
777	#[test]
778	fn basic_frame_reading() {
779		let mut producer = Info { sequence: 0 }.produce();
780		producer
781			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame0"))
782			.unwrap();
783		producer
784			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame1"))
785			.unwrap();
786		producer.finish().unwrap();
787
788		let mut consumer = producer.consume();
789		let f0 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
790		assert_eq!(f0.size, 6);
791		let f1 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
792		assert_eq!(f1.size, 6);
793		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
794		assert!(end.is_none());
795	}
796
797	#[test]
798	fn read_frame_all_at_once() {
799		let mut producer = Info { sequence: 0 }.produce();
800		producer
801			.write_frame(Timestamp::ZERO, Bytes::from_static(b"hello"))
802			.unwrap();
803		producer.finish().unwrap();
804
805		let mut consumer = producer.consume();
806		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
807		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
808	}
809
810	#[test]
811	fn read_frame_preserves_timestamp() {
812		let mut producer = Info { sequence: 0 }.produce();
813		let timestamp = Timestamp::from_micros(20_000).unwrap();
814		producer.write_frame(timestamp, Bytes::from_static(b"hello")).unwrap();
815		producer.finish().unwrap();
816
817		let mut consumer = producer.consume();
818		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
819		assert_eq!(frame.timestamp.as_micros(), 20_000);
820		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
821	}
822
823	#[test]
824	fn chunked_frame_reads_whole() {
825		let mut producer = Info { sequence: 0 }.produce();
826		{
827			let mut frame = producer
828				.create_frame(frame::Info {
829					size: 10,
830					timestamp: Timestamp::ZERO,
831				})
832				.unwrap();
833			frame.write(Bytes::from_static(b"hello")).unwrap();
834			frame.write(Bytes::from_static(b"world")).unwrap();
835			frame.finish().unwrap();
836		}
837		producer.finish().unwrap();
838
839		// Frame data is held in a single per-frame buffer; a whole-frame read returns
840		// the full contents in one slice.
841		let mut consumer = producer.consume();
842		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
843		assert_eq!(frame.payload, Bytes::from_static(b"helloworld"));
844	}
845
846	#[test]
847	fn chunked_frame_streams_partial() {
848		let mut producer = Info { sequence: 0 }.produce();
849		let mut consumer = producer.consume();
850
851		let mut frame = producer
852			.create_frame(frame::Info {
853				size: 6,
854				timestamp: Timestamp::ZERO,
855			})
856			.unwrap();
857		frame.write(Bytes::from_static(b"foo")).unwrap();
858
859		// A consumer can stream the in-flight tail before it's finished.
860		let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
861		let c1 = f.read_chunk().now_or_never().unwrap().unwrap();
862		assert_eq!(c1, Some(Bytes::from_static(b"foo")));
863		assert!(f.read_chunk().now_or_never().is_none());
864
865		frame.write(Bytes::from_static(b"bar")).unwrap();
866		frame.finish().unwrap();
867
868		let c2 = f.read_chunk().now_or_never().unwrap().unwrap();
869		assert_eq!(c2, Some(Bytes::from_static(b"bar")));
870		let c3 = f.read_chunk().now_or_never().unwrap().unwrap();
871		assert_eq!(c3, None);
872	}
873
874	#[test]
875	fn group_finish_returns_none() {
876		let mut producer = Info { sequence: 0 }.produce();
877		producer.finish().unwrap();
878
879		let mut consumer = producer.consume();
880		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
881		assert!(end.is_none());
882	}
883
884	#[test]
885	fn abort_propagates() {
886		let producer = Info { sequence: 0 }.produce();
887		let mut consumer = producer.consume();
888		producer.abort(crate::Error::Cancel).unwrap();
889
890		let result = consumer.next_frame().now_or_never().unwrap();
891		assert!(matches!(result, Err(crate::Error::Cancel)));
892	}
893
894	#[test]
895	fn abort_clears_cached_frames() {
896		let mut producer = Info { sequence: 0 }.produce();
897		producer
898			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
899			.unwrap();
900
901		// A stale consumer that never reads must not pin the cached frames.
902		let _consumer = producer.consume();
903		assert_eq!(producer.state.read().frames.len(), 1);
904
905		producer.clone().abort(crate::Error::Cancel).unwrap();
906
907		let state = producer.state.read();
908		assert!(state.frames.is_empty(), "cached frames should be dropped on abort");
909		assert_eq!(state.cache, 0);
910	}
911
912	#[test]
913	fn drop_unfinished_clears_cached_frames() {
914		let producer = Info { sequence: 0 }.produce();
915		let mut writer = producer.clone();
916		writer
917			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
918			.unwrap();
919
920		// A stale consumer keeps the channel (and thus the cache) alive.
921		let mut consumer = producer.consume();
922		assert_eq!(producer.state.read().frames.len(), 1);
923
924		// Drop every producer without finishing: the cache is released.
925		drop(writer);
926		drop(producer);
927
928		let result = consumer.next_frame().now_or_never().unwrap();
929		assert!(matches!(result, Err(crate::Error::Dropped)));
930	}
931
932	#[test]
933	fn drop_finished_keeps_cached_frames() {
934		let mut producer = Info { sequence: 0 }.produce();
935		producer
936			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
937			.unwrap();
938		producer.finish().unwrap();
939
940		let mut consumer = producer.consume();
941		drop(producer);
942
943		// A cleanly finished group keeps its cache so the consumer can still drain.
944		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
945		assert_eq!(frame.payload, Bytes::from_static(b"data"));
946	}
947
948	#[tokio::test]
949	async fn pending_then_ready() {
950		let mut producer = Info { sequence: 0 }.produce();
951		let mut consumer = producer.consume();
952
953		// Consumer blocks because no frames yet.
954		assert!(consumer.next_frame().now_or_never().is_none());
955
956		producer
957			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
958			.unwrap();
959		producer.finish().unwrap();
960
961		let frame = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
962		assert_eq!(frame.size, 4);
963	}
964
965	#[test]
966	fn eviction_drops_old_frames() {
967		let mut producer = Info { sequence: 0 }.produce();
968
969		// Write frames that total more than MAX_GROUP_CACHE.
970		let big = Bytes::from(vec![0u8; MAX_GROUP_CACHE as usize]);
971		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
972		producer.write_frame(Timestamp::ZERO, big).unwrap();
973
974		// The first frame should have been evicted (tombstoned via offset).
975		let state = producer.state.read();
976		assert_eq!(state.offset, 1);
977		assert_eq!(state.frames.len(), 1);
978		assert_eq!(state.frames[0].payload.len(), MAX_GROUP_CACHE as usize);
979	}
980
981	#[test]
982	fn next_frame_returns_cache_full_on_tombstone() {
983		let mut producer = Info { sequence: 0 }.produce();
984
985		let big = Bytes::from(vec![0u8; MAX_GROUP_CACHE as usize]);
986		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
987		producer.write_frame(Timestamp::ZERO, big).unwrap();
988
989		let mut consumer = producer.consume();
990		// First frame was evicted, next_frame should return Lagged.
991		let result = consumer.next_frame().now_or_never().unwrap();
992		assert!(matches!(result, Err(crate::Error::Lagged)));
993	}
994
995	#[test]
996	fn no_eviction_under_budget() {
997		let mut producer = Info { sequence: 0 }.produce();
998		// Many small frames stay cached: there is no frame-count cap, only a byte budget.
999		for _ in 0..100_000 {
1000			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1001		}
1002		producer.finish().unwrap();
1003
1004		let state = producer.state.read();
1005		assert_eq!(state.offset, 0);
1006		assert_eq!(state.frames.len(), 100_000);
1007	}
1008
1009	#[test]
1010	fn clone_consumer_independent() {
1011		let mut producer = Info { sequence: 0 }.produce();
1012		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1013
1014		let mut c1 = producer.consume();
1015		// Read one frame from c1
1016		let _ = c1.next_frame().now_or_never().unwrap().unwrap().unwrap();
1017
1018		// Clone c1, inheriting its index (past first frame).
1019		let mut c2 = c1.clone();
1020
1021		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1022		producer.finish().unwrap();
1023
1024		// c2 should get the second frame (inherited index)
1025		let f = c2.next_frame().now_or_never().unwrap().unwrap().unwrap();
1026		assert_eq!(f.size, 1); // "b"
1027
1028		let end = c2.next_frame().now_or_never().unwrap().unwrap();
1029		assert!(end.is_none());
1030	}
1031
1032	/// Reading more than one prefetch batch drains every frame in order across the
1033	/// batch boundary (the refill starts exactly where the previous batch ended).
1034	#[test]
1035	fn read_frame_crosses_prefetch_batches() {
1036		let n = Prefetch::CAP * 3 + 5;
1037		let mut producer = Info { sequence: 0 }.produce();
1038		for i in 0..n {
1039			producer
1040				.write_frame(Timestamp::ZERO, Bytes::from(vec![i as u8; 4]))
1041				.unwrap();
1042		}
1043		producer.finish().unwrap();
1044
1045		let mut consumer = producer.consume();
1046		for i in 0..n {
1047			let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1048			assert_eq!(frame.payload, Bytes::from(vec![i as u8; 4]));
1049		}
1050		assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none());
1051	}
1052
1053	/// `next_frame` drains frames a prior `read_frame` prefetched, preserving order.
1054	#[test]
1055	fn interleave_read_and_next_frame() {
1056		let mut producer = Info { sequence: 0 }.produce();
1057		for i in 0..5u8 {
1058			producer.write_frame(Timestamp::ZERO, Bytes::from(vec![i; 1])).unwrap();
1059		}
1060		producer.finish().unwrap();
1061
1062		let mut consumer = producer.consume();
1063		// The first whole-frame read prefetches all five frames into the batch.
1064		let f0 = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1065		assert_eq!(f0.payload, Bytes::from(vec![0u8; 1]));
1066
1067		// next_frame must continue from the batch, not skip ahead or repeat.
1068		for i in 1..5u8 {
1069			let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1070			let data = f.read_all().now_or_never().unwrap().unwrap();
1071			assert_eq!(data, Bytes::from(vec![i; 1]));
1072		}
1073		assert!(consumer.next_frame().now_or_never().unwrap().unwrap().is_none());
1074	}
1075
1076	/// A `read_frame` whose index sits past the buffered frames (cleared by an abort, or an
1077	/// eviction gap) must surface the error, not panic on an out-of-range `range(local..)`.
1078	#[test]
1079	fn read_frame_past_cleared_frames_does_not_panic() {
1080		let mut producer = Info { sequence: 0 }.produce();
1081		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1082		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1083
1084		let mut consumer = producer.consume();
1085		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1086		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1087
1088		// Abort clears the cached frames but leaves the consumer's index (2) past them, so the
1089		// refill's `local` (2) exceeds `frames.len()` (0).
1090		producer.abort(Error::Cancel).unwrap();
1091
1092		let result = consumer.read_frame().now_or_never().unwrap();
1093		assert!(matches!(result, Err(Error::Cancel)), "expected Cancel, got {result:?}");
1094	}
1095
1096	/// Dropping a consumer mid-batch must drop the buffered-but-untaken frames
1097	/// (exercises the `MaybeUninit` Drop path; run under miri to catch leaks/UB).
1098	#[test]
1099	fn drop_with_partial_batch() {
1100		let mut producer = Info { sequence: 0 }.produce();
1101		for _ in 0..Prefetch::CAP {
1102			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1103		}
1104		producer.finish().unwrap();
1105
1106		let mut consumer = producer.consume();
1107		// Take one frame so the batch is filled but only partially drained.
1108		let _ = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1109		drop(consumer);
1110	}
1111
1112	/// A frame whose timestamp is at a different scale is converted to the group's
1113	/// scale by `create_frame`.
1114	#[test]
1115	fn create_frame_converts_mismatched_scale() {
1116		use crate::{Timescale, Timestamp};
1117
1118		let mut producer = Producer::new(
1119			Info { sequence: 0 },
1120			track::Info::default().with_timescale(Timescale::MICRO),
1121		);
1122		let frame = frame::Info {
1123			size: 3,
1124			timestamp: Timestamp::from_millis(1).unwrap(), // 1ms -> 1000µs
1125		};
1126		let writer = producer.create_frame(frame).unwrap();
1127		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1128		assert_eq!(writer.timestamp.value(), 1000);
1129	}
1130
1131	/// An explicit current timestamp is converted to the group's scale.
1132	#[tokio::test]
1133	async fn create_frame_converts_current_timestamp() {
1134		use crate::Timescale;
1135
1136		let mut producer = Producer::new(
1137			Info { sequence: 0 },
1138			track::Info::default().with_timescale(Timescale::MICRO),
1139		);
1140		let writer = producer
1141			.create_frame(frame::Info {
1142				size: 3,
1143				timestamp: Timestamp::now(),
1144			})
1145			.unwrap();
1146		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1147		assert!(!writer.timestamp.is_zero(), "local clock should be non-zero");
1148	}
1149
1150	/// The per-frame size cap (the group byte budget) is enforced before allocating.
1151	#[test]
1152	fn create_frame_rejects_oversized() {
1153		let mut producer = Info { sequence: 0 }.produce();
1154		let result = producer.create_frame(frame::Info {
1155			size: MAX_GROUP_CACHE + 1,
1156			timestamp: Timestamp::ZERO,
1157		});
1158		assert!(matches!(result, Err(Error::FrameTooLarge)));
1159	}
1160}