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.
26pub(super) const 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(), Default::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	// Mirrors `cache` into the track's shared cache pool, so the group's bytes count
105	// against the byte budget tracks evict toward.
106	charge: cache::Charge,
107
108	// Once finalized, the total number of frames the group will ever contain. Recorded
109	// at finish so the count outlives an abort that clears the cache.
110	pub(crate) fin: Option<usize>,
111
112	// The error that caused the group to be aborted, if any.
113	pub(crate) abort: Option<Error>,
114}
115
116impl GroupState {
117	/// Resolve the source for the frame at `index`: a completed frame (whole) or the
118	/// in-flight tail (streamed). Used by [`Consumer::poll_next_frame`].
119	fn poll_frame_source(&self, index: usize) -> Poll<Result<Option<(frame::Info, frame::Source)>>> {
120		if index < self.offset {
121			return Poll::Ready(Err(Error::Lagged));
122		}
123		let local = index - self.offset;
124		if let Some(f) = self.frames.get(local) {
125			// A frame read is a cache access: stamp it so expiry and the eviction
126			// walk spare a group a consumer is actively draining.
127			self.charge.refresh();
128			let info = frame::Info {
129				size: f.payload.len() as u64,
130				timestamp: f.timestamp,
131			};
132			return Poll::Ready(Ok(Some((info, frame::Source::Complete(f.payload.clone())))));
133		}
134		if local == self.frames.len()
135			&& let Some(p) = &self.partial
136		{
137			self.charge.refresh();
138			let info = frame::Info {
139				size: p.buf.capacity() as u64,
140				timestamp: p.timestamp,
141			};
142			return Poll::Ready(Ok(Some((info, frame::Source::Partial(p.buf.clone())))));
143		}
144		ready!(self.poll_terminal(index))?;
145		Poll::Ready(Ok(None))
146	}
147
148	/// Resolve the group's terminal state for a reader positioned at `index`.
149	///
150	/// A finished group is still aborted once its frames are released to free memory
151	/// (aged out of the track's latency window, or evicted by the cache pool). A reader
152	/// that already consumed every frame is missing nothing, so it gets the clean end of
153	/// group; one that fell short sees the abort rather than a silently truncated stream.
154	fn poll_terminal(&self, index: usize) -> Poll<Result<()>> {
155		match (self.fin, &self.abort) {
156			(Some(total), Some(err)) if index < total => Poll::Ready(Err(err.clone())),
157			(Some(_), _) => Poll::Ready(Ok(())),
158			(None, Some(err)) => Poll::Ready(Err(err.clone())),
159			(None, None) => Poll::Pending,
160		}
161	}
162
163	fn poll_finished(&self) -> Poll<Result<u64>> {
164		// The count is recorded at finish, so a later abort that cleared the cache
165		// doesn't turn a complete group into an error.
166		if let Some(total) = self.fin {
167			Poll::Ready(Ok(total as u64))
168		} else if let Some(err) = &self.abort {
169			Poll::Ready(Err(err.clone()))
170		} else {
171			Poll::Pending
172		}
173	}
174
175	/// Evict completed frames from the front until within the byte budget.
176	fn evict(&mut self) {
177		while self.cache > MAX_GROUP_CACHE {
178			let Some(frame) = self.frames.pop_front() else {
179				break;
180			};
181			let size = frame.payload.len() as u64;
182			self.cache -= size;
183			self.charge.sub(size);
184			self.offset += 1;
185		}
186	}
187
188	/// Drop the cached frames (and any in-flight tail) and release their pool charge.
189	fn release(&mut self) {
190		self.frames.clear();
191		self.partial = None;
192		self.cache = 0;
193		self.charge.clear();
194	}
195}
196
197fn modify(state: &kio::Producer<GroupState>) -> Result<kio::Mut<'_, GroupState>> {
198	state.write().map_err(|r| r.abort.clone().unwrap_or(Error::Dropped))
199}
200
201/// Writes frames to a group in order.
202///
203/// Each group is delivered independently over a QUIC stream.
204/// Use [Self::write_frame] for simple single-buffer frames,
205/// or [Self::create_frame] for multi-chunk streaming writes.
206pub struct Producer {
207	// Mutable stream state.
208	state: kio::Producer<GroupState>,
209
210	// The group header containing the sequence number. A small `Copy` value,
211	// inherited by each frame (see [`Self::create_frame`]).
212	info: Info,
213
214	// The parent track's properties, inherited rather than passed piecemeal. Its
215	// `timescale` is used by [`Self::create_frame`] to normalize every frame's
216	// timestamp into the track scale before it enters the stream. Threaded down by
217	// value from [`track::Producer::create_group`] / `append_group`.
218	track: track::Info,
219
220	// The parent track's account against the shared cache pool. Held here as well as
221	// in the group's `cache::Charge` so a frame write can settle the track's eviction
222	// debt with the group lock released.
223	cache: Arc<cache::Track>,
224
225	// Ingress payload meter, set by a tagged [`track::Producer`] via
226	// [`Self::with_meter`]. Empty (no-op) for an untagged group.
227	stats: stats::Meter,
228
229	// Shared by every clone: its `Drop` is the abrupt-teardown, running exactly once
230	// when the last of them goes.
231	alive: Arc<Alive>,
232}
233
234/// Ends the group when the last [`Producer`] clone drops, including the clone the
235/// parent track holds in its cache.
236///
237/// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is
238/// a snapshot, and acting on it is exactly what can invalidate it. Holding a producer
239/// of its own also keeps the state writable until the teardown has run, whatever order
240/// the last owner's fields drop in.
241struct Alive {
242	info: Info,
243	state: kio::Producer<GroupState>,
244}
245
246impl Drop for Alive {
247	fn drop(&mut self) {
248		// See track::Alive: the last producer dropping without a clean finish releases
249		// the cached frames so a stale consumer can't pin their buffers forever. A
250		// finished group keeps its cache so consumers can drain.
251		//
252		// Check Ok and Err: Ok is unreachable after a deliberate close.
253		match self.state.write() {
254			Ok(mut state) => {
255				if state.fin.is_some() || state.abort.is_some() {
256					return;
257				}
258				tracing::warn!(
259					sequence = self.info.sequence,
260					"group::Producer dropped without finish() or abort()"
261				);
262				state.release();
263			}
264			Err(state) => {
265				if state.fin.is_some() || state.abort.is_some() {
266					return;
267				}
268				tracing::warn!(
269					sequence = self.info.sequence,
270					"group::Producer dropped without finish() or abort()"
271				);
272			}
273		}
274	}
275}
276
277impl std::ops::Deref for Producer {
278	type Target = Info;
279
280	fn deref(&self) -> &Self::Target {
281		&self.info
282	}
283}
284
285impl Producer {
286	/// Create a group producer bound to its parent track's [`track::Info`] and cache
287	/// account.
288	///
289	/// Crate-private: groups are only constructed via [`track::Producer`], which
290	/// threads both down so properties like the timescale are inherited rather than
291	/// passed in. Every frame added to this group is normalized to the track's
292	/// timescale by [`Self::create_frame`].
293	///
294	/// Charges the group into `cache`, so its cached bytes count against the budget the
295	/// track evicts toward under memory pressure.
296	pub(crate) fn new(info: Info, track: track::Info, cache: Arc<cache::Track>) -> Self {
297		let state = kio::Producer::<GroupState>::default();
298		state.write().ok().expect("a new group is open").charge = cache.charge();
299		let alive = Arc::new(Alive {
300			info,
301			state: state.clone(),
302		});
303		Self {
304			info,
305			state,
306			track,
307			cache,
308			stats: stats::Meter::default(),
309			alive,
310		}
311	}
312
313	/// Attach an ingress payload meter, counting this as one delivered group.
314	/// Called by a tagged [`track::Producer`] when it creates the group.
315	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
316		meter.group();
317		self.stats = meter;
318		self
319	}
320
321	/// The group header.
322	pub(crate) fn info(&self) -> Info {
323		self.info
324	}
325
326	/// The parent track's timescale.
327	pub fn timescale(&self) -> Timescale {
328		self.track.timescale
329	}
330
331	/// A helper method to write a frame from a single byte buffer.
332	///
333	/// If you want to write multiple chunks, use [Self::create_frame] to get a frame producer.
334	/// But an upfront size is required.
335	///
336	/// `timestamp` is converted into the parent track's timescale. For data without
337	/// a presentation time, pass [`Timestamp::now`] explicitly.
338	pub fn write_frame<B: IntoBytes>(&mut self, timestamp: Timestamp, data: B) -> Result<()> {
339		let timestamp = timestamp
340			.convert(self.track.timescale)
341			.map_err(|_| Error::TimestampMismatch)?;
342		let payload = data.into_bytes();
343		if payload.len() as u64 > MAX_GROUP_CACHE {
344			return Err(Error::FrameTooLarge);
345		}
346
347		let mut state = modify(&self.state)?;
348		if state.fin.is_some() {
349			return Err(Error::Closed);
350		}
351		debug_assert!(state.partial.is_none(), "a frame is already open");
352		let size = payload.len() as u64;
353		state.cache += size;
354		state.charge.add(size);
355		state.frames.push_back(Frame { timestamp, payload });
356		state.evict();
357		drop(state);
358
359		// With the group lock released (lock order is track then group), settle
360		// eviction debt if enough has been written since the track last paid.
361		self.cache.settle();
362
363		// Ingress payload: one whole frame written.
364		self.stats.frames(1);
365		self.stats.bytes(size);
366		Ok(())
367	}
368
369	/// Create a frame with an upfront size and presentation timestamp, streamed in
370	/// chunks. Borrows the group exclusively until the returned [`frame::Producer`]
371	/// is finished or dropped, so only one frame is open at a time.
372	///
373	/// The `timestamp` is converted into the parent track's timescale, so the scale you
374	/// build it with doesn't have to match the track. Returns [`Error::FrameTooLarge`]
375	/// if the declared size exceeds the group's byte budget (refused before allocating)
376	/// or [`Error::TimestampMismatch`] if the timestamp can't be converted (overflow).
377	pub fn create_frame(&mut self, frame: frame::Info) -> Result<frame::Producer<'_>> {
378		let timestamp = frame
379			.timestamp
380			.convert(self.track.timescale)
381			.map_err(|_| Error::TimestampMismatch)?;
382		if frame.size > MAX_GROUP_CACHE {
383			return Err(Error::FrameTooLarge);
384		}
385		let buf = FrameBuf::new(frame.size as usize);
386
387		let mut state = modify(&self.state)?;
388		if state.fin.is_some() {
389			return Err(Error::Closed);
390		}
391		debug_assert!(state.partial.is_none(), "a frame is already open");
392		state.cache += frame.size;
393		state.charge.add(frame.size);
394		state.partial = Some(Partial {
395			timestamp,
396			buf: buf.clone(),
397		});
398		state.evict();
399		drop(state);
400
401		// With the group lock released (lock order is track then group), settle
402		// eviction debt if enough has been written since the track last paid.
403		self.cache.settle();
404
405		// Ingress payload: one frame opened; its bytes are counted per chunk as the
406		// frame::Producer writes them.
407		self.stats.frames(1);
408		let meter = self.stats.clone();
409
410		let info = frame::Info {
411			size: frame.size,
412			timestamp,
413		};
414		Ok(frame::Producer::new(self, buf, info).with_meter(meter))
415	}
416
417	/// Wake consumers parked on the group channel (called after a partial write).
418	pub(crate) fn frame_notify(&self) {
419		// The chunk that was just written is a write access: restart the retention
420		// clock so a straggler group streaming a large frame isn't expired
421		// mid-write (its bytes were already charged when the frame was created).
422		// `record_write` takes `&mut`, which marks the guard modified: kio only
423		// notifies on a mutably-accessed guard's release, and that notify is what
424		// delivers the chunk to parked readers.
425		if let Ok(mut state) = self.state.write() {
426			state.charge.record_write();
427		}
428	}
429
430	/// Commit the in-flight frame as a completed frame (called by [`frame::Producer::finish`]).
431	pub(crate) fn frame_commit(&mut self, frame: Frame) -> Result<()> {
432		let mut state = modify(&self.state)?;
433		// Bytes were already counted against the cache (and the pool charge) when the
434		// frame was created; committing just moves the tail into the completed set.
435		state.partial = None;
436		state.frames.push_back(frame);
437		Ok(())
438	}
439
440	/// Fail the group because an in-flight frame couldn't complete (called by
441	/// [`frame::Producer::abort`] / its drop).
442	pub(crate) fn frame_abort(&mut self, err: Error) {
443		let _ = self.clone().abort(err);
444	}
445
446	/// Return the number of frames written so far (completed plus any in-flight).
447	pub fn frame_count(&self) -> usize {
448		let state = self.state.read();
449		state.offset + state.frames.len() + state.partial.is_some() as usize
450	}
451
452	/// Mark the group as complete; no more frames will be written.
453	///
454	/// Borrows rather than consumes, so a later failure can still be reported through
455	/// [`abort`](Self::abort). The handle also keeps the cached frames readable.
456	pub fn finish(&mut self) -> Result<()> {
457		let mut state = modify(&self.state)?;
458		state.fin = Some(state.offset + state.frames.len());
459		Ok(())
460	}
461
462	/// Abort the group with the given error.
463	///
464	/// Consumes the handle. Drops the cached frames so a stale [`Consumer`] can't pin
465	/// their buffers in memory forever; consumers that haven't drained yet surface the
466	/// abort error instead of the leftover cache.
467	pub fn abort(self, err: Error) -> Result<()> {
468		let mut guard = modify(&self.state)?;
469		guard.abort = Some(err);
470		guard.release();
471		guard.close();
472		Ok(())
473	}
474
475	/// Whether the group has been aborted (including pool eviction). The track's
476	/// read paths treat an aborted cached group as absent.
477	pub(crate) fn is_aborted(&self) -> bool {
478		self.state.read().abort.is_some()
479	}
480
481	/// The group's full cached footprint (payload plus fixed overhead), used by the
482	/// track to size this group as an eviction victim.
483	pub(crate) fn cache_size(&self) -> u64 {
484		self.state.read().charge.size()
485	}
486
487	/// Tick of the group's last cache access, driving eviction protection and age
488	/// expiry (see [`cache::Pool::average`]).
489	pub(crate) fn cache_accessed(&self) -> u64 {
490		self.state.read().charge.accessed()
491	}
492
493	/// Enter the group into the evictable population: demoted from the live edge,
494	/// or inserted behind it. Idempotent; a no-op once the group is closed.
495	pub(crate) fn cache_demote(&self) {
496		if let Ok(mut state) = self.state.write() {
497			state.charge.demote();
498		}
499	}
500
501	/// Record a cache access (delivery to a subscriber, a FETCH hit, or a fetched
502	/// backfill's birth), protecting the group from eviction and restarting its
503	/// expiry clock. Stamps through a read guard, whose release never notifies, so
504	/// delivery can't wake every consumer parked on the group. Harmless on a
505	/// closed group: its charge is already cleared.
506	pub(crate) fn cache_refresh(&self) {
507		self.state.read().charge.refresh();
508	}
509
510	/// Create a new consumer for the group.
511	pub fn consume(&self) -> Consumer {
512		Consumer {
513			info: self.info,
514			state: self.state.consume(),
515			track: self.track.clone(),
516			index: 0,
517			prefetch: Prefetch::default(),
518			last_refresh: web_async::time::Instant::now(),
519			// Untagged: a tagged track attaches the egress meter via `with_meter`
520			// when it hands the consumer to a subscriber/fetch.
521			stats: stats::Meter::default(),
522		}
523	}
524
525	/// Block until the group is closed or aborted.
526	pub async fn closed(&self) -> Error {
527		kio::wait(|waiter| self.poll_closed(waiter)).await
528	}
529
530	/// Poll until the group is closed or aborted; ready with the cause.
531	pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<Error> {
532		self.state.poll_closed(waiter).map(|()| self.abort_reason())
533	}
534
535	/// Block until there are no active consumers.
536	pub async fn unused(&self) -> Result<()> {
537		self.state.unused().await.map_err(|_| self.abort_reason())
538	}
539
540	/// The recorded abort reason, or [`Error::Dropped`] if the group closed without one.
541	fn abort_reason(&self) -> Error {
542		self.state.read().abort.clone().unwrap_or(Error::Dropped)
543	}
544}
545
546impl Clone for Producer {
547	fn clone(&self) -> Self {
548		Self {
549			info: self.info,
550			state: self.state.clone(),
551			track: self.track.clone(),
552			cache: self.cache.clone(),
553			stats: self.stats.clone(),
554			alive: self.alive.clone(),
555		}
556	}
557}
558
559/// A small inline batch of completed frames, drained from the shared group state
560/// under one lock and then handed out without re-locking.
561///
562/// Each [`Consumer::read_frame`] otherwise takes the group mutex and allocates a
563/// waker just to clone one `Bytes`; draining a batch amortizes both across `CAP`
564/// frames. Storage is inline and uninitialized (no heap), so a consumer that never
565/// reads whole frames, or drains through a higher-level buffer, pays nothing.
566struct Prefetch {
567	// Initialized, not-yet-taken frames are `frames[pos..len]`; the rest are uninitialized.
568	frames: [MaybeUninit<Frame>; Self::CAP],
569	pos: usize,
570	len: usize,
571}
572
573impl Prefetch {
574	const CAP: usize = 8;
575
576	/// Take the next buffered frame, or `None` if the batch is drained.
577	fn pop(&mut self) -> Option<Frame> {
578		if self.pos == self.len {
579			return None;
580		}
581		// SAFETY: `pos < len`, so this slot was written by `fill` and not yet taken.
582		let frame = unsafe { self.frames[self.pos].assume_init_read() };
583		self.pos += 1;
584		Some(frame)
585	}
586
587	/// Refill with up to `CAP` frames. Must be drained first (`pop` returned `None`).
588	fn fill(&mut self, frames: impl Iterator<Item = Frame>) {
589		debug_assert_eq!(self.pos, self.len, "fill on a non-empty batch would leak frames");
590		self.pos = 0;
591		self.len = 0;
592		for frame in frames.take(Self::CAP) {
593			self.frames[self.len].write(frame);
594			self.len += 1;
595		}
596	}
597
598	/// `(frame count, total payload bytes)` of the buffered, not-yet-taken frames.
599	/// Read once per fill to bump the egress payload counters for the whole batch.
600	fn buffered(&self) -> (u64, u64) {
601		let mut bytes = 0u64;
602		for slot in &self.frames[self.pos..self.len] {
603			// SAFETY: slots in `pos..len` are initialized (written by `fill`, not yet popped).
604			bytes += unsafe { slot.assume_init_ref() }.payload.len() as u64;
605		}
606		((self.len - self.pos) as u64, bytes)
607	}
608}
609
610impl Default for Prefetch {
611	fn default() -> Self {
612		Self {
613			frames: [const { MaybeUninit::uninit() }; Self::CAP],
614			pos: 0,
615			len: 0,
616		}
617	}
618}
619
620impl Drop for Prefetch {
621	fn drop(&mut self) {
622		for slot in &mut self.frames[self.pos..self.len] {
623			// SAFETY: slots in `pos..len` are initialized and were never taken.
624			unsafe { slot.assume_init_drop() };
625		}
626	}
627}
628
629/// Consume a group, frame-by-frame.
630pub struct Consumer {
631	// Shared state with the producer.
632	state: kio::Consumer<GroupState>,
633
634	// Immutable stream state.
635	info: Info,
636
637	// The parent track's info, inherited from the producer. Its `timescale` lets the
638	// wire publisher emit per-frame timestamps at the right scale for a fetched group.
639	track: track::Info,
640
641	// The number of frames we've read.
642	// NOTE: Cloned readers inherit this offset, but then run in parallel.
643	index: usize,
644
645	// A batch of completed frames drained ahead under one lock (whole-frame reads only).
646	prefetch: Prefetch,
647
648	// When this consumer last stamped the group's access time. The prefetch bounds
649	// a batch by frame count, not elapsed time, so pops re-stamp on a time bound
650	// (see [`Self::refresh_if_stale`]) or a slow reader could go a full retention
651	// window without an access and be expired mid-read.
652	last_refresh: web_async::time::Instant,
653
654	// Egress payload meter, set by a tagged track via [`Self::with_meter`]. Empty
655	// (no-op) for an untagged group.
656	stats: stats::Meter,
657}
658
659impl Clone for Consumer {
660	fn clone(&self) -> Self {
661		// A clone shares the channel and inherits `index`, but starts with an empty
662		// prefetch: it re-reads its batch from the shared state, in parallel.
663		Self {
664			state: self.state.clone(),
665			info: self.info,
666			track: self.track.clone(),
667			index: self.index,
668			prefetch: Prefetch::default(),
669			last_refresh: self.last_refresh,
670			// Inherit the meter without re-counting the group: the original already
671			// counted it when the track handed it out.
672			stats: self.stats.clone(),
673		}
674	}
675}
676
677impl std::ops::Deref for Consumer {
678	type Target = Info;
679
680	fn deref(&self) -> &Self::Target {
681		&self.info
682	}
683}
684
685impl Consumer {
686	/// Attach an egress payload meter, counting this as one delivered group.
687	/// Called by a tagged track when it hands the consumer to a subscriber or fetch.
688	pub(crate) fn with_meter(mut self, meter: stats::Meter) -> Self {
689		meter.group();
690		self.stats = meter;
691		self
692	}
693
694	/// Whether the group has been aborted (including pool eviction); the abort
695	/// dropped the cached frames, so a held consumer has nothing left to read.
696	pub(crate) fn is_aborted(&self) -> bool {
697		self.state.read().abort.is_some()
698	}
699
700	/// Record a cache access from the consumer side: a parked group re-offered to
701	/// its subscriber. Same stamp as [`Producer::cache_refresh`].
702	pub(crate) fn cache_refresh(&self) {
703		self.state.read().charge.refresh();
704	}
705
706	/// Park `waiter` until the group closes (finish, abort, or eviction). Spliced
707	/// subscribers register on parked groups so an eviction wakes them; a group
708	/// that already closed cleanly can never abort, so no waiter is needed.
709	pub(crate) fn poll_closed(&self, waiter: &kio::Waiter) -> Poll<()> {
710		self.state.poll_closed(waiter)
711	}
712
713	/// The parent track's timescale.
714	pub fn timescale(&self) -> Timescale {
715		self.track.timescale
716	}
717
718	/// Re-stamp the group's access time from the lock-free prefetch path once half
719	/// the retention window has passed since this consumer last stamped it. The
720	/// batch bounds frames, not elapsed time, so without this a reader pacing
721	/// through a batch could be expired while demonstrably active. Half the window
722	/// keeps the stamp comfortably inside it while staying rare on the hot path.
723	fn refresh_if_stale(&mut self) {
724		if self.last_refresh.elapsed() * 2 < self.track.latency_max {
725			return;
726		}
727		self.state.read().charge.refresh();
728		self.last_refresh = web_async::time::Instant::now();
729	}
730
731	// A helper to automatically apply Dropped if the state is closed without an error.
732	fn poll<F, R>(&self, waiter: &kio::Waiter, f: F) -> Poll<Result<R>>
733	where
734		F: Fn(&kio::Ref<'_, GroupState>) -> Poll<Result<R>>,
735	{
736		Poll::Ready(match ready!(self.state.poll(waiter, f)) {
737			Ok(res) => res,
738			// We try to clone abort just in case the function forgot to check for terminal state.
739			Err(state) => Err(state.abort.clone().unwrap_or(Error::Dropped)),
740		})
741	}
742
743	/// Return a consumer for the next frame for chunked reading.
744	pub async fn next_frame(&mut self) -> Result<Option<frame::Consumer>> {
745		kio::wait(|waiter| self.poll_next_frame(waiter)).await
746	}
747
748	/// Poll for the next frame, without blocking.
749	///
750	/// Returns None if the group is finished and the index is out of range.
751	pub fn poll_next_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Consumer>>> {
752		// Hand out any frames a prior read_frame prefetched before touching the tail.
753		// Their bytes were already counted at the batch fill, so the frame::Consumer
754		// carries no meter.
755		if let Some(frame) = self.prefetch.pop() {
756			self.refresh_if_stale();
757			self.index += 1;
758			let info = frame::Info {
759				size: frame.payload.len() as u64,
760				timestamp: frame.timestamp,
761			};
762			let source = frame::Source::Complete(frame.payload);
763			return Poll::Ready(Ok(Some(frame::Consumer::new(self.state.clone(), info, source))));
764		}
765
766		let index = self.index;
767		let Some((info, source)) = ready!(self.poll(waiter, |state| state.poll_frame_source(index))?) else {
768			return Poll::Ready(Ok(None));
769		};
770
771		self.index += 1;
772		// A direct read (not prefetched): count the frame here; the frame::Consumer
773		// counts its bytes per chunk as they're read out.
774		self.stats.frames(1);
775		Poll::Ready(Ok(Some(
776			frame::Consumer::new(self.state.clone(), info, source).with_meter(self.stats.clone()),
777		)))
778	}
779
780	/// Read the next frame (timestamp and payload) all at once, without blocking.
781	pub fn poll_read_frame(&mut self, waiter: &kio::Waiter) -> Poll<Result<Option<frame::Frame>>> {
782		// Fast path: serve from the prefetched batch without locking or allocating a waker.
783		if let Some(frame) = self.prefetch.pop() {
784			self.refresh_if_stale();
785			self.index += 1;
786			return Poll::Ready(Ok(Some(frame)));
787		}
788
789		// The batch is drained: refill it under a single lock, registering the waiter if
790		// nothing is ready. Borrow the two fields disjointly so the closure can fill.
791		let index = self.index;
792		let prefetch = &mut self.prefetch;
793		let res = self.state.poll(waiter, |state| {
794			if index < state.offset {
795				return Poll::Ready(Err(Error::Lagged));
796			}
797			// `local` can run past the buffered count when frames were cleared or evicted out
798			// from under us (abort, unfinished drop, an eviction gap); clamp so `range` never
799			// panics on an out-of-bounds start. `fill` always resets the batch, so an empty
800			// range leaves `len == 0` and the terminal checks below resolve abort/fin/pending.
801			let local = (index - state.offset).min(state.frames.len());
802			prefetch.fill(state.frames.range(local..).cloned());
803			if prefetch.len > 0 {
804				// One stamp covers the whole batch: frames popped from the prefetch
805				// don't re-stamp until the next refill, which `CAP` bounds.
806				state.charge.refresh();
807				return Poll::Ready(Ok(()));
808			}
809			// Nothing completed at `index`: an in-flight tail waits, otherwise resolve
810			// the terminal state (whole-frame reads never stream the partial).
811			state.poll_terminal(index)
812		});
813
814		match ready!(res) {
815			Ok(Ok(())) => {}
816			Ok(Err(err)) => return Poll::Ready(Err(err)),
817			Err(state) => return Poll::Ready(Err(state.abort.clone().unwrap_or(Error::Dropped))),
818		}
819
820		// The refill stamped the group under its lock; restart the staleness clock
821		// so the pops that follow don't immediately re-stamp.
822		self.last_refresh = web_async::time::Instant::now();
823
824		// A fresh batch was just filled (empty only on a clean end). Count the whole
825		// batch once here, under no lock, so the drained pops that follow stay free.
826		let (frames, bytes) = self.prefetch.buffered();
827		self.stats.frames(frames);
828		self.stats.bytes(bytes);
829
830		Poll::Ready(Ok(self.prefetch.pop().inspect(|_| {
831			self.index += 1;
832		})))
833	}
834
835	/// Read the next frame (timestamp and payload) all at once.
836	pub async fn read_frame(&mut self) -> Result<Option<frame::Frame>> {
837		// Serve from the prefetched batch without building a future or allocating a waker.
838		if let Some(frame) = self.prefetch.pop() {
839			self.refresh_if_stale();
840			self.index += 1;
841			return Ok(Some(frame));
842		}
843		kio::wait(|waiter| self.poll_read_frame(waiter)).await
844	}
845
846	/// Poll for the final number of frames in the group.
847	pub fn poll_finished(&mut self, waiter: &kio::Waiter) -> Poll<Result<u64>> {
848		self.poll(waiter, |state| state.poll_finished())
849	}
850
851	/// Block until the group is finished, returning the number of frames in the group.
852	pub async fn finished(&mut self) -> Result<u64> {
853		kio::wait(|waiter| self.poll_finished(waiter)).await
854	}
855}
856
857/// Options for a one-shot [`track::Consumer::fetch_group`] of a past group.
858#[derive(Clone, Debug, Default)]
859#[non_exhaustive]
860pub struct Fetch {
861	/// Delivery priority for the fetched group's stream. Defaults to 0.
862	pub priority: u8,
863}
864
865impl Fetch {
866	/// Set the delivery priority, returning `self` for chaining.
867	pub fn with_priority(mut self, priority: u8) -> Self {
868		self.priority = priority;
869		self
870	}
871}
872
873#[cfg(test)]
874mod test {
875	use super::*;
876	use crate::model::test_tracing::count_drop_warnings;
877	use bytes::Bytes;
878	use futures::FutureExt;
879
880	#[test]
881	fn basic_frame_reading() {
882		let mut producer = Info { sequence: 0 }.produce();
883		producer
884			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame0"))
885			.unwrap();
886		producer
887			.write_frame(Timestamp::ZERO, Bytes::from_static(b"frame1"))
888			.unwrap();
889		producer.finish().unwrap();
890
891		let mut consumer = producer.consume();
892		let f0 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
893		assert_eq!(f0.size, 6);
894		let f1 = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
895		assert_eq!(f1.size, 6);
896		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
897		assert!(end.is_none());
898	}
899
900	#[test]
901	fn read_frame_all_at_once() {
902		let mut producer = Info { sequence: 0 }.produce();
903		producer
904			.write_frame(Timestamp::ZERO, Bytes::from_static(b"hello"))
905			.unwrap();
906		producer.finish().unwrap();
907
908		let mut consumer = producer.consume();
909		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
910		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
911	}
912
913	#[test]
914	fn read_frame_preserves_timestamp() {
915		let mut producer = Info { sequence: 0 }.produce();
916		let timestamp = Timestamp::from_micros(20_000).unwrap();
917		producer.write_frame(timestamp, Bytes::from_static(b"hello")).unwrap();
918		producer.finish().unwrap();
919
920		let mut consumer = producer.consume();
921		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
922		assert_eq!(frame.timestamp.as_micros(), 20_000);
923		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
924	}
925
926	#[test]
927	fn chunked_frame_reads_whole() {
928		let mut producer = Info { sequence: 0 }.produce();
929		{
930			let mut frame = producer
931				.create_frame(frame::Info {
932					size: 10,
933					timestamp: Timestamp::ZERO,
934				})
935				.unwrap();
936			frame.write(Bytes::from_static(b"hello")).unwrap();
937			frame.write(Bytes::from_static(b"world")).unwrap();
938			frame.finish().unwrap();
939		}
940		producer.finish().unwrap();
941
942		// Frame data is held in a single per-frame buffer; a whole-frame read returns
943		// the full contents in one slice.
944		let mut consumer = producer.consume();
945		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
946		assert_eq!(frame.payload, Bytes::from_static(b"helloworld"));
947	}
948
949	#[test]
950	fn chunked_frame_streams_partial() {
951		let mut producer = Info { sequence: 0 }.produce();
952		let mut consumer = producer.consume();
953
954		let mut frame = producer
955			.create_frame(frame::Info {
956				size: 6,
957				timestamp: Timestamp::ZERO,
958			})
959			.unwrap();
960		frame.write(Bytes::from_static(b"foo")).unwrap();
961
962		// A consumer can stream the in-flight tail before it's finished.
963		let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
964		let c1 = f.read_chunk().now_or_never().unwrap().unwrap();
965		assert_eq!(c1, Some(Bytes::from_static(b"foo")));
966		assert!(f.read_chunk().now_or_never().is_none());
967
968		frame.write(Bytes::from_static(b"bar")).unwrap();
969		frame.finish().unwrap();
970
971		let c2 = f.read_chunk().now_or_never().unwrap().unwrap();
972		assert_eq!(c2, Some(Bytes::from_static(b"bar")));
973		let c3 = f.read_chunk().now_or_never().unwrap().unwrap();
974		assert_eq!(c3, None);
975	}
976
977	#[test]
978	fn group_finish_returns_none() {
979		let mut producer = Info { sequence: 0 }.produce();
980		producer.finish().unwrap();
981
982		let mut consumer = producer.consume();
983		let end = consumer.next_frame().now_or_never().unwrap().unwrap();
984		assert!(end.is_none());
985	}
986
987	#[test]
988	fn abort_propagates() {
989		let producer = Info { sequence: 0 }.produce();
990		let mut consumer = producer.consume();
991		producer.abort(crate::Error::Cancel).unwrap();
992
993		let result = consumer.next_frame().now_or_never().unwrap();
994		assert!(matches!(result, Err(crate::Error::Cancel)));
995	}
996
997	#[test]
998	fn abort_clears_cached_frames() {
999		let mut producer = Info { sequence: 0 }.produce();
1000		producer
1001			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1002			.unwrap();
1003
1004		// A stale consumer that never reads must not pin the cached frames.
1005		let _consumer = producer.consume();
1006		assert_eq!(producer.state.read().frames.len(), 1);
1007
1008		producer.clone().abort(crate::Error::Cancel).unwrap();
1009
1010		let state = producer.state.read();
1011		assert!(state.frames.is_empty(), "cached frames should be dropped on abort");
1012		assert_eq!(state.cache, 0);
1013	}
1014
1015	#[test]
1016	fn drop_unfinished_clears_cached_frames() {
1017		let producer = Info { sequence: 0 }.produce();
1018		let mut writer = producer.clone();
1019		writer
1020			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1021			.unwrap();
1022
1023		// A stale consumer keeps the channel (and thus the cache) alive.
1024		let mut consumer = producer.consume();
1025		assert_eq!(producer.state.read().frames.len(), 1);
1026
1027		// Drop every producer without finishing: the cache is released.
1028		drop(writer);
1029		drop(producer);
1030
1031		let result = consumer.next_frame().now_or_never().unwrap();
1032		assert!(matches!(result, Err(crate::Error::Dropped)));
1033	}
1034
1035	#[test]
1036	fn drop_after_abort_does_not_warn() {
1037		let warns = count_drop_warnings("group::Producer dropped without finish", || {
1038			let producer = Info { sequence: 0 }.produce();
1039			let keep = producer.clone();
1040			let mut writer = producer.clone();
1041			writer
1042				.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1043				.unwrap();
1044			let _consumer = producer.consume();
1045			writer.abort(crate::Error::Cancel).unwrap();
1046			drop(keep);
1047		});
1048		assert_eq!(warns, 0, "abort-then-drop must not emit unfinished-producer WARN");
1049	}
1050
1051	#[test]
1052	fn drop_unfinished_warns() {
1053		let warns = count_drop_warnings("group::Producer dropped without finish", || {
1054			let producer = Info { sequence: 0 }.produce();
1055			let mut writer = producer.clone();
1056			writer
1057				.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1058				.unwrap();
1059			let _consumer = producer.consume();
1060			drop(writer);
1061			drop(producer);
1062		});
1063		assert!(warns >= 1, "unfinished drop must emit unfinished-producer WARN");
1064	}
1065
1066	#[test]
1067	fn drop_finished_keeps_cached_frames() {
1068		let mut producer = Info { sequence: 0 }.produce();
1069		producer
1070			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1071			.unwrap();
1072		producer.finish().unwrap();
1073
1074		let mut consumer = producer.consume();
1075		drop(producer);
1076
1077		// A cleanly finished group keeps its cache so the consumer can still drain.
1078		let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1079		assert_eq!(frame.payload, Bytes::from_static(b"data"));
1080	}
1081
1082	#[tokio::test]
1083	async fn pending_then_ready() {
1084		let mut producer = Info { sequence: 0 }.produce();
1085		let mut consumer = producer.consume();
1086
1087		// Consumer blocks because no frames yet.
1088		assert!(consumer.next_frame().now_or_never().is_none());
1089
1090		producer
1091			.write_frame(Timestamp::ZERO, Bytes::from_static(b"data"))
1092			.unwrap();
1093		producer.finish().unwrap();
1094
1095		let frame = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1096		assert_eq!(frame.size, 4);
1097	}
1098
1099	#[test]
1100	fn eviction_drops_old_frames() {
1101		let mut producer = Info { sequence: 0 }.produce();
1102
1103		// Write frames that total more than MAX_GROUP_CACHE.
1104		let big = Bytes::from(vec![0u8; MAX_GROUP_CACHE as usize]);
1105		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
1106		producer.write_frame(Timestamp::ZERO, big).unwrap();
1107
1108		// The first frame should have been evicted (tombstoned via offset).
1109		let state = producer.state.read();
1110		assert_eq!(state.offset, 1);
1111		assert_eq!(state.frames.len(), 1);
1112		assert_eq!(state.frames[0].payload.len(), MAX_GROUP_CACHE as usize);
1113	}
1114
1115	#[test]
1116	fn next_frame_returns_cache_full_on_tombstone() {
1117		let mut producer = Info { sequence: 0 }.produce();
1118
1119		let big = Bytes::from(vec![0u8; MAX_GROUP_CACHE as usize]);
1120		producer.write_frame(Timestamp::ZERO, big.clone()).unwrap();
1121		producer.write_frame(Timestamp::ZERO, big).unwrap();
1122
1123		let mut consumer = producer.consume();
1124		// First frame was evicted, next_frame should return Lagged.
1125		let result = consumer.next_frame().now_or_never().unwrap();
1126		assert!(matches!(result, Err(crate::Error::Lagged)));
1127	}
1128
1129	#[test]
1130	fn no_eviction_under_budget() {
1131		let mut producer = Info { sequence: 0 }.produce();
1132		// Many small frames stay cached: there is no frame-count cap, only a byte budget.
1133		for _ in 0..100_000 {
1134			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1135		}
1136		producer.finish().unwrap();
1137
1138		let state = producer.state.read();
1139		assert_eq!(state.offset, 0);
1140		assert_eq!(state.frames.len(), 100_000);
1141	}
1142
1143	#[test]
1144	fn clone_consumer_independent() {
1145		let mut producer = Info { sequence: 0 }.produce();
1146		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1147
1148		let mut c1 = producer.consume();
1149		// Read one frame from c1
1150		let _ = c1.next_frame().now_or_never().unwrap().unwrap().unwrap();
1151
1152		// Clone c1, inheriting its index (past first frame).
1153		let mut c2 = c1.clone();
1154
1155		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1156		producer.finish().unwrap();
1157
1158		// c2 should get the second frame (inherited index)
1159		let f = c2.next_frame().now_or_never().unwrap().unwrap().unwrap();
1160		assert_eq!(f.size, 1); // "b"
1161
1162		let end = c2.next_frame().now_or_never().unwrap().unwrap();
1163		assert!(end.is_none());
1164	}
1165
1166	/// Reading more than one prefetch batch drains every frame in order across the
1167	/// batch boundary (the refill starts exactly where the previous batch ended).
1168	#[test]
1169	fn read_frame_crosses_prefetch_batches() {
1170		let n = Prefetch::CAP * 3 + 5;
1171		let mut producer = Info { sequence: 0 }.produce();
1172		for i in 0..n {
1173			producer
1174				.write_frame(Timestamp::ZERO, Bytes::from(vec![i as u8; 4]))
1175				.unwrap();
1176		}
1177		producer.finish().unwrap();
1178
1179		let mut consumer = producer.consume();
1180		for i in 0..n {
1181			let frame = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1182			assert_eq!(frame.payload, Bytes::from(vec![i as u8; 4]));
1183		}
1184		assert!(consumer.read_frame().now_or_never().unwrap().unwrap().is_none());
1185	}
1186
1187	/// A finished group is still aborted once its frames are released to free memory (the
1188	/// track's latency window, or the cache pool). A reader that already drained every frame
1189	/// is missing nothing, so it must see the clean end of group rather than the abort.
1190	#[test]
1191	fn abort_after_finish_keeps_the_clean_end_for_a_drained_reader() {
1192		let mut producer = Info { sequence: 0 }.produce();
1193		producer
1194			.write_frame(Timestamp::ZERO, Bytes::from_static(b"hello"))
1195			.unwrap();
1196		producer.finish().unwrap();
1197
1198		let mut drained = producer.consume();
1199		let mut behind = producer.consume();
1200		let frame = drained.read_frame().now_or_never().unwrap().unwrap().unwrap();
1201		assert_eq!(frame.payload, Bytes::from_static(b"hello"));
1202
1203		producer.abort(Error::Old).unwrap();
1204
1205		// Drained everything before the abort: nothing is missing.
1206		assert!(drained.read_frame().now_or_never().unwrap().unwrap().is_none());
1207		assert!(drained.next_frame().now_or_never().unwrap().unwrap().is_none());
1208
1209		// Never read the frame, and its bytes are gone: a truncated stream, not a clean end.
1210		assert!(matches!(behind.read_frame().now_or_never().unwrap(), Err(Error::Old)));
1211	}
1212
1213	/// The frame count is fixed at finish, so an abort that clears the cache can't turn a
1214	/// complete group into an error.
1215	#[test]
1216	fn finished_survives_a_later_abort() {
1217		let mut producer = Info { sequence: 0 }.produce();
1218		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1219		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1220		producer.finish().unwrap();
1221
1222		let mut consumer = producer.consume();
1223		producer.abort(Error::Old).unwrap();
1224
1225		assert_eq!(consumer.finished().now_or_never().unwrap().unwrap(), 2);
1226	}
1227
1228	/// `next_frame` drains frames a prior `read_frame` prefetched, preserving order.
1229	#[test]
1230	fn interleave_read_and_next_frame() {
1231		let mut producer = Info { sequence: 0 }.produce();
1232		for i in 0..5u8 {
1233			producer.write_frame(Timestamp::ZERO, Bytes::from(vec![i; 1])).unwrap();
1234		}
1235		producer.finish().unwrap();
1236
1237		let mut consumer = producer.consume();
1238		// The first whole-frame read prefetches all five frames into the batch.
1239		let f0 = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1240		assert_eq!(f0.payload, Bytes::from(vec![0u8; 1]));
1241
1242		// next_frame must continue from the batch, not skip ahead or repeat.
1243		for i in 1..5u8 {
1244			let mut f = consumer.next_frame().now_or_never().unwrap().unwrap().unwrap();
1245			let data = f.read_all().now_or_never().unwrap().unwrap();
1246			assert_eq!(data, Bytes::from(vec![i; 1]));
1247		}
1248		assert!(consumer.next_frame().now_or_never().unwrap().unwrap().is_none());
1249	}
1250
1251	/// A `read_frame` whose index sits past the buffered frames (cleared by an abort, or an
1252	/// eviction gap) must surface the error, not panic on an out-of-range `range(local..)`.
1253	#[test]
1254	fn read_frame_past_cleared_frames_does_not_panic() {
1255		let mut producer = Info { sequence: 0 }.produce();
1256		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"a")).unwrap();
1257		producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"b")).unwrap();
1258
1259		let mut consumer = producer.consume();
1260		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1261		consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1262
1263		// Abort clears the cached frames but leaves the consumer's index (2) past them, so the
1264		// refill's `local` (2) exceeds `frames.len()` (0).
1265		producer.abort(Error::Cancel).unwrap();
1266
1267		let result = consumer.read_frame().now_or_never().unwrap();
1268		assert!(matches!(result, Err(Error::Cancel)), "expected Cancel, got {result:?}");
1269	}
1270
1271	/// Dropping a consumer mid-batch must drop the buffered-but-untaken frames
1272	/// (exercises the `MaybeUninit` Drop path; run under miri to catch leaks/UB).
1273	#[test]
1274	fn drop_with_partial_batch() {
1275		let mut producer = Info { sequence: 0 }.produce();
1276		for _ in 0..Prefetch::CAP {
1277			producer.write_frame(Timestamp::ZERO, Bytes::from_static(b"x")).unwrap();
1278		}
1279		producer.finish().unwrap();
1280
1281		let mut consumer = producer.consume();
1282		// Take one frame so the batch is filled but only partially drained.
1283		let _ = consumer.read_frame().now_or_never().unwrap().unwrap().unwrap();
1284		drop(consumer);
1285	}
1286
1287	/// A parked chunk reader is woken by each chunk write. kio only notifies when
1288	/// a write guard was mutably accessed, so `frame_notify` must mark the guard
1289	/// modified; a guard dropped untouched wakes nobody and the reader would
1290	/// stall until the frame completed.
1291	#[tokio::test]
1292	async fn chunk_write_wakes_parked_reader() {
1293		let mut producer = Info { sequence: 0 }.produce();
1294		let mut consumer = producer.consume();
1295		let mut frame = producer
1296			.create_frame(frame::Info {
1297				size: 6,
1298				timestamp: Timestamp::ZERO,
1299			})
1300			.unwrap();
1301		let mut f = consumer.next_frame().await.unwrap().unwrap();
1302		let handle = tokio::spawn(async move { f.read_chunk().await });
1303		// Let the reader park on the empty partial before the chunk lands.
1304		tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1305		frame.write(Bytes::from_static(b"foo")).unwrap();
1306		let chunk = tokio::time::timeout(std::time::Duration::from_secs(2), handle)
1307			.await
1308			.expect("parked chunk reader was never woken by the chunk write")
1309			.unwrap()
1310			.unwrap();
1311		assert_eq!(chunk, Some(Bytes::from_static(b"foo")));
1312	}
1313
1314	/// A frame whose timestamp is at a different scale is converted to the group's
1315	/// scale by `create_frame`.
1316	#[test]
1317	fn create_frame_converts_mismatched_scale() {
1318		use crate::{Timescale, Timestamp};
1319
1320		let mut producer = Producer::new(
1321			Info { sequence: 0 },
1322			track::Info::default().with_timescale(Timescale::MICRO),
1323			Default::default(),
1324		);
1325		let frame = frame::Info {
1326			size: 3,
1327			timestamp: Timestamp::from_millis(1).unwrap(), // 1ms -> 1000µs
1328		};
1329		let writer = producer.create_frame(frame).unwrap();
1330		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1331		assert_eq!(writer.timestamp.value(), 1000);
1332	}
1333
1334	/// An explicit current timestamp is converted to the group's scale.
1335	#[tokio::test]
1336	async fn create_frame_converts_current_timestamp() {
1337		use crate::Timescale;
1338
1339		let mut producer = Producer::new(
1340			Info { sequence: 0 },
1341			track::Info::default().with_timescale(Timescale::MICRO),
1342			Default::default(),
1343		);
1344		let writer = producer
1345			.create_frame(frame::Info {
1346				size: 3,
1347				timestamp: Timestamp::now(),
1348			})
1349			.unwrap();
1350		assert_eq!(writer.timestamp.scale(), Timescale::MICRO);
1351		assert!(!writer.timestamp.is_zero(), "local clock should be non-zero");
1352	}
1353
1354	/// The per-frame size cap (the group byte budget) is enforced before allocating.
1355	#[test]
1356	fn create_frame_rejects_oversized() {
1357		let mut producer = Info { sequence: 0 }.produce();
1358		let result = producer.create_frame(frame::Info {
1359			size: MAX_GROUP_CACHE + 1,
1360			timestamp: Timestamp::ZERO,
1361		});
1362		assert!(matches!(result, Err(Error::FrameTooLarge)));
1363	}
1364}