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::sync::Arc;
16use std::task::{Poll, ready};
17
18use crate::{Error, IntoBytes, Result, Timestamp};
19
20/// Maximum total size of frames cached in a group before old frames are evicted.
21///
22/// Doubles as the per-frame size cap: a single frame can be at most this large (a
23/// larger declared size is refused before allocating), so one maximum-size frame can
24/// fill a group's cache.
25pub const MAX_CACHE_BYTES: u64 = 32 * 1024 * 1024; // 32 MB
26
27/// A group contains a sequence number because they can arrive out of order.
28///
29/// You can use [track::Producer::append_group] if you just want to +1 the sequence number.
30#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Ord, PartialOrd)]
31pub struct Info {
32	/// Per-track sequence number used to detect ordering and gaps. Higher numbers
33	/// supersede lower ones; consumers may skip late arrivals.
34	pub sequence: u64,
35}
36
37impl Info {
38	/// Create an untimed producer for this group.
39	///
40	/// Test-only: real groups are created via [`track::Producer`], which
41	/// supplies the parent track's [`track::Info`]. This helper exists for in-crate
42	/// tests that don't exercise timestamps.
43	#[cfg(test)]
44	pub(crate) fn produce(self) -> Producer {
45		Producer::new(self, track::Info::default(), Default::default())
46	}
47}
48
49impl From<usize> for Info {
50	fn from(sequence: usize) -> Self {
51		Self {
52			sequence: sequence as u64,
53		}
54	}
55}
56
57impl From<u64> for Info {
58	fn from(sequence: u64) -> Self {
59		Self { sequence }
60	}
61}
62
63impl From<u32> for Info {
64	fn from(sequence: u32) -> Self {
65		Self {
66			sequence: sequence as u64,
67		}
68	}
69}
70
71impl From<u16> for Info {
72	fn from(sequence: u16) -> Self {
73		Self {
74			sequence: sequence as u64,
75		}
76	}
77}
78
79/// The in-flight (tail) frame being written. At most one exists at a time, since a
80/// group is a single ordered stream.
81pub(crate) struct Partial {
82	timestamp: Timestamp,
83	buf: FrameBuf,
84}
85
86/// Shared group state. `pub(crate)` so [`frame`] handles can observe the abort flag
87/// while streaming a partial frame.
88#[derive(Default)]
89pub(crate) struct GroupState {
90	// Completed frames, each a contiguous payload. Evicted frames are popped from the
91	// front; `offset` tracks how many.
92	pub(crate) frames: VecDeque<Frame>,
93
94	// The single in-flight frame, if one is open.
95	pub(crate) partial: Option<Partial>,
96
97	// The number of frames evicted from the front of the group.
98	pub(crate) offset: usize,
99
100	// The total size (in bytes) of all cached frames plus any in-flight frame.
101	pub(crate) cache: u64,
102
103	// Mirrors `cache` into the track's shared cache pool, so the group's bytes count
104	// against the byte budget tracks evict toward.
105	charge: cache::Charge,
106
107	// Once finalized, the total number of frames the group will ever contain. Recorded
108	// at finish so the count outlives an abort that clears the cache.
109	pub(crate) fin: Option<usize>,
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			// A frame read is a cache access: stamp it so expiry and the eviction
125			// walk spare a group a consumer is actively draining.
126			self.charge.refresh();
127			let info = frame::Info {
128				size: f.payload.len() as u64,
129				timestamp: f.timestamp,
130			};
131			return Poll::Ready(Ok(Some((info, frame::Source::Complete(f.payload.clone())))));
132		}
133		if local == self.frames.len()
134			&& let Some(p) = &self.partial
135		{
136			self.charge.refresh();
137			let info = frame::Info {
138				size: p.buf.capacity() as u64,
139				timestamp: p.timestamp,
140			};
141			return Poll::Ready(Ok(Some((info, frame::Source::Partial(p.buf.clone())))));
142		}
143		ready!(self.poll_terminal(index))?;
144		Poll::Ready(Ok(None))
145	}
146
147	/// Resolve the group's terminal state for a reader positioned at `index`.
148	///
149	/// A finished group is still aborted once its frames are released to free memory
150	/// (aged out of the track's latency window, or evicted by the cache pool). A reader
151	/// that already consumed every frame is missing nothing, so it gets the clean end of
152	/// group; one that fell short sees the abort rather than a silently truncated stream.
153	fn poll_terminal(&self, index: usize) -> Poll<Result<()>> {
154		match (self.fin, &self.abort) {
155			(Some(total), Some(err)) if index < total => Poll::Ready(Err(err.clone())),
156			(Some(_), _) => Poll::Ready(Ok(())),
157			(None, Some(err)) => Poll::Ready(Err(err.clone())),
158			(None, None) => Poll::Pending,
159		}
160	}
161
162	fn poll_finished(&self) -> Poll<Result<u64>> {
163		// The count is recorded at finish, so a later abort that cleared the cache
164		// doesn't turn a complete group into an error.
165		if let Some(total) = self.fin {
166			Poll::Ready(Ok(total as u64))
167		} else if let Some(err) = &self.abort {
168			Poll::Ready(Err(err.clone()))
169		} else {
170			Poll::Pending
171		}
172	}
173
174	/// Evict completed frames from the front until within the byte budget.
175	fn evict(&mut self) {
176		while self.cache > MAX_CACHE_BYTES {
177			let Some(frame) = self.frames.pop_front() else {
178				break;
179			};
180			let size = frame.payload.len() as u64;
181			self.cache -= size;
182			self.charge.sub(size);
183			self.offset += 1;
184		}
185	}
186
187	/// Drop the cached frames (and any in-flight tail) and release their pool charge.
188	fn release(&mut self) {
189		self.frames.clear();
190		self.partial = None;
191		self.cache = 0;
192		self.charge.clear();
193	}
194}
195
196fn modify(state: &kio::Producer<GroupState>) -> Result<kio::Mut<'_, GroupState>> {
197	state.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped))
198}
199
200/// Writes frames to a group in order.
201///
202/// Each group is delivered independently over a QUIC stream.
203/// Use [Self::write_frame] for simple single-buffer frames,
204/// or [Self::create_frame] for multi-chunk streaming writes.
205pub struct Producer {
206	// Mutable stream state.
207	state: kio::Producer<GroupState>,
208
209	// The group header containing the sequence number. A small `Copy` value,
210	// inherited by each frame (see [`Self::create_frame`]).
211	info: Info,
212
213	// The parent track's properties, inherited rather than passed piecemeal. Its
214	// `timescale` is used by [`Self::create_frame`] to normalize every frame's
215	// timestamp into the track scale before it enters the stream. Threaded down by
216	// value from [`track::Producer::create_group`] / `append_group`.
217	track: track::Info,
218
219	// The parent track's account against the shared cache pool. Held here as well as
220	// in the group's `cache::Charge` so a frame write can settle the track's eviction
221	// debt with the group lock released.
222	cache: Arc<cache::Track>,
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	// Shared by every clone: its `Drop` is the abrupt-teardown, running exactly once
229	// when the last of them goes.
230	alive: Arc<Alive>,
231}
232
233/// Ends the group when the last [`Producer`] clone drops, including the clone the
234/// parent track holds in its cache.
235///
236/// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is
237/// a snapshot, and acting on it is exactly what can invalidate it. Holding a producer
238/// of its own also keeps the state writable until the teardown has run, whatever order
239/// the last owner's fields drop in.
240struct Alive {
241	info: Info,
242	state: kio::Producer<GroupState>,
243}
244
245impl Drop for Alive {
246	fn drop(&mut self) {
247		// See track::Alive: the last producer dropping without a clean finish releases
248		// the cached frames so a stale consumer can't pin their buffers forever. A
249		// finished group keeps its cache so consumers can drain.
250		//
251		// Check Ok and Err: Ok is unreachable after a deliberate close.
252		match self.state.write() {
253			Ok(mut state) => {
254				if state.fin.is_some() || state.abort.is_some() {
255					return;
256				}
257				tracing::warn!(
258					sequence = self.info.sequence,
259					"group::Producer dropped without finish() or abort()"
260				);
261				state.release();
262			}
263			Err(state) => {
264				if state.fin.is_some() || state.abort.is_some() {
265					return;
266				}
267				tracing::warn!(
268					sequence = self.info.sequence,
269					"group::Producer dropped without finish() or abort()"
270				);
271			}
272		}
273	}
274}
275
276impl std::ops::Deref for Producer {
277	type Target = Info;
278
279	fn deref(&self) -> &Self::Target {
280		&self.info
281	}
282}
283
284impl Producer {
285	/// Create a group producer bound to its parent track's [`track::Info`] and cache
286	/// account.
287	///
288	/// Crate-private: groups are only constructed via [`track::Producer`], which
289	/// threads both down so properties like the timescale are inherited rather than
290	/// passed in. Every frame added to this group is normalized to the track's
291	/// timescale by [`Self::create_frame`].
292	///
293	/// Charges the group into `cache`, so its cached bytes count against the budget the
294	/// track evicts toward under memory pressure.
295	pub(crate) fn new(info: Info, track: track::Info, cache: Arc<cache::Track>) -> Self {
296		let state = kio::Producer::<GroupState>::default();
297		state.write().ok().expect("a new group is open").charge = cache.charge();
298		let alive = Arc::new(Alive {
299			info,
300			state: state.clone(),
301		});
302		Self {
303			info,
304			state,
305			track,
306			cache,
307			stats: stats::Meter::default(),
308			alive,
309		}
310	}
311
312	/// Attach an ingress payload meter, counting this as one delivered group.
313	/// Called by a tagged [`track::Producer`] when it creates the group.
314	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
315		meter.group();
316		self.stats = meter;
317		self
318	}
319
320	/// The group header.
321	pub(crate) fn info(&self) -> Info {
322		self.info
323	}
324
325	/// The parent track's timescale.
326	pub fn timescale(&self) -> Timescale {
327		self.track.timescale
328	}
329
330	/// A helper method to write a frame from a single byte buffer.
331	///
332	/// If you want to write multiple chunks, use [Self::create_frame] to get a frame producer.
333	/// But an upfront size is required.
334	///
335	/// `timestamp` is converted into the parent track's timescale. For data without
336	/// a presentation time, pass [`Timestamp::now`] explicitly.
337	pub fn write_frame<B: IntoBytes>(&mut self, timestamp: Timestamp, data: B) -> Result<()> {
338		let timestamp = timestamp
339			.convert(self.track.timescale)
340			.map_err(|_| Error::TimestampMismatch)?;
341		let payload = data.into_bytes();
342		if payload.len() as u64 > MAX_CACHE_BYTES {
343			return Err(Error::FrameTooLarge);
344		}
345
346		let mut state = self.writable()?;
347		let size = payload.len() as u64;
348		state.cache += size;
349		state.charge.add(size);
350		state.frames.push_back(Frame { timestamp, payload });
351		state.evict();
352		drop(state);
353
354		// With the group lock released (lock order is track then group), settle
355		// eviction debt if enough has been written since the track last paid.
356		self.cache.settle();
357
358		// Ingress payload: one whole frame written.
359		self.stats.frames(1);
360		self.stats.bytes(size);
361		Ok(())
362	}
363
364	/// Take the group state for a write, refusing one that can no longer accept frames.
365	///
366	/// A group with an open frame rejects rather than appends: `create_frame` borrows
367	/// its producer exclusively, but `Producer` is `Clone`, so a second handle can
368	/// reach this while the first is still streaming. Appending around the open frame
369	/// would hand readers the batch before the frame that was opened first.
370	fn writable(&self) -> Result<kio::Mut<'_, GroupState>> {
371		let state = modify(&self.state)?;
372		if state.fin.is_some() {
373			return Err(Error::Closed);
374		}
375		if state.partial.is_some() {
376			return Err(Error::FrameOpen);
377		}
378		Ok(state)
379	}
380
381	/// Write a whole batch of frames at once, draining `frames`.
382	///
383	/// One lock covers the batch, so an ingest with several frames in hand pays the
384	/// group mutex and the track's eviction settle once rather than per frame. Build
385	/// the batch with [`frame::Buffer::push`].
386	///
387	/// The batch is validated before anything is written, so a rejected frame leaves
388	/// both the group and the buffer exactly as they were, ready to retry or redirect.
389	/// Returns [`Error::FrameOpen`] if another handle is streaming a frame into this
390	/// group, since appending around it would reorder the group.
391	pub fn write_frames<const N: usize>(&mut self, frames: &mut frame::Buffer<N>) -> Result<()> {
392		// Check the whole batch up front, without touching it: a rejected batch stays
393		// exactly as the caller built it, so it can be retried or sent elsewhere.
394		// Timestamp conversion is lossy across scales that don't divide evenly, so
395		// converting in place here would silently shift presentation times on retry.
396		for frame in frames.filled() {
397			frame
398				.timestamp
399				.convert(self.track.timescale)
400				.map_err(|_| Error::TimestampMismatch)?;
401			if frame.payload.len() as u64 > MAX_CACHE_BYTES {
402				return Err(Error::FrameTooLarge);
403			}
404		}
405
406		let count = frames.len() as u64;
407		let mut bytes = 0;
408
409		let mut state = self.writable()?;
410		// Past every fallible check: converting again can't fail, and the batch is
411		// ours from here.
412		for mut frame in frames.drain() {
413			frame.timestamp = frame
414				.timestamp
415				.convert(self.track.timescale)
416				.expect("timestamp scale checked above");
417			let size = frame.payload.len() as u64;
418			bytes += size;
419			state.cache += size;
420			state.charge.add(size);
421			state.frames.push_back(frame);
422		}
423		state.evict();
424		drop(state);
425
426		// With the group lock released (lock order is track then group), settle
427		// eviction debt if enough has been written since the track last paid.
428		self.cache.settle();
429
430		// Ingress payload: the whole batch, counted once.
431		self.stats.frames(count);
432		self.stats.bytes(bytes);
433		Ok(())
434	}
435
436	/// Create a frame with an upfront size and presentation timestamp, streamed in
437	/// chunks. Borrows the group exclusively until the returned [`frame::Producer`]
438	/// is finished or dropped, so only one frame is open at a time.
439	///
440	/// The `timestamp` is converted into the parent track's timescale, so the scale you
441	/// build it with doesn't have to match the track. Returns [`Error::FrameTooLarge`]
442	/// if the declared size exceeds the group's byte budget (refused before allocating)
443	/// or [`Error::TimestampMismatch`] if the timestamp can't be converted (overflow).
444	pub fn create_frame(&mut self, frame: frame::Info) -> Result<frame::Producer<'_>> {
445		let timestamp = frame
446			.timestamp
447			.convert(self.track.timescale)
448			.map_err(|_| Error::TimestampMismatch)?;
449		if frame.size > MAX_CACHE_BYTES {
450			return Err(Error::FrameTooLarge);
451		}
452		let buf = FrameBuf::new(frame.size as usize);
453
454		let mut state = self.writable()?;
455		state.cache += frame.size;
456		state.charge.add(frame.size);
457		state.partial = Some(Partial {
458			timestamp,
459			buf: buf.clone(),
460		});
461		state.evict();
462		drop(state);
463
464		// With the group lock released (lock order is track then group), settle
465		// eviction debt if enough has been written since the track last paid.
466		self.cache.settle();
467
468		// Ingress payload: one frame opened; its bytes are counted per chunk as the
469		// frame::Producer writes them.
470		self.stats.frames(1);
471		let meter = self.stats.clone();
472
473		let info = frame::Info {
474			size: frame.size,
475			timestamp,
476		};
477		Ok(frame::Producer::new(self, buf, info).with_meter(meter))
478	}
479
480	/// Wake consumers parked on the group channel (called after a partial write).
481	pub(crate) fn frame_notify(&self) {
482		// The chunk that was just written is a write access: restart the retention
483		// clock so a straggler group streaming a large frame isn't expired
484		// mid-write (its bytes were already charged when the frame was created).
485		// `record_write` takes `&mut`, which marks the guard modified: kio only
486		// notifies on a mutably-accessed guard's release, and that notify is what
487		// delivers the chunk to parked readers.
488		if let Ok(mut state) = self.state.write() {
489			state.charge.record_write();
490		}
491	}
492
493	/// Commit the in-flight frame as a completed frame (called by [`frame::Producer::finish`]).
494	pub(crate) fn frame_commit(&mut self, frame: Frame) -> Result<()> {
495		let mut state = modify(&self.state)?;
496		// Bytes were already counted against the cache (and the pool charge) when the
497		// frame was created; committing just moves the tail into the completed set.
498		state.partial = None;
499		state.frames.push_back(frame);
500		Ok(())
501	}
502
503	/// Fail the group because an in-flight frame couldn't complete (called by
504	/// [`frame::Producer::abort`] / its drop).
505	pub(crate) fn frame_abort(&mut self, err: Error) {
506		let _ = self.clone().abort(err);
507	}
508
509	/// Return the number of frames written so far (completed plus any in-flight).
510	pub fn frame_count(&self) -> usize {
511		let state = self.state.read();
512		state.offset + state.frames.len() + state.partial.is_some() as usize
513	}
514
515	/// Mark the group as complete; no more frames will be written.
516	///
517	/// Borrows rather than consumes, so a later failure can still be reported through
518	/// [`abort`](Self::abort). The handle also keeps the cached frames readable.
519	pub fn finish(&mut self) -> Result<()> {
520		let mut state = modify(&self.state)?;
521		// The recorded count is what tells readers the group ended, so an open frame
522		// would be left out of it and read as a clean end rather than a frame still
523		// coming. Another clone can reach this while the frame's producer holds the
524		// handle, so refuse rather than strand it. Use `abort` to end a group early.
525		if state.partial.is_some() {
526			return Err(Error::FrameOpen);
527		}
528		state.fin = Some(state.offset + state.frames.len());
529		Ok(())
530	}
531
532	/// Abort the group with the given error.
533	///
534	/// Consumes the handle. Drops the cached frames so a stale [`Consumer`] can't pin
535	/// their buffers in memory forever; consumers that haven't drained yet surface the
536	/// abort error instead of the leftover cache.
537	pub fn abort(self, err: Error) -> Result<()> {
538		let mut guard = modify(&self.state)?;
539		guard.abort = Some(err);
540		guard.release();
541		guard.close();
542		Ok(())
543	}
544
545	/// Whether the group has been aborted (including pool eviction). The track's
546	/// read paths treat an aborted cached group as absent.
547	pub(crate) fn is_aborted(&self) -> bool {
548		self.state.read().abort.is_some()
549	}
550
551	/// The group's full cached footprint (payload plus fixed overhead), used by the
552	/// track to size this group as an eviction victim.
553	pub(crate) fn cache_size(&self) -> u64 {
554		self.state.read().charge.size()
555	}
556
557	/// Tick of the group's last cache access, driving eviction protection and age
558	/// expiry (see [`cache::Pool::average`]).
559	pub(crate) fn cache_accessed(&self) -> u64 {
560		self.state.read().charge.accessed()
561	}
562
563	/// Enter the group into the evictable population: demoted from the live edge,
564	/// or inserted behind it. Idempotent; a no-op once the group is closed.
565	pub(crate) fn cache_demote(&self) {
566		if let Ok(mut state) = self.state.write() {
567			state.charge.demote();
568		}
569	}
570
571	/// Record a cache access (delivery to a subscriber, a FETCH hit, or a fetched
572	/// backfill's birth), protecting the group from eviction and restarting its
573	/// expiry clock. Stamps through a read guard, whose release never notifies, so
574	/// delivery can't wake every consumer parked on the group. Harmless on a
575	/// closed group: its charge is already cleared.
576	pub(crate) fn cache_refresh(&self) {
577		self.state.read().charge.refresh();
578	}
579
580	/// Create a new consumer for the group.
581	pub fn consume(&self) -> Consumer {
582		Consumer {
583			info: self.info,
584			state: self.state.consume(),
585			track: self.track.clone(),
586			index: 0,
587			// Untagged: a tagged track attaches the egress meter via `with_meter`
588			// when it hands the consumer to a subscriber/fetch.
589			stats: stats::Meter::default(),
590		}
591	}
592
593	/// Block until the group is closed or aborted.
594	pub async fn closed(&self) -> Error {
595		kio::wait(|waiter| self.poll_closed(waiter)).await
596	}
597
598	/// Poll until the group is closed or aborted; ready with the cause.
599	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<Error> {
600		self.state.poll_closed(waiter).map(|()| self.abort_reason())
601	}
602
603	/// Block until there are no active consumers.
604	pub async fn unused(&self) -> Result<()> {
605		self.state.unused().await.map_err(|_| self.abort_reason())
606	}
607
608	/// The recorded abort reason, or [`Error::Dropped`] if the group closed without one.
609	fn abort_reason(&self) -> Error {
610		self.state.read().abort.clone().unwrap_or(Error::Dropped)
611	}
612}
613
614impl Clone for Producer {
615	fn clone(&self) -> Self {
616		Self {
617			info: self.info,
618			state: self.state.clone(),
619			track: self.track.clone(),
620			cache: self.cache.clone(),
621			stats: self.stats.clone(),
622			alive: self.alive.clone(),
623		}
624	}
625}
626
627/// Consume a group, frame-by-frame.
628pub struct Consumer {
629	// Shared state with the producer.
630	state: kio::Consumer<GroupState>,
631
632	// Immutable stream state.
633	info: Info,
634
635	// The parent track's info, inherited from the producer. Its `timescale` lets the
636	// wire publisher emit per-frame timestamps at the right scale for a fetched group.
637	track: track::Info,
638
639	// The number of frames we've read.
640	// NOTE: Cloned readers inherit this offset, but then run in parallel.
641	index: usize,
642
643	// Egress payload meter, set by a tagged track via [`Self::with_meter`]. Empty
644	// (no-op) for an untagged group.
645	stats: stats::Meter,
646}
647
648impl Clone for Consumer {
649	fn clone(&self) -> Self {
650		// A clone shares the channel and inherits `index`, but then runs in parallel.
651		Self {
652			state: self.state.clone(),
653			info: self.info,
654			track: self.track.clone(),
655			index: self.index,
656			// Inherit the meter without re-counting the group: the original already
657			// counted it when the track handed it out.
658			stats: self.stats.clone(),
659		}
660	}
661}
662
663impl std::ops::Deref for Consumer {
664	type Target = Info;
665
666	fn deref(&self) -> &Self::Target {
667		&self.info
668	}
669}
670
671impl Consumer {
672	/// Attach an egress payload meter, counting this as one delivered group.
673	/// Called by a tagged track when it hands the consumer to a subscriber or fetch.
674	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
675		meter.group();
676		self.stats = meter;
677		self
678	}
679
680	/// Whether the group has been aborted (including pool eviction); the abort
681	/// dropped the cached frames, so a held consumer has nothing left to read.
682	pub(crate) fn is_aborted(&self) -> bool {
683		self.state.read().abort.is_some()
684	}
685
686	/// Mark the group as still being read, so a slow drain doesn't expire it.
687	///
688	/// [`Self::read_frames`] stamps the group's cache access once per batch, which
689	/// bounds frames rather than elapsed time. A reader that takes longer than the
690	/// track's `latency_max` to work through one batch (a publisher writing to a
691	/// flow-controlled peer, say) calls this between frames, or the rest of the group
692	/// is expired out from under it mid-serve. [`Self::read_frame`] stamps on every
693	/// call and needs no help.
694	///
695	/// Cheap and idempotent within a coarse clock tick, so calling it per frame is
696	/// fine.
697	pub fn keep_alive(&self) {
698		self.state.read().charge.refresh();
699	}
700
701	/// Record a cache access from the consumer side: a parked group re-offered to
702	/// its subscriber. Same stamp as [`Producer::cache_refresh`].
703	pub(crate) fn cache_refresh(&self) {
704		self.keep_alive();
705	}
706
707	/// Park `waiter` until the group closes (finish, abort, or eviction). Spliced
708	/// subscribers register on parked groups so an eviction wakes them; a group
709	/// that already closed cleanly can never abort, so no waiter is needed.
710	pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<()> {
711		self.state.poll_closed(waiter)
712	}
713
714	/// The parent track's timescale.
715	pub fn timescale(&self) -> Timescale {
716		self.track.timescale
717	}
718
719	/// The number of frames written so far (completed plus any in-flight), independent of
720	/// how many this consumer has read. The final total once the group is finished.
721	pub fn frame_count(&self) -> usize {
722		let state = self.state.read();
723		state
724			.fin
725			.unwrap_or(state.offset + state.frames.len() + state.partial.is_some() as usize)
726	}
727
728	// A helper to automatically apply Dropped if the state is closed without an error.
729	fn poll<F, R>(&self, waiter: &kio::Waiter, f: F) -> Poll<Result<R>>
730	where
731		F: FnMut(&kio::Ref<'_, GroupState>) -> Poll<Result<R>>,
732	{
733		Poll::Ready(match ready!(self.state.poll(waiter, f)) {
734			Ok(res) => res,
735			// We try to clone abort just in case the function forgot to check for terminal state.
736			Err(state) => Err(state.abort.clone().unwrap_or(Error::Dropped)),
737		})
738	}
739
740	/// Return a consumer for the next frame for chunked reading.
741	pub async fn next_frame(&mut self) -> Result<Option<frame::Consumer>> {
742		kio::wait(|waiter| self.poll_next_frame(waiter)).await
743	}
744
745	/// Poll for the next frame, without blocking.
746	///
747	/// Returns None if the group is finished and the index is out of range.
748	pub fn poll_next_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Consumer>>> {
749		let index = self.index;
750		let Some((info, source)) = ready!(self.poll(waiter, |state| state.poll_frame_source(index))?) else {
751			return Poll::Ready(Ok(None));
752		};
753
754		self.index += 1;
755		// Count the frame here; the frame::Consumer counts its bytes per chunk as
756		// they're read out.
757		self.stats.frames(1);
758		Poll::Ready(Ok(Some(
759			frame::Consumer::new(self.state.clone(), info, source).with_meter(self.stats.clone()),
760		)))
761	}
762
763	/// Read the next frame (timestamp and payload) all at once, without blocking.
764	///
765	/// Use [`Self::read_frames`] to pull a whole batch under one lock; a group of small
766	/// frames drains several times faster that way.
767	pub fn poll_read_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Frame>>> {
768		let index = self.index;
769		let frame = ready!(self.poll(waiter, |state| {
770			if index < state.offset {
771				return Poll::Ready(Err(Error::Lagged));
772			}
773			if let Some(frame) = state.frames.get(index - state.offset) {
774				// A frame read is a cache access: stamp it so expiry and the eviction
775				// walk spare a group a consumer is actively draining.
776				state.charge.refresh();
777				return Poll::Ready(Ok(Some(frame.clone())));
778			}
779			// Nothing completed at `index`: an in-flight tail waits, otherwise resolve
780			// the terminal state (whole-frame reads never stream the partial).
781			state.poll_terminal(index).map_ok(|()| None)
782		})?);
783
784		if let Some(frame) = &frame {
785			self.index += 1;
786			self.stats.frames(1);
787			self.stats.bytes(frame.payload.len() as u64);
788		}
789
790		Poll::Ready(Ok(frame))
791	}
792
793	/// Read the next frame (timestamp and payload) all at once.
794	pub async fn read_frame(&mut self) -> Result<Option<frame::Frame>> {
795		kio::wait(|waiter| self.poll_read_frame(waiter)).await
796	}
797
798	/// Fill `out` with every frame that is ready, up to its capacity, without blocking.
799	///
800	/// Returns how many frames were written; they're in [`frame::Buffer::filled`]. The
801	/// buffer's previous batch is dropped first, so one buffer serves a whole group.
802	///
803	/// This is a *short* read: it returns as soon as anything is ready rather than
804	/// waiting for `out` to fill, so a partial batch does not mean the group ended.
805	/// Only a count of `0` does (and only for a non-zero capacity).
806	///
807	/// One stamp covers the whole batch, so a slow drain calls
808	/// [`Self::keep_alive`] between frames.
809	pub fn poll_read_frames<const N: usize>(
810		&mut self,
811		waiter: &kio::Waiter,
812		out: &mut frame::Buffer<N>,
813	) -> Poll<Result<usize>> {
814		// Drop the previous batch before taking the lock: deallocating payloads is the
815		// caller's cost to pay, not something to hold the group's mutex through.
816		out.clear();
817
818		let index = self.index;
819		let res = self.poll(waiter, |state| {
820			if index < state.offset {
821				return Poll::Ready(Err(Error::Lagged));
822			}
823			// `local` can run past the buffered count when frames were cleared or evicted
824			// out from under us (abort, unfinished drop, an eviction gap); clamp so
825			// `range` never panics on an out-of-bounds start.
826			let local = (index - state.offset).min(state.frames.len());
827			if out.fill(state.frames.range(local..).cloned()) > 0 {
828				// One stamp covers the whole batch.
829				state.charge.refresh();
830				return Poll::Ready(Ok(()));
831			}
832			// An empty fill means nothing completed at `index`: park on an in-flight
833			// tail, otherwise resolve the terminal state. A finished group resolves to
834			// `Ok`, leaving the zero count to report the end.
835			state.poll_terminal(index)
836		});
837
838		// A `Pending` here leaves `out` cleared, which is what an empty batch should look
839		// like to a caller that inspects it anyway.
840		ready!(res)?;
841
842		let filled = out.filled().len();
843		self.index += filled;
844		// Count the whole batch once, under no lock.
845		self.stats.frames(filled as u64);
846		self.stats
847			.bytes(out.filled().iter().map(|f| f.payload.len() as u64).sum());
848
849		Poll::Ready(Ok(filled))
850	}
851
852	/// Fill `out` with every frame that is ready, blocking until at least one is or the
853	/// group ends. Returns the batch, empty only at the end of the group.
854	///
855	/// See [`Self::poll_read_frames`] for the short-read semantics.
856	pub async fn read_frames<'a, const N: usize>(
857		&mut self,
858		out: &'a mut frame::Buffer<N>,
859	) -> Result<&'a mut [frame::Frame]> {
860		// The closure reborrows `out` for less than `'a`, so the buffer is free again
861		// once the wait resolves.
862		kio::wait(|waiter| self.poll_read_frames(waiter, out)).await?;
863		Ok(out.filled_mut())
864	}
865
866	/// Poll for the final number of frames in the group.
867	pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll<Result<u64>> {
868		self.poll(waiter, |state| state.poll_finished())
869	}
870
871	/// Block until the group is finished, returning the number of frames in the group.
872	pub async fn finished(&mut self) -> Result<u64> {
873		kio::wait(|waiter| self.poll_finished(waiter)).await
874	}
875}
876
877/// Options for a one-shot [`track::Consumer::fetch_group`] of a past group.
878#[derive(Clone, Debug, Default)]
879#[non_exhaustive]
880pub struct Fetch {
881	/// Delivery priority for the fetched group's stream. Defaults to 0.
882	pub priority: u8,
883}
884
885impl Fetch {
886	/// Set the delivery priority, returning `self` for chaining.
887	pub fn with_priority(mut self, priority: u8) -> Self {
888		self.priority = priority;
889		self
890	}
891}
892
893#[cfg(test)]
894mod test {
895	use super::*;
896	use crate::model::test_tracing::count_drop_warnings;
897	use bytes::Bytes;
898	use futures::FutureExt;
899
900	#[test]
901	fn basic_frame_reading() {
902		let mut producer = Info { sequence: 0 }.produce();
903		producer
904			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame0"))
905			.unwrap();
906		producer
907			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame1"))
908			.unwrap();
909		producer.finish().unwrap();
910
911		let mut consumer = producer.consume();
912		let f0 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
913		assert_eq!(f0.size, 6);
914		let f1 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
915		assert_eq!(f1.size, 6);
916		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
917		assert!(end.is_none());
918	}
919
920	/// Write `n` frames with payloads "0".."n-1" into a fresh group.
921	fn filled_group(n: usize) -> Producer {
922		let mut producer = Info { sequence: 0 }.produce();
923		for i in 0..n {
924			producer
925				.write_frame(Timestamp::ZERO, Bytes::from(i.to_string()))
926				.unwrap();
927		}
928		producer
929	}
930
931	/// The payload strings of a batch.
932	fn payloads(frames: &[Frame]) -> Vec<String> {
933		frames
934			.iter()
935			.map(|frame| String::from_utf8(frame.payload.to_vec()).unwrap())
936			.collect()
937	}
938
939	/// Drain a consumer through a batch buffer of `N`, collecting payload strings.
940	fn drain<const N: usize>(consumer: &mut Consumer) -> Vec<String> {
941		let mut buf = frame::Buffer::<N>::new();
942		let mut seen = Vec::new();
943		loop {
944			let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
945			if batch.is_empty() {
946				break;
947			}
948			seen.extend(payloads(batch));
949		}
950		seen
951	}
952
953	/// `create_frame` borrows its producer exclusively, but `Producer` is `Clone`, so a
954	/// second handle can reach the whole-frame writes while a frame is still open.
955	/// Appending there would hand readers the new frames before the one opened first,
956	/// so every whole-frame path refuses instead.
957	#[test]
958	fn writes_are_refused_while_a_frame_is_open() {
959		let mut producer = Info { sequence: 0 }.produce();
960		let mut other = producer.clone();
961
962		// One handle opens a frame and holds it, incomplete.
963		let mut open = producer
964			.create_frame(frame::Info {
965				size: 4,
966				timestamp: Timestamp::ZERO,
967			})
968			.unwrap();
969
970		let mut buf = frame::Buffer::<4>::new();
971		buf.push(frame::Frame {
972			timestamp: Timestamp::ZERO,
973			payload: Bytes::from_static(b"batch"),
974		})
975		.unwrap();
976
977		assert!(matches!(other.write_frames(&mut buf), Err(Error::FrameOpen)));
978		assert_eq!(buf.len(), 1, "the batch is still the caller's");
979		assert!(matches!(
980			other.write_frame(Timestamp::ZERO, Bytes::from_static(b"single")),
981			Err(Error::FrameOpen)
982		));
983		assert!(matches!(
984			other
985				.create_frame(frame::Info {
986					size: 1,
987					timestamp: Timestamp::ZERO,
988				})
989				.err(),
990			Some(Error::FrameOpen)
991		));
992
993		// Once the open frame lands, the group takes writes again in order.
994		open.write(&b"open"[..]).unwrap();
995		open.finish().unwrap();
996		other.write_frames(&mut buf).unwrap();
997		other.finish().unwrap();
998
999		let mut consumer = other.consume();
1000		assert_eq!(drain::<4>(&mut consumer), ["open", "batch"]);
1001	}
1002
1003	/// Finishing records the frame count, and a batch read consults that count to
1004	/// decide the group ended. `create_frame` borrows its producer exclusively, but
1005	/// `Producer` is `Clone`, so a second handle can finish the group while the first
1006	/// is still writing a frame. The open frame would be left out of the count and
1007	/// read as a clean end of group, so a publisher would close the stream without
1008	/// ever sending it.
1009	#[test]
1010	fn finish_is_refused_while_a_frame_is_open() {
1011		let mut producer = Info { sequence: 0 }.produce();
1012		let mut other = producer.clone();
1013		let mut consumer = producer.consume();
1014
1015		let mut frame = producer
1016			.create_frame(frame::Info {
1017				size: 4,
1018				timestamp: Timestamp::ZERO,
1019			})
1020			.unwrap();
1021
1022		assert!(matches!(other.finish(), Err(Error::FrameOpen)));
1023
1024		// Not "the group ended": the batch read parks until the frame lands.
1025		let mut buf = frame::Buffer::<4>::new();
1026		assert!(
1027			consumer.read_frames(&mut buf).now_or_never().is_none(),
1028			"an open frame must not read as the end of the group"
1029		);
1030
1031		frame.write(&b"open"[..]).unwrap();
1032		frame.finish().unwrap();
1033		other.finish().unwrap();
1034
1035		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1036		assert_eq!(batch.len(), 1);
1037		assert_eq!(batch[0].payload, Bytes::from_static(b"open"));
1038	}
1039
1040	#[test]
1041	fn write_frames_appends_the_whole_batch() {
1042		let mut producer = Info { sequence: 0 }.produce();
1043		let mut buf = frame::Buffer::<8>::new();
1044		for i in 0..5u8 {
1045			buf.push(frame::Frame {
1046				timestamp: Timestamp::ZERO,
1047				payload: Bytes::from(i.to_string()),
1048			})
1049			.unwrap();
1050		}
1051		producer.write_frames(&mut buf).unwrap();
1052		assert!(buf.is_empty(), "the batch was drained");
1053		producer.finish().unwrap();
1054
1055		let mut consumer = producer.consume();
1056		assert_eq!(drain::<8>(&mut consumer), ["0", "1", "2", "3", "4"]);
1057	}
1058
1059	/// A rejected frame must leave both the group and the batch untouched, or the
1060	/// caller has no way to tell what was written.
1061	#[test]
1062	fn write_frames_rejects_the_batch_atomically() {
1063		let mut producer = Info { sequence: 0 }.produce();
1064		let mut buf = frame::Buffer::<4>::new();
1065		buf.push(frame::Frame {
1066			timestamp: Timestamp::ZERO,
1067			payload: Bytes::from_static(b"ok"),
1068		})
1069		.unwrap();
1070		// Larger than the group's whole byte budget.
1071		buf.push(frame::Frame {
1072			timestamp: Timestamp::ZERO,
1073			payload: Bytes::from(vec![0u8; MAX_CACHE_BYTES as usize + 1]),
1074		})
1075		.unwrap();
1076
1077		assert!(matches!(producer.write_frames(&mut buf), Err(Error::FrameTooLarge)));
1078		assert_eq!(buf.len(), 2, "the batch is still the caller's");
1079
1080		producer.finish().unwrap();
1081		let mut consumer = producer.consume();
1082		assert!(drain::<4>(&mut consumer).is_empty(), "nothing was written");
1083	}
1084
1085	/// A batch rejected mid-validation must leave the caller's frames byte-identical,
1086	/// including their timestamps: converting in place would compound scale loss if
1087	/// the batch is retried against another track.
1088	#[test]
1089	fn write_frames_leaves_a_rejected_batch_unconverted() {
1090		use crate::Timescale;
1091
1092		let mut producer = Producer::new(
1093			Info { sequence: 0 },
1094			track::Info::default().with_timescale(Timescale::MICRO),
1095			Default::default(),
1096		);
1097
1098		let mut buf = frame::Buffer::<4>::new();
1099		buf.push(frame::Frame {
1100			timestamp: Timestamp::from_millis(1).unwrap(),
1101			payload: Bytes::from_static(b"ok"),
1102		})
1103		.unwrap();
1104		// Refused after the first frame would already have been converted in place.
1105		buf.push(frame::Frame {
1106			timestamp: Timestamp::from_millis(2).unwrap(),
1107			payload: Bytes::from(vec![0u8; MAX_CACHE_BYTES as usize + 1]),
1108		})
1109		.unwrap();
1110
1111		assert!(matches!(producer.write_frames(&mut buf), Err(Error::FrameTooLarge)));
1112		let kept = buf.filled();
1113		assert_eq!(kept.len(), 2, "the batch is still the caller's");
1114		assert_eq!(kept[0].timestamp.scale(), Timescale::MILLI, "timestamp was rewritten");
1115		assert_eq!(kept[0].timestamp.value(), 1);
1116	}
1117
1118	/// A batch that is accepted still converts into the track's scale.
1119	#[test]
1120	fn write_frames_converts_into_the_track_scale() {
1121		use crate::Timescale;
1122
1123		let mut producer = Producer::new(
1124			Info { sequence: 0 },
1125			track::Info::default().with_timescale(Timescale::MICRO),
1126			Default::default(),
1127		);
1128
1129		let mut buf = frame::Buffer::<4>::new();
1130		buf.push(frame::Frame {
1131			timestamp: Timestamp::from_millis(1).unwrap(),
1132			payload: Bytes::from_static(b"x"),
1133		})
1134		.unwrap();
1135		producer.write_frames(&mut buf).unwrap();
1136		producer.finish().unwrap();
1137
1138		let frame = producer
1139			.consume()
1140			.read_frame()
1141			.now_or_never()
1142			.unwrap()
1143			.unwrap()
1144			.unwrap();
1145		assert_eq!(frame.timestamp.scale(), Timescale::MICRO);
1146		assert_eq!(frame.timestamp.value(), 1000);
1147	}
1148
1149	#[test]
1150	fn buffer_push_refuses_past_capacity() {
1151		let mut buf = frame::Buffer::<2>::new();
1152		let frame = || frame::Frame {
1153			timestamp: Timestamp::ZERO,
1154			payload: Bytes::from_static(b"x"),
1155		};
1156		buf.push(frame()).unwrap();
1157		buf.push(frame()).unwrap();
1158		assert!(buf.is_full());
1159		assert!(buf.push(frame()).is_err(), "a full buffer hands the frame back");
1160	}
1161
1162	/// A partially consumed drain still empties the buffer, dropping the rest.
1163	#[test]
1164	fn buffer_drain_empties_even_when_abandoned() {
1165		let mut buf = frame::Buffer::<4>::new();
1166		for i in 0..4u8 {
1167			buf.push(frame::Frame {
1168				timestamp: Timestamp::ZERO,
1169				payload: Bytes::from(vec![i; 1]),
1170			})
1171			.unwrap();
1172		}
1173		let taken: Vec<_> = buf.drain().take(2).collect();
1174		assert_eq!(taken.len(), 2);
1175		assert!(buf.is_empty(), "an abandoned drain still empties the buffer");
1176	}
1177
1178	#[test]
1179	fn read_frames_fills_whole_batch() {
1180		let mut producer = filled_group(5);
1181		producer.finish().unwrap();
1182
1183		let mut consumer = producer.consume();
1184		let mut buf = frame::Buffer::<8>::new();
1185
1186		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1187		assert_eq!(payloads(batch), ["0", "1", "2", "3", "4"]);
1188
1189		// A finished group reports the end with an empty batch.
1190		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1191		assert!(batch.is_empty());
1192	}
1193
1194	#[test]
1195	fn read_frames_bounded_by_capacity() {
1196		let mut producer = filled_group(5);
1197		producer.finish().unwrap();
1198
1199		let mut consumer = producer.consume();
1200		assert_eq!(drain::<2>(&mut consumer), ["0", "1", "2", "3", "4"]);
1201	}
1202
1203	#[test]
1204	fn read_frames_resumes_after_a_single_read() {
1205		let mut producer = filled_group(12);
1206		producer.finish().unwrap();
1207
1208		let mut consumer = producer.consume();
1209		let first = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1210		assert_eq!(first.payload, Bytes::from_static(b"0"));
1211
1212		assert_eq!(
1213			drain::<8>(&mut consumer),
1214			["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"]
1215		);
1216	}
1217
1218	#[test]
1219	fn read_frames_returns_short_instead_of_waiting() {
1220		let mut producer = filled_group(2);
1221
1222		let mut consumer = producer.consume();
1223		let mut buf = frame::Buffer::<8>::new();
1224
1225		// The group is still open, so the batch is short rather than blocking for more.
1226		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1227		assert_eq!(payloads(batch), ["0", "1"]);
1228
1229		// Nothing left and no terminal state: this one parks.
1230		assert!(consumer.read_frames(&mut buf).now_or_never().is_none());
1231
1232		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"2")).unwrap();
1233		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1234		assert_eq!(payloads(batch), ["2"]);
1235	}
1236
1237	#[test]
1238	fn read_frames_reports_an_abort() {
1239		let producer = filled_group(2);
1240		let mut consumer = producer.consume();
1241		producer.abort(Error::Cancel).unwrap();
1242
1243		// The abort released the cached frames, so nothing survives it.
1244		let mut buf = frame::Buffer::<8>::new();
1245		let res = consumer.read_frames(&mut buf).now_or_never().unwrap();
1246		assert!(matches!(res, Err(Error::Cancel)));
1247	}
1248
1249	/// A refill drops the previous batch, so a reused buffer never accumulates frames.
1250	#[test]
1251	fn read_frames_refill_replaces_the_previous_batch() {
1252		let mut producer = filled_group(3);
1253		producer.finish().unwrap();
1254
1255		let mut consumer = producer.consume();
1256		let mut buf = frame::Buffer::<2>::new();
1257
1258		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1259		assert_eq!(payloads(batch), ["0", "1"]);
1260
1261		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1262		assert_eq!(payloads(batch), ["2"]);
1263		assert_eq!(buf.filled().len(), 1, "the buffer holds only the latest batch");
1264	}
1265
1266	#[test]
1267	fn read_frames_zero_capacity_reads_nothing() {
1268		let mut producer = filled_group(2);
1269		producer.finish().unwrap();
1270
1271		let mut consumer = producer.consume();
1272		let mut buf = frame::Buffer::<0>::new();
1273		let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1274		assert!(batch.is_empty());
1275
1276		// The reader did not advance.
1277		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1278		assert_eq!(frame.payload, Bytes::from_static(b"0"));
1279	}
1280
1281	#[test]
1282	fn read_frame_all_at_once() {
1283		let mut producer = Info { sequence: 0 }.produce();
1284		producer
1285			.write_frame(Timestamp::ZERO, Bytes::from_static(b"hello"))
1286			.unwrap();
1287		producer.finish().unwrap();
1288
1289		let mut consumer = producer.consume();
1290		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1291		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
1292	}
1293
1294	#[test]
1295	fn read_frame_preserves_timestamp() {
1296		let mut producer = Info { sequence: 0 }.produce();
1297		let timestamp = Timestamp::from_micros(20_000).unwrap();
1298		producer.write_frame(timestamp, Bytes::from_static(b"hello")).unwrap();
1299		producer.finish().unwrap();
1300
1301		let mut consumer = producer.consume();
1302		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1303		assert_eq!(frame.timestamp.as_micros(), 20_000);
1304		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
1305	}
1306
1307	#[test]
1308	fn chunked_frame_reads_whole() {
1309		let mut producer = Info { sequence: 0 }.produce();
1310		{
1311			let mut frame = producer
1312				.create_frame(frame::Info {
1313					size: 10,
1314					timestamp: Timestamp::ZERO,
1315				})
1316				.unwrap();
1317			frame.write(Bytes::from_static(b"hello")).unwrap();
1318			frame.write(Bytes::from_static(b"world")).unwrap();
1319			frame.finish().unwrap();
1320		}
1321		producer.finish().unwrap();
1322
1323		// Frame data is held in a single per-frame buffer; a whole-frame read returns
1324		// the full contents in one slice.
1325		let mut consumer = producer.consume();
1326		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1327		assert_eq!(frame.payload, Bytes::from_static(b"helloworld"));
1328	}
1329
1330	#[test]
1331	fn chunked_frame_streams_partial() {
1332		let mut producer = Info { sequence: 0 }.produce();
1333		let mut consumer = producer.consume();
1334
1335		let mut frame = producer
1336			.create_frame(frame::Info {
1337				size: 6,
1338				timestamp: Timestamp::ZERO,
1339			})
1340			.unwrap();
1341		frame.write(Bytes::from_static(b"foo")).unwrap();
1342
1343		// A consumer can stream the in-flight tail before it's finished.
1344		let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1345		let c1 = f.read_chunk().now_or_never().unwrap().unwrap();
1346		assert_eq!(c1, Some(Bytes::from_static(b"foo")));
1347		assert!(f.read_chunk().now_or_never().is_none());
1348
1349		frame.write(Bytes::from_static(b"bar")).unwrap();
1350		frame.finish().unwrap();
1351
1352		let c2 = f.read_chunk().now_or_never().unwrap().unwrap();
1353		assert_eq!(c2, Some(Bytes::from_static(b"bar")));
1354		let c3 = f.read_chunk().now_or_never().unwrap().unwrap();
1355		assert_eq!(c3, None);
1356	}
1357
1358	#[test]
1359	fn group_finish_returns_none() {
1360		let mut producer = Info { sequence: 0 }.produce();
1361		producer.finish().unwrap();
1362
1363		let mut consumer = producer.consume();
1364		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
1365		assert!(end.is_none());
1366	}
1367
1368	#[test]
1369	fn abort_propagates() {
1370		let producer = Info { sequence: 0 }.produce();
1371		let mut consumer = producer.consume();
1372		producer.abort(crate::Error::Cancel).unwrap();
1373
1374		let result = consumer.next_frame().now_or_never().unwrap();
1375		assert!(matches!(result, Err(crate::Error::Cancel)));
1376	}
1377
1378	#[test]
1379	fn abort_clears_cached_frames() {
1380		let mut producer = Info { sequence: 0 }.produce();
1381		producer
1382			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1383			.unwrap();
1384
1385		// A stale consumer that never reads must not pin the cached frames.
1386		let _consumer = producer.consume();
1387		assert_eq!(producer.state.read().frames.len(), 1);
1388
1389		producer.clone().abort(crate::Error::Cancel).unwrap();
1390
1391		let state = producer.state.read();
1392		assert!(state.frames.is_empty(), "cached frames should be dropped on abort");
1393		assert_eq!(state.cache, 0);
1394	}
1395
1396	#[test]
1397	fn drop_unfinished_clears_cached_frames() {
1398		let producer = Info { sequence: 0 }.produce();
1399		let mut writer = producer.clone();
1400		writer
1401			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1402			.unwrap();
1403
1404		// A stale consumer keeps the channel (and thus the cache) alive.
1405		let mut consumer = producer.consume();
1406		assert_eq!(producer.state.read().frames.len(), 1);
1407
1408		// Drop every producer without finishing: the cache is released.
1409		drop(writer);
1410		drop(producer);
1411
1412		let result = consumer.next_frame().now_or_never().unwrap();
1413		assert!(matches!(result, Err(crate::Error::Dropped)));
1414	}
1415
1416	#[test]
1417	fn drop_after_abort_does_not_warn() {
1418		let warns = count_drop_warnings("group::Producer dropped without finish", || {
1419			let producer = Info { sequence: 0 }.produce();
1420			let keep = producer.clone();
1421			let mut writer = producer.clone();
1422			writer
1423				.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1424				.unwrap();
1425			let _consumer = producer.consume();
1426			writer.abort(crate::Error::Cancel).unwrap();
1427			drop(keep);
1428		});
1429		assert_eq!(warns, 0, "abort-then-drop must not emit unfinished-producer WARN");
1430	}
1431
1432	#[test]
1433	fn drop_unfinished_warns() {
1434		let warns = count_drop_warnings("group::Producer dropped without finish", || {
1435			let producer = Info { sequence: 0 }.produce();
1436			let mut writer = producer.clone();
1437			writer
1438				.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1439				.unwrap();
1440			let _consumer = producer.consume();
1441			drop(writer);
1442			drop(producer);
1443		});
1444		assert!(warns >= 1, "unfinished drop must emit unfinished-producer WARN");
1445	}
1446
1447	#[test]
1448	fn drop_finished_keeps_cached_frames() {
1449		let mut producer = Info { sequence: 0 }.produce();
1450		producer
1451			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1452			.unwrap();
1453		producer.finish().unwrap();
1454
1455		let mut consumer = producer.consume();
1456		drop(producer);
1457
1458		// A cleanly finished group keeps its cache so the consumer can still drain.
1459		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1460		assert_eq!(frame.payload, Bytes::from_static(b"data"));
1461	}
1462
1463	#[tokio::test]
1464	async fn pending_then_ready() {
1465		let mut producer = Info { sequence: 0 }.produce();
1466		let mut consumer = producer.consume();
1467
1468		// Consumer blocks because no frames yet.
1469		assert!(consumer.next_frame().now_or_never().is_none());
1470
1471		producer
1472			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1473			.unwrap();
1474		producer.finish().unwrap();
1475
1476		let frame = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1477		assert_eq!(frame.size, 4);
1478	}
1479
1480	#[test]
1481	fn eviction_drops_old_frames() {
1482		let mut producer = Info { sequence: 0 }.produce();
1483
1484		// Write frames that total more than MAX_CACHE_BYTES.
1485		let big = Bytes::from(vec![0u8; MAX_CACHE_BYTES as usize]);
1486		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
1487		producer.write_frame(Timestamp::ZERO, big).unwrap();
1488
1489		// The first frame should have been evicted (tombstoned via offset).
1490		let state = producer.state.read();
1491		assert_eq!(state.offset, 1);
1492		assert_eq!(state.frames.len(), 1);
1493		assert_eq!(state.frames[0].payload.len(), MAX_CACHE_BYTES as usize);
1494	}
1495
1496	#[test]
1497	fn next_frame_returns_cache_full_on_tombstone() {
1498		let mut producer = Info { sequence: 0 }.produce();
1499
1500		let big = Bytes::from(vec![0u8; MAX_CACHE_BYTES as usize]);
1501		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
1502		producer.write_frame(Timestamp::ZERO, big).unwrap();
1503
1504		let mut consumer = producer.consume();
1505		// First frame was evicted, next_frame should return Lagged.
1506		let result = consumer.next_frame().now_or_never().unwrap();
1507		assert!(matches!(result, Err(crate::Error::Lagged)));
1508	}
1509
1510	#[test]
1511	fn no_eviction_under_budget() {
1512		let mut producer = Info { sequence: 0 }.produce();
1513		// Many small frames stay cached: there is no frame-count cap, only a byte budget.
1514		for _ in 0..100_000 {
1515			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1516		}
1517		producer.finish().unwrap();
1518
1519		let state = producer.state.read();
1520		assert_eq!(state.offset, 0);
1521		assert_eq!(state.frames.len(), 100_000);
1522	}
1523
1524	#[test]
1525	fn clone_consumer_independent() {
1526		let mut producer = Info { sequence: 0 }.produce();
1527		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1528
1529		let mut c1 = producer.consume();
1530		// Read one frame from c1
1531		let _ = c1.next_frame().now_or_never().unwrap().unwrap().unwrap();
1532
1533		// Clone c1, inheriting its index (past first frame).
1534		let mut c2 = c1.clone();
1535
1536		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1537		producer.finish().unwrap();
1538
1539		// c2 should get the second frame (inherited index)
1540		let f = c2.next_frame().now_or_never().unwrap().unwrap().unwrap();
1541		assert_eq!(f.size, 1); // "b"
1542
1543		let end = c2.next_frame().now_or_never().unwrap().unwrap();
1544		assert!(end.is_none());
1545	}
1546
1547	/// Refilling a buffer several times drains every frame in order across the batch
1548	/// boundary (each refill starts exactly where the previous batch ended).
1549	#[test]
1550	fn read_frames_crosses_batches() {
1551		const CAP: usize = 8;
1552		let n = CAP * 3 + 5;
1553		let mut producer = Info { sequence: 0 }.produce();
1554		for i in 0..n {
1555			producer
1556				.write_frame(Timestamp::ZERO, Bytes::from(vec![i as u8; 4]))
1557				.unwrap();
1558		}
1559		producer.finish().unwrap();
1560
1561		let mut consumer = producer.consume();
1562		let mut buf = frame::Buffer::<CAP>::new();
1563		let mut seen = 0;
1564		loop {
1565			let batch = consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap();
1566			if batch.is_empty() {
1567				break;
1568			}
1569			for frame in batch.iter() {
1570				assert_eq!(frame.payload, Bytes::from(vec![seen as u8; 4]));
1571				seen += 1;
1572			}
1573		}
1574		assert_eq!(seen, n);
1575		assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none());
1576	}
1577
1578	/// A finished group is still aborted once its frames are released to free memory (the
1579	/// track's latency window, or the cache pool). A reader that already drained every frame
1580	/// is missing nothing, so it must see the clean end of group rather than the abort.
1581	#[test]
1582	fn abort_after_finish_keeps_the_clean_end_for_a_drained_reader() {
1583		let mut producer = Info { sequence: 0 }.produce();
1584		producer
1585			.write_frame(Timestamp::ZERO, Bytes::from_static(b"hello"))
1586			.unwrap();
1587		producer.finish().unwrap();
1588
1589		let mut drained = producer.consume();
1590		let mut behind = producer.consume();
1591		let frame = drained.read_frame().now_or_never().unwrap().unwrap().unwrap();
1592		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
1593
1594		producer.abort(Error::Old).unwrap();
1595
1596		// Drained everything before the abort: nothing is missing.
1597		assert!(drained.read_frame().now_or_never().unwrap().unwrap().is_none());
1598		assert!(drained.next_frame().now_or_never().unwrap().unwrap().is_none());
1599
1600		// Never read the frame, and its bytes are gone: a truncated stream, not a clean end.
1601		assert!(matches!(behind.read_frame().now_or_never().unwrap(), Err(Error::Old)));
1602	}
1603
1604	/// The frame count is fixed at finish, so an abort that clears the cache can't turn a
1605	/// complete group into an error.
1606	#[test]
1607	fn finished_survives_a_later_abort() {
1608		let mut producer = Info { sequence: 0 }.produce();
1609		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1610		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1611		producer.finish().unwrap();
1612
1613		let mut consumer = producer.consume();
1614		producer.abort(Error::Old).unwrap();
1615
1616		assert_eq!(consumer.finished().now_or_never().unwrap().unwrap(), 2);
1617	}
1618
1619	/// `next_frame` picks up where a prior `read_frame` left off, preserving order.
1620	#[test]
1621	fn interleave_read_and_next_frame() {
1622		let mut producer = Info { sequence: 0 }.produce();
1623		for i in 0..5u8 {
1624			producer.write_frame(Timestamp::ZERO, Bytes::from(vec![i; 1])).unwrap();
1625		}
1626		producer.finish().unwrap();
1627
1628		let mut consumer = producer.consume();
1629		let f0 = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1630		assert_eq!(f0.payload, Bytes::from(vec![0u8; 1]));
1631
1632		// next_frame must continue from there, not skip ahead or repeat.
1633		for i in 1..5u8 {
1634			let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1635			let data = f.read_all().now_or_never().unwrap().unwrap();
1636			assert_eq!(data, Bytes::from(vec![i; 1]));
1637		}
1638		assert!(consumer.next_frame().now_or_never().unwrap().unwrap().is_none());
1639	}
1640
1641	/// A `read_frame` whose index sits past the buffered frames (cleared by an abort, or an
1642	/// eviction gap) must surface the error, not panic on an out-of-range `range(local..)`.
1643	#[test]
1644	fn read_frame_past_cleared_frames_does_not_panic() {
1645		let mut producer = Info { sequence: 0 }.produce();
1646		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1647		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1648
1649		let mut consumer = producer.consume();
1650		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1651		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1652
1653		// Abort clears the cached frames but leaves the consumer's index (2) past them, so the
1654		// refill's `local` (2) exceeds `frames.len()` (0).
1655		producer.abort(Error::Cancel).unwrap();
1656
1657		let result = consumer.read_frame().now_or_never().unwrap();
1658		assert!(matches!(result, Err(Error::Cancel)), "expected Cancel, got {result:?}");
1659	}
1660
1661	/// Dropping a filled buffer must drop its frames rather than leak them
1662	/// (exercises the `MaybeUninit` Drop path; run under miri to catch leaks/UB).
1663	#[test]
1664	fn drop_with_a_filled_buffer() {
1665		const CAP: usize = 8;
1666		let mut producer = Info { sequence: 0 }.produce();
1667		for _ in 0..CAP {
1668			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1669		}
1670		producer.finish().unwrap();
1671
1672		let mut consumer = producer.consume();
1673		let mut buf = frame::Buffer::<CAP>::new();
1674		// Fill the buffer, then drop it without taking anything out.
1675		assert_eq!(
1676			consumer.read_frames(&mut buf).now_or_never().unwrap().unwrap().len(),
1677			CAP
1678		);
1679		drop(buf);
1680	}
1681
1682	/// A parked chunk reader is woken by each chunk write. kio only notifies when
1683	/// a write guard was mutably accessed, so `frame_notify` must mark the guard
1684	/// modified; a guard dropped untouched wakes nobody and the reader would
1685	/// stall until the frame completed.
1686	#[tokio::test]
1687	async fn chunk_write_wakes_parked_reader() {
1688		let mut producer = Info { sequence: 0 }.produce();
1689		let mut consumer = producer.consume();
1690		let mut frame = producer
1691			.create_frame(frame::Info {
1692				size: 6,
1693				timestamp: Timestamp::ZERO,
1694			})
1695			.unwrap();
1696		let mut f = consumer.next_frame().await.unwrap().unwrap();
1697		let handle = tokio::spawn(async move { f.read_chunk().await });
1698		// Let the reader park on the empty partial before the chunk lands.
1699		tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1700		frame.write(Bytes::from_static(b"foo")).unwrap();
1701		let chunk = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
1702			.await
1703			.expect("parked chunk reader was never woken by the chunk write")
1704			.unwrap()
1705			.unwrap();
1706		assert_eq!(chunk, Some(Bytes::from_static(b"foo")));
1707	}
1708
1709	/// A frame whose timestamp is at a different scale is converted to the group's
1710	/// scale by `create_frame`.
1711	#[test]
1712	fn create_frame_converts_mismatched_scale() {
1713		use crate::{Timescale, Timestamp};
1714
1715		let mut producer = Producer::new(
1716			Info { sequence: 0 },
1717			track::Info::default().with_timescale(Timescale::MICRO),
1718			Default::default(),
1719		);
1720		let frame = frame::Info {
1721			size: 3,
1722			timestamp: Timestamp::from_millis(1).unwrap(), // 1ms -> 1000µs
1723		};
1724		let writer = producer.create_frame(frame).unwrap();
1725		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1726		assert_eq!(writer.timestamp.value(), 1000);
1727	}
1728
1729	/// An explicit current timestamp is converted to the group's scale.
1730	#[tokio::test]
1731	async fn create_frame_converts_current_timestamp() {
1732		use crate::Timescale;
1733
1734		let mut producer = Producer::new(
1735			Info { sequence: 0 },
1736			track::Info::default().with_timescale(Timescale::MICRO),
1737			Default::default(),
1738		);
1739		let writer = producer
1740			.create_frame(frame::Info {
1741				size: 3,
1742				timestamp: Timestamp::now(),
1743			})
1744			.unwrap();
1745		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1746		assert!(!writer.timestamp.is_zero(), "local clock should be non-zero");
1747	}
1748
1749	/// The per-frame size cap (the group byte budget) is enforced before allocating.
1750	#[test]
1751	fn create_frame_rejects_oversized() {
1752		let mut producer = Info { sequence: 0 }.produce();
1753		let result = producer.create_frame(frame::Info {
1754			size: MAX_CACHE_BYTES + 1,
1755			timestamp: Timestamp::ZERO,
1756		});
1757		assert!(matches!(result, Err(Error::FrameTooLarge)));
1758	}
1759}