Skip to main content

moq_video/decode/
consumer.rs

1//! Subscribe to an encoded H.264, H.265, or AV1 track and emit decoded frames.
2
3use std::collections::VecDeque;
4
5use hang::catalog::VideoConfig;
6
7use super::decoder::Config;
8use super::sink::Sink;
9use crate::Error;
10use crate::Frame;
11
12/// Where a consumer starts on a track that already holds groups.
13///
14/// A track keeps its groups for a while after they are read, so a decoder does
15/// not always open on an empty one: a player rebuilding its decoder subscribes
16/// while its predecessor still holds groups, and a rendition switched away from
17/// and back to stays warm on the origin for the track's idle linger (cached
18/// groups, not an upstream subscription). What to do with that
19/// backlog depends on the consumer, and the two answers are opposites, so it is
20/// asked rather than guessed.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum Start {
24	/// The oldest group the track still holds, decoding everything cached.
25	///
26	/// What a recorder, an export, or anything reading a complete track wants,
27	/// and the default because dropping media a caller has not asked to drop is
28	/// the worse mistake.
29	#[default]
30	Oldest,
31	/// The newest group, skipping whatever is already cached.
32	///
33	/// What a live player wants. Without it a rebuilt decoder walks the whole
34	/// backlog at decode speed before reaching live media, which a viewer sees
35	/// as playback jumping backwards and then sprinting to catch up.
36	Latest,
37}
38
39/// How a [`Consumer`] subscribes to its track, and the decoder it feeds.
40///
41/// The subscription half is what a bare [`Decoder`](super::Decoder) has no use
42/// for: where to start on a cached track, and how far to fall behind live
43/// before skipping. The decoder half is passed through as it is.
44///
45/// `#[non_exhaustive]`: build via [`Options::new`] (or `default()`) and set the
46/// fields, so future knobs don't break callers.
47#[derive(Clone, Debug, Default)]
48#[non_exhaustive]
49pub struct Options {
50	/// The decoder: backend, output representation, scaling hint.
51	pub decoder: Config,
52	/// Where to start on a track that already holds groups.
53	pub start: Start,
54	/// How far playback may drift from the live edge before a stalled group is
55	/// skipped. Defaults to [`std::time::Duration::ZERO`](std::time::Duration::ZERO)
56	/// (skip aggressively); set it to your playout buffer for a softer skip.
57	/// Applied to the transport subscription and inherited by
58	/// [`moq_mux::container::Consumer`].
59	pub max_age: std::time::Duration,
60}
61
62impl Options {
63	/// Defaults: a default [`Config`], every cached group, real-time latency.
64	pub fn new() -> Self {
65		Self::default()
66	}
67}
68
69/// Subscribe to a moq-mux video track and emit decoded frames.
70///
71/// The codec/backend are fixed at construction; [`read`](Self::read) returns
72/// plain [`Frame`]s in the representation
73/// [`Config::output`](super::Config::output) asked for. The direct mirror of
74/// `moq_audio::decode::Consumer`.
75pub struct Consumer {
76	/// A [`Sink`] rather than a bare `Decoder`: the read loop below is held
77	/// across `.await` by every caller (libmoq's spawned task, moq-transcode),
78	/// so the codec would otherwise migrate between executor workers and
79	/// unbalance the per-thread COM apartment the Windows backend opens.
80	decoder: Sink,
81	track: moq_mux::container::Consumer<moq_mux::catalog::hang::Container>,
82	/// Frames a single access unit decoded to but `read` hasn't returned yet.
83	/// One AU yields one frame in the low-delay path, but a backend may hand back
84	/// more, so we buffer to keep `read` one-frame-per-call.
85	pending: VecDeque<Frame>,
86	/// Whether the ended track's decoder has already been drained.
87	drained: bool,
88	/// Last container playhead generation observed.
89	discontinuity: u64,
90}
91
92impl Consumer {
93	/// Subscribe to `name` in `broadcast`, decoding it per the catalog entry.
94	/// Errors if the rendition's codec is not supported by a native backend.
95	pub async fn new(
96		broadcast: &moq_net::broadcast::Consumer,
97		catalog: &VideoConfig,
98		name: impl Into<String>,
99		options: Options,
100	) -> Result<Self, Error> {
101		let decoder = Sink::open(catalog, &options.decoder).await?;
102
103		let name = name.into();
104		let track = broadcast.track(&name)?;
105		let mut subscriber = track
106			.subscribe(
107				moq_net::track::Subscription::default()
108					.with_priority(hang::catalog::PRIORITY.video)
109					.with_max_age(options.max_age),
110			)
111			.await?;
112		// A decoder often opens on a track that is already cached: a replacement
113		// decoder subscribes while its predecessor still holds groups, and a
114		// rendition switched away from and back to stays warm on the origin for
115		// `TRACK_IDLE_LINGER` (cached groups, not an upstream subscription). A
116		// caller that asked for `Start::Latest` wants none of that backlog,
117		// because a cursor starting at sequence zero replays every cached group
118		// at decode speed before reaching live media, which on a thirty-second
119		// retention is half a minute of pictures raced through.
120		//
121		// This moves the local read cursor and deliberately not
122		// `Subscription::group_start`. That field is a request to the publisher,
123		// aggregated across every live subscriber, so naming a stale cached
124		// sequence there asks the publisher to rewind the track for everyone
125		// reading it. What a player wants is to skip what it already has.
126		if options.start == Start::Latest
127			&& let Some(live_edge) = track.latest()
128		{
129			subscriber.set_groups(live_edge..);
130		}
131		let track = subscriber;
132		// The catalog says how the track is framed, and it is not always the legacy
133		// wire: `moq import fmp4` publishes CMAF. Reading a moof+mdat fragment as a
134		// varint timestamp plus a payload decodes to garbage rather than failing.
135		let container = moq_mux::catalog::hang::Container::try_from(catalog)?;
136		let track = moq_mux::container::Consumer::new(track, container);
137
138		Ok(Self {
139			decoder,
140			track,
141			pending: VecDeque::new(),
142			drained: false,
143			discontinuity: 0,
144		})
145	}
146
147	/// The decoder backend name in use, e.g. `"videotoolbox"` or `"openh264"`.
148	pub fn name(&self) -> &str {
149		self.decoder.name()
150	}
151
152	/// Read the next decoded frame, or `None` after the track ends and the
153	/// decoder's buffered tail has been drained.
154	///
155	/// This inherits [`Sink`]'s cancellation contract. If a queued codec
156	/// operation is cancelled, the next read returns a codec error: a cancelled
157	/// mid-stream decode poisons the sink so every later read keeps returning
158	/// that error, while a cancelled tail flush reports the error once and then
159	/// `None`. Drop the consumer instead of continuing to read it.
160	pub async fn read(&mut self) -> Result<Option<Frame>, Error> {
161		loop {
162			if let Some(frame) = self.pending.pop_front() {
163				return Ok(Some(frame));
164			}
165			if self.drained {
166				return Ok(None);
167			}
168
169			let mux_frame = self.track.read().await?;
170			let discontinuity = self.track.discontinuity();
171			if discontinuity != self.discontinuity {
172				// A playhead event re-applies startup delay and skip; the next group
173				// already starts on a keyframe with parameter sets, so the decoder is
174				// not flushed.
175				self.discontinuity = discontinuity;
176			}
177
178			let Some(mux_frame) = mux_frame else {
179				// The flag goes up only once the tail is in hand, so a read
180				// dropped before the drain ran retries it rather than reporting
181				// an end the stream has not reached. Flushing twice is safe: the
182				// second hands back nothing.
183				let tail = self.decoder.flush().await;
184				// Set before the error is returned, not after. A flush that
185				// failed once fails the same way every time, and the track has
186				// ended either way, so leaving the flag down turns one bad
187				// drain into a caller that reads, fails, and reads again with
188				// nothing in between to wait on. A caller that treats a codec
189				// error as one lost picture and carries on then spins.
190				self.drained = true;
191				self.pending.extend(tail?);
192				continue;
193			};
194
195			self.pending.extend(
196				self.decoder
197					.decode(mux_frame.payload, mux_frame.timestamp, mux_frame.keyframe)
198					.await?,
199			);
200		}
201	}
202}
203
204#[cfg(test)]
205mod tests {
206	#![cfg_attr(not(feature = "openh264"), allow(dead_code, unused_imports))]
207
208	use bytes::Bytes;
209	use moq_net::Timestamp;
210
211	/// Build an origin producer, spawning its driver on the ambient runtime.
212	fn produce_origin() -> moq_net::origin::Producer {
213		let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::default());
214		if tokio::runtime::Handle::try_current().is_ok() {
215			tokio::spawn(moq_net::time::run(driver));
216		} else {
217			// A sync test: nothing polls the driver, and dropping it would tear
218			// the origin down, so leak it and rely on the synchronous half.
219			std::mem::forget(driver);
220		}
221		producer
222	}
223	use super::*;
224	use crate::decode::Kind;
225	use crate::decode::backend::probe;
226	use crate::encode::{Config as EncodeConfig, Encoder, Kind as EncodeKind, Producer as EncodeProducer};
227
228	#[tokio::test]
229	#[cfg(feature = "openh264")]
230	async fn reads_cmaf_container_declared_by_catalog() {
231		let mut source_broadcast = moq_net::broadcast::Info::new().produce();
232		let source_subscriber = source_broadcast.consume();
233		let source_catalog =
234			moq_mux::catalog::Producer::new(&mut source_broadcast, moq_mux::catalog::Config::default()).unwrap();
235		let config = EncodeConfig {
236			kind: EncodeKind::Software,
237			..EncodeConfig::new(320, 240, crate::Rate::new(30, 1).unwrap())
238		};
239		let rendition = config.probe().await.unwrap();
240		let mut producer = EncodeProducer::new(source_broadcast, source_catalog, rendition).unwrap();
241		let mut encoder = Encoder::new(&config).unwrap();
242		let rgba = vec![0x80u8; 320 * 240 * 4];
243		for index in 0..2 {
244			encoder.cut().unwrap();
245			let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
246			let frame = crate::Frame::new(surface, moq_net::Timestamp::from_micros(index * 33_333).unwrap());
247			producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
248		}
249
250		let origin = produce_origin();
251		let requests = origin.dynamic("", Default::default()).unwrap();
252		let served = source_subscriber.clone();
253		tokio::spawn(async move {
254			while let Ok(request) = requests.requested_broadcast().await {
255				request.accept(served.clone());
256			}
257		});
258		let catalog = moq_mux::catalog::Consumer::<()>::new(&source_subscriber, moq_mux::catalog::CatalogFormat::Hang)
259			.await
260			.unwrap();
261		let source = moq_mux::Source::new(origin.consume(), "test");
262		// Both frames are encoded before the export runs, so the exporter needs a budget
263		// wide enough to read them: its REAL_TIME default keeps only the live edge, and
264		// the second `next()` would then block forever waiting for a group that was
265		// skipped.
266		let mut export =
267			moq_mux::container::fmp4::Export::new(source, catalog).with_max_age(std::time::Duration::from_secs(30));
268		let init = export.next().await.unwrap().expect("CMAF init");
269		let fragment = export.next().await.unwrap().expect("CMAF fragment");
270
271		let mut broadcast = moq_net::broadcast::Info::new().produce();
272		let subscriber = broadcast.consume();
273		let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
274		let mut import = moq_mux::container::fmp4::Import::new(broadcast, catalog.reserve());
275		import.decode(&init).unwrap();
276		import.decode(&fragment).unwrap();
277
278		let snapshot = catalog.snapshot();
279		let (name, config) = snapshot.video.renditions.iter().next().expect("video rendition");
280		assert!(matches!(config.container, hang::catalog::Container::Cmaf { .. }));
281		let mut consumer = Consumer::new(
282			&subscriber,
283			config,
284			name,
285			Options {
286				decoder: Config {
287					kind: Kind::Software,
288					..Config::new()
289				},
290				..Options::new()
291			},
292		)
293		.await
294		.unwrap();
295
296		let frame = consumer.read().await.unwrap().expect("decoded frame");
297		assert_eq!(frame.size(), crate::Size::new(320, 240));
298	}
299
300	/// A decoder opened on a track that already holds groups starts at the
301	/// newest one, not at the oldest still cached.
302	///
303	/// A player rebuilding its decoder (a backend change, a rendition pin) opens
304	/// a second consumer while the first still holds the groups it has not
305	/// released. Starting those at sequence zero replays the whole retention at
306	/// decode speed before the picture reaches live media.
307	#[tokio::test]
308	async fn a_second_consumer_starts_at_the_live_edge() {
309		let broadcast = moq_net::broadcast::Info::new().produce();
310		let track = broadcast
311			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
312			.unwrap();
313		// Kept so the aggregated subscription can be read back below.
314		let published = track.clone();
315		let subscriber = broadcast.consume();
316		let mut producer = moq_mux::container::Producer::new(
317			track,
318			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
319		);
320		// A keyframe opens a group, so this is three groups a second apart.
321		for index in 0..3u64 {
322			producer
323				.write(moq_mux::container::Frame {
324					timestamp: Timestamp::from_micros(index * 1_000_000).unwrap(),
325					duration: None,
326					payload: Bytes::from_static(b"access unit"),
327					keyframe: true,
328				})
329				.unwrap();
330		}
331		producer.finish().unwrap();
332
333		let catalog = VideoConfig::new(hang::catalog::H264 {
334			inline: true,
335			profile: 0x42,
336			constraints: 0,
337			level: 30,
338		});
339		let mut consumer = Consumer::new(
340			&subscriber,
341			&catalog,
342			"video",
343			Options {
344				decoder: Config {
345					kind: Kind::Named(probe::BUFFERED_NAME.into()),
346					..Config::new()
347				},
348				start: Start::Latest,
349				// Wide enough to keep every group fresh: the age budget on its own
350				// delivers only the live edge, which would pass this test without
351				// `Start::Latest` doing anything.
352				max_age: std::time::Duration::from_secs(10),
353				..Options::new()
354			},
355		)
356		.await
357		.unwrap();
358
359		// The buffered probe stamps each picture with the access unit's own
360		// timestamp, so this says which group the read started from. It is the
361		// backend to use here rather than the plain probe, whose event log is
362		// process-wide and belongs to the thread-affinity test.
363		let frame = consumer.read().await.unwrap().expect("a decoded frame");
364		assert_eq!(
365			frame.timestamp,
366			Timestamp::from_micros(2_000_000).unwrap(),
367			"a fresh consumer replayed the groups an earlier reader still holds",
368		);
369
370		// The skip is the local read cursor and nothing else. Asking for it
371		// through `Subscription::start` would look equivalent and is not: the
372		// floor is aggregated across every live subscriber and tells the
373		// publisher what to send, so naming a cached sequence there rewinds the
374		// track for everyone reading it. A rendition switched away from and back
375		// to is the case that bites, because its cached sequence is stale by
376		// then and the publisher resends the broadcast from it.
377		assert_eq!(
378			published.subscription().and_then(|sub| sub.start),
379			None,
380			"the publisher was asked to rewind the track",
381		);
382	}
383
384	/// The default reads everything the track holds.
385	///
386	/// `Start::Latest` is a player's policy and not the API's: a recorder, an
387	/// export, or a test decoding a track that was written before it subscribed
388	/// wants every group, and dropping media nobody asked to drop is the worse
389	/// of the two mistakes. This is the half that a live-edge default breaks,
390	/// so it is pinned beside the other one.
391	#[tokio::test]
392	async fn the_default_reads_every_cached_group() {
393		let broadcast = moq_net::broadcast::Info::new().produce();
394		let track = broadcast
395			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
396			.unwrap();
397		let subscriber = broadcast.consume();
398		let mut producer = moq_mux::container::Producer::new(
399			track,
400			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
401		);
402		for index in 0..3u64 {
403			producer
404				.write(moq_mux::container::Frame {
405					timestamp: Timestamp::from_micros(index * 1_000_000).unwrap(),
406					duration: None,
407					payload: Bytes::from_static(b"access unit"),
408					keyframe: true,
409				})
410				.unwrap();
411		}
412		producer.finish().unwrap();
413
414		let catalog = VideoConfig::new(hang::catalog::H264 {
415			inline: true,
416			profile: 0x42,
417			constraints: 0,
418			level: 30,
419		});
420		let mut consumer = Consumer::new(
421			&subscriber,
422			&catalog,
423			"video",
424			Options {
425				decoder: Config {
426					// The buffered probe rather than the plain one: the plain probe's
427					// event log is process-wide and belongs to the thread-affinity test.
428					kind: Kind::Named(probe::BUFFERED_NAME.into()),
429					..Config::new()
430				},
431				// A budget that keeps every group fresh, so the start policy is the
432				// only thing deciding what is read.
433				max_age: std::time::Duration::from_secs(10),
434				..Options::new()
435			},
436		)
437		.await
438		.unwrap();
439
440		let mut seen = Vec::new();
441		while let Some(frame) = consumer.read().await.unwrap() {
442			seen.push(frame.timestamp);
443		}
444		assert_eq!(
445			seen,
446			vec![
447				Timestamp::from_micros(0).unwrap(),
448				Timestamp::from_micros(1_000_000).unwrap(),
449				Timestamp::from_micros(2_000_000).unwrap(),
450			],
451			"the default dropped groups the caller never asked to drop",
452		);
453	}
454
455	/// The age budget is the subscription's and not the decoder's: it reaches
456	/// the publisher through the track subscription, while the decoder opens
457	/// with exactly the config it was handed.
458	///
459	/// Driven by `pollster` rather than tokio: the probe's guard is a plain
460	/// mutex, and holding one across an `.await` is what clippy rightly flags.
461	#[test]
462	fn max_age_reaches_the_subscription_and_not_the_decoder() {
463		let _probe = probe::native_exclusive();
464		let broadcast = moq_net::broadcast::Info::new().produce();
465		let track = broadcast
466			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
467			.unwrap();
468		let published = track.clone();
469		let subscriber = broadcast.consume();
470		let mut producer = moq_mux::container::Producer::new(
471			track,
472			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
473		);
474		producer
475			.write(moq_mux::container::Frame {
476				timestamp: Timestamp::from_micros(0).unwrap(),
477				duration: None,
478				payload: Bytes::from_static(b"access unit"),
479				keyframe: true,
480			})
481			.unwrap();
482		producer.finish().unwrap();
483
484		let catalog = VideoConfig::new(hang::catalog::H264 {
485			inline: true,
486			profile: 0x42,
487			constraints: 0,
488			level: 30,
489		});
490		let decoder = Config {
491			kind: Kind::Named(probe::NATIVE_NAME.into()),
492			output: crate::Output::Cpu,
493			scale_hint: Some(crate::Size::new(160, 120)),
494		};
495		let max_age = std::time::Duration::from_secs(10);
496		let mut consumer = pollster::block_on(Consumer::new(
497			&subscriber,
498			&catalog,
499			"video",
500			Options {
501				decoder: decoder.clone(),
502				max_age,
503				..Options::new()
504			},
505		))
506		.unwrap();
507
508		let subscription = published.subscription().expect("the consumer subscribed");
509		assert_eq!(
510			subscription.max_age, max_age,
511			"the age budget did not reach the publisher"
512		);
513
514		let opened = probe::native_opened().expect("the decoder opened");
515		assert_eq!(opened.output, decoder.output);
516		assert_eq!(opened.scale_hint, decoder.scale_hint);
517
518		let frame = pollster::block_on(consumer.read()).unwrap().expect("a decoded frame");
519		assert!(
520			matches!(frame.surface, crate::Surface::I420(_)),
521			"CPU output was not enforced"
522		);
523	}
524
525	/// A track ends before a decoder that reorders pictures does. The consumer
526	/// drains the backend once and returns its tail before reporting the end.
527	#[tokio::test]
528	async fn track_end_drains_buffered_decoder() {
529		let broadcast = moq_net::broadcast::Info::new().produce();
530		let track = broadcast
531			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
532			.unwrap();
533		let subscriber = broadcast.consume();
534		let mut producer = moq_mux::container::Producer::new(
535			track,
536			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
537		);
538		for index in 0..2u64 {
539			producer
540				.write(moq_mux::container::Frame {
541					timestamp: Timestamp::from_micros(index * 33_333).unwrap(),
542					duration: None,
543					payload: Bytes::from_static(b"access unit"),
544					keyframe: index == 0,
545				})
546				.unwrap();
547		}
548		producer.finish().unwrap();
549
550		let catalog = VideoConfig::new(hang::catalog::H264 {
551			inline: true,
552			profile: 0x42,
553			constraints: 0,
554			level: 30,
555		});
556		let mut consumer = Consumer::new(
557			&subscriber,
558			&catalog,
559			"video",
560			Options {
561				decoder: Config {
562					kind: Kind::Named(probe::BUFFERED_NAME.into()),
563					..Config::new()
564				},
565				..Options::new()
566			},
567		)
568		.await
569		.unwrap();
570
571		let mut timestamps = Vec::new();
572		while let Some(frame) = consumer.read().await.unwrap() {
573			timestamps.push(frame.timestamp.as_micros());
574		}
575		assert_eq!(timestamps, vec![0, 33_333]);
576		assert!(
577			consumer.read().await.unwrap().is_none(),
578			"the decoder was drained twice"
579		);
580	}
581
582	/// A declared discontinuity is a playhead event, not a decoder flush. A delayed
583	/// picture from before the seam still surfaces; the next group continues forward.
584	#[tokio::test]
585	async fn discontinuity_does_not_flush_the_decoder() {
586		let broadcast = moq_net::broadcast::Info::new().produce();
587		let track = broadcast
588			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
589			.unwrap();
590		let subscriber = broadcast.consume();
591		let mut producer = moq_mux::container::Producer::new(
592			track,
593			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video),
594		);
595		producer
596			.write(moq_mux::container::Frame {
597				timestamp: Timestamp::from_micros(100_000).unwrap(),
598				duration: None,
599				payload: Bytes::from_static(b"old access unit"),
600				keyframe: true,
601			})
602			.unwrap();
603		producer.discontinuity().unwrap();
604		producer
605			.write(moq_mux::container::Frame {
606				timestamp: Timestamp::from_micros(200_000).unwrap(),
607				duration: None,
608				payload: Bytes::from_static(b"new access unit"),
609				keyframe: true,
610			})
611			.unwrap();
612		producer.finish().unwrap();
613
614		let catalog = VideoConfig::new(hang::catalog::H264 {
615			inline: true,
616			profile: 0x42,
617			constraints: 0,
618			level: 30,
619		});
620		let mut consumer = Consumer::new(
621			&subscriber,
622			&catalog,
623			"video",
624			Options {
625				decoder: Config {
626					kind: Kind::Named(probe::BUFFERED_NAME.into()),
627					..Config::new()
628				},
629				max_age: std::time::Duration::from_secs(10),
630				..Options::new()
631			},
632		)
633		.await
634		.unwrap();
635
636		let mut timestamps = Vec::new();
637		while let Some(frame) = consumer.read().await.unwrap() {
638			timestamps.push(frame.timestamp.as_micros());
639		}
640		assert_eq!(timestamps, vec![100_000, 200_000]);
641	}
642
643	/// Cancellation while a threaded flush is in flight leaves the sink poisoned.
644	/// The next read surfaces that error rather than reporting a clean end and
645	/// silently discarding the tail.
646	#[cfg(not(target_os = "macos"))]
647	#[tokio::test]
648	async fn cancelled_track_end_flush_is_not_reported_as_drained() {
649		probe::prepare_blocking_flush();
650		let broadcast = moq_net::broadcast::Info::new().produce();
651		let track = broadcast
652			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
653			.unwrap();
654		let subscriber = broadcast.consume();
655		let mut producer = moq_mux::container::Producer::new(
656			track,
657			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
658		);
659		producer.finish().unwrap();
660
661		let catalog = VideoConfig::new(hang::catalog::H264 {
662			inline: true,
663			profile: 0x42,
664			constraints: 0,
665			level: 30,
666		});
667		let mut consumer = Consumer::new(
668			&subscriber,
669			&catalog,
670			"video",
671			Options {
672				decoder: Config {
673					kind: Kind::Named(probe::BLOCKING_FLUSH_NAME.into()),
674					..Config::new()
675				},
676				..Options::new()
677			},
678		)
679		.await
680		.unwrap();
681
682		let mut read = Box::pin(consumer.read());
683		let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
684		loop {
685			tokio::select! {
686				_result = &mut read => panic!("flush returned before cancellation"),
687				_ = tokio::time::sleep(std::time::Duration::from_millis(1)) => {
688					if probe::flush_entered() {
689						break;
690					}
691					if tokio::time::Instant::now() >= deadline {
692						probe::release_flush();
693						panic!("flush never reached the codec thread");
694					}
695				}
696			}
697		}
698		drop(read);
699		probe::release_flush();
700
701		let err = match consumer.read().await {
702			Err(err) => err,
703			Ok(_) => panic!("cancelled flush must poison the sink"),
704		};
705		assert!(err.to_string().contains("cancelled call"), "unexpected error: {err}");
706		assert!(matches!(err, crate::Error::CodecGone(_)));
707		assert!(consumer.read().await.unwrap().is_none());
708	}
709
710	/// VAAPI returns its buffered tail before the consumer reports track end.
711	#[cfg(all(target_os = "linux", feature = "vaapi"))]
712	#[tokio::test]
713	async fn the_track_ending_drains_the_decoder() {
714		const FRAMES: u64 = 5;
715		let config = EncodeConfig {
716			kind: EncodeKind::Software,
717			..EncodeConfig::new(320, 240, crate::Rate::new(30, 1).unwrap())
718		};
719		let catalog = config.probe().await.expect("probe the software encoder");
720
721		let broadcast = moq_net::broadcast::Info::new().produce();
722		let track = broadcast
723			.create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video))
724			.unwrap();
725		let subscriber = broadcast.consume();
726		let mut producer = moq_mux::container::Producer::new(
727			track,
728			moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data),
729		);
730
731		let mut encoder = Encoder::new(&config).unwrap();
732		let rgba = vec![0x80u8; 320 * 240 * 4];
733		for index in 0..FRAMES {
734			if index == 0 {
735				encoder.cut().unwrap();
736			}
737			let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
738			let frame = crate::Frame::new(surface, moq_net::Timestamp::from_micros(index * 33_333).unwrap());
739			for encoded in encoder.encode(&frame).unwrap() {
740				producer
741					.write(moq_mux::container::Frame {
742						timestamp: encoded.timestamp,
743						duration: None,
744						payload: encoded.payload,
745						keyframe: index == 0,
746					})
747					.unwrap();
748			}
749		}
750		producer.finish().unwrap();
751
752		let decode = Options {
753			decoder: Config {
754				kind: Kind::Named("vaapi".into()),
755				..Config::new()
756			},
757			..Options::new()
758		};
759		// The hardware gate: no libva, no render node, or no H.264 decode
760		// entrypoint and the named backend refuses to open.
761		let Ok(mut consumer) = Consumer::new(&subscriber, &catalog, "video", decode).await else {
762			return;
763		};
764
765		let mut timestamps = Vec::new();
766		while let Some(frame) = consumer.read().await.unwrap() {
767			timestamps.push(frame.timestamp.as_micros());
768		}
769		let expected: Vec<u128> = (0..FRAMES as u128).map(|index| index * 33_333).collect();
770		assert_eq!(timestamps, expected, "the track ended before the stream did");
771
772		// The end stays the end: the drain runs once, so a caller that keeps
773		// reading past it does not get the tail a second time.
774		assert!(consumer.read().await.unwrap().is_none());
775	}
776}