Skip to main content

moq_transcode/
lib.rs

1//! Just-in-time live transcoding for hang broadcasts.
2//!
3//! [`run`] consumes a source broadcast and fills a derivative broadcast: a
4//! catalog advertising lower renditions (rungs) of the source video plus
5//! references back to the source renditions, and one output video track per
6//! rung. The catalog is published immediately and deterministically (codec
7//! strings are computed from the ladder, not the bitstream), but nothing is
8//! encoded until a subscriber actually asks:
9//!
10//! - Subscribing to a rung attaches it to a shared live decode of the source
11//!   (one subscription and one decoder per source, no matter how many rungs
12//!   are active); each rung resizes and encodes its own copy, group for group,
13//!   stopping when the last subscriber leaves.
14//! - Fetching a specific group fetches that same group from the source and
15//!   transcodes just that group. Output groups mirror source sequence numbers
16//!   1:1, so group N of every rung is the same content as source group N.
17//!
18//! The codec work is `moq-video`: hardware where available (NVDEC + NVENC on
19//! Linux, VideoToolbox on macOS, Media Foundation on Windows) with openh264 as
20//! the H.264 software fallback. On an NVIDIA GPU the whole pipeline is
21//! GPU-resident: NVDEC decodes and scales in hardware and NVENC encodes the
22//! CUDA frame in place, with no CPU copies. Other decoders scale on the CPU.
23
24mod catalog;
25mod config;
26mod error;
27mod feed;
28mod rung;
29
30pub use config::{Config, Rung, source_reference};
31pub use error::Error;
32
33/// Transcode `source` into `output` until the source broadcast ends.
34///
35/// Reads the source catalog, publishes the derivative catalog (rungs strictly
36/// below the source, plus source renditions referenced via [`Config::source`]),
37/// and serves each rung just-in-time: a rung track only materializes when a
38/// consumer asks for it, and only encodes while consumed. Where `output` is
39/// announced (and how its path relates to the source) is the caller's business.
40///
41/// The catalog tracks and the on-demand rung handler are registered
42/// synchronously, before the first `await`, so a consumer may race the rest of
43/// the setup safely: call `run` before announcing `output`.
44pub async fn run(
45	source: moq_net::broadcast::Consumer,
46	mut output: moq_net::broadcast::Producer,
47	config: Config,
48) -> Result<(), Error> {
49	// The catalog starts empty and fills in below, exactly like a media
50	// importer that hasn't seen parameter sets yet.
51	let mut derived = moq_mux::catalog::Producer::new(&mut output)?;
52	// Consumers asking for a rung before (or after) it exists queue here.
53	let mut dynamic = output.dynamic();
54
55	// The source catalog drives everything; wait for a snapshot with a usable
56	// video rendition (the first may precede the source publishing its video).
57	let track = source
58		.track(hang::Catalog::DEFAULT_NAME)?
59		.subscribe(hang::Catalog::default_subscription())
60		.await?;
61	let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
62	let (source_name, source_config, snapshot) = loop {
63		let Some(snapshot) = catalogs.next().await? else {
64			return Err(Error::NoSource);
65		};
66		match catalog::choose_source(&snapshot.video) {
67			Ok((name, config)) => break (name, config, snapshot),
68			Err(_) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"),
69		}
70	};
71	let rungs = catalog::resolve_rungs(&config.rungs, &source_name, &source_config)?;
72	tracing::info!(source = %source_name, rungs = rungs.len(), "transcoding");
73
74	// One shared live decode for every rung of this source: N active rungs
75	// share one subscription and one decoder instead of N.
76	let feed = feed::Feed::new(
77		source.track(&source_name)?,
78		source_config.clone(),
79		config.decoder.clone(),
80	);
81
82	// Publish the derivative catalog before any encoder exists, so subscribers
83	// can pick a rung immediately.
84	let mut entries = Vec::with_capacity(rungs.len());
85	for rung in &rungs {
86		let entry = catalog::rung_entry(rung, &source_config, &config.encoder).await?;
87		entries.push((rung.name.clone(), entry));
88	}
89	{
90		let mut guard = derived.lock();
91		catalog::populate(&mut guard, &snapshot, &entries, config.source.as_ref())?;
92	}
93
94	// Serve rung requests and follow source catalog updates until the source
95	// ends. The rung set is fixed at startup: a source that changes resolution
96	// mid-stream keeps the ladder it started with, but the passthrough entries
97	// track the source.
98	let mut tasks = tokio::task::JoinSet::new();
99	loop {
100		tokio::select! {
101			request = dynamic.requested_track() => {
102				// Err means the broadcast closed; nothing left to serve.
103				let Ok(request) = request else { break };
104				match rungs.iter().find(|rung| rung.name == request.name()) {
105					Some(info) => {
106						let rung = rung::Rung {
107							source: source.track(&source_name)?,
108							feed: feed.clone(),
109							broadcast: source.clone(),
110							config: source_config.clone(),
111							encoder: config.encoder.clone(),
112							decoder: config.decoder.clone(),
113							resize: config.resize,
114							info: info.clone(),
115						};
116						tasks.spawn(rung::serve(rung, request));
117					}
118					None => request.reject(moq_net::Error::NotFound),
119				}
120			},
121			update = catalogs.next() => match update {
122				Ok(Some(snapshot)) => {
123					let mut guard = derived.lock();
124					catalog::populate(&mut guard, &snapshot, &entries, config.source.as_ref())?;
125				}
126				// The source ended (or its catalog track died): wind down.
127				Ok(None) => break,
128				Err(err) => {
129					tracing::debug!(%err, "source catalog ended");
130					break;
131				}
132			},
133			Some(result) = tasks.join_next() => match result {
134				Ok(Ok(())) => {}
135				Ok(Err(err)) => tracing::warn!(%err, "rung failed"),
136				Err(err) => tracing::warn!(%err, "rung panicked"),
137			}
138		}
139	}
140
141	// Wind the rungs down. On a clean source end they are already finishing on
142	// their own (the live path saw the source track end), so `shutdown` just
143	// joins them. But `run` also breaks on a catalog-track error while the
144	// source media and viewers are still live, and a rung task only self-ends on
145	// source-media-end or broadcast-close, not catalog-end. Aborting rather than
146	// awaiting keeps that case from hanging forever here.
147	tasks.shutdown().await;
148
149	derived.finish()?;
150	output.finish();
151	Ok(())
152}
153
154#[cfg(test)]
155mod tests {
156	use super::*;
157
158	/// A live source broadcast; the producers are kept so the tracks stay open
159	/// for the duration of the test.
160	struct Source {
161		broadcast: moq_net::broadcast::Producer,
162		_catalog: moq_mux::catalog::Producer,
163		_track: moq_net::track::Producer,
164	}
165
166	/// H.264 NAL unit types in an Annex-B buffer, found via 3-byte start codes (a
167	/// 4-byte `00 00 00 01` code contains `00 00 01` too, so this catches both).
168	fn nal_types(annexb: &[u8]) -> Vec<u8> {
169		let mut types = Vec::new();
170		let mut i = 0;
171		while i + 3 < annexb.len() {
172			if annexb[i..i + 3] == [0, 0, 1] {
173				types.push(annexb[i + 3] & 0x1f);
174				i += 3;
175			} else {
176				i += 1;
177			}
178		}
179		types
180	}
181
182	/// Wrap a gray 320x240 RGBA buffer as a raw frame at `timestamp` microseconds.
183	fn gray_frame(rgba: &[u8], timestamp: u64) -> moq_video::Frame {
184		let surface = moq_video::Surface::rgba(rgba, moq_video::Size::new(320, 240)).unwrap();
185		moq_video::Frame::new(surface, moq_net::Timestamp::from_micros(timestamp).unwrap())
186	}
187
188	/// Build a 320x240 avc3 source broadcast: a catalog plus a video track with
189	/// `groups` groups of `frames` gray frames each, encoded with openh264.
190	fn source_broadcast(groups: u64, frames: u64) -> Source {
191		let mut broadcast = moq_net::broadcast::Info::default().produce();
192		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
193
194		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
195			inline: true,
196			profile: 0x42,
197			constraints: 0,
198			level: 30,
199		});
200		video.coded_width = Some(320);
201		video.coded_height = Some(240);
202		video.bitrate = Some(1_000_000);
203		video.framerate = Some(30.0);
204		catalog.lock().video.insert("video", video).unwrap();
205
206		let info = hang::container::track_info();
207		let mut track = broadcast.create_track("video", info).unwrap();
208
209		let mut encoder = moq_video::encode::Encoder::new(&{
210			let mut config = moq_video::encode::Config::new(320, 240, 30);
211			config.kind = moq_video::encode::Kind::Software;
212			config
213		})
214		.unwrap();
215		let gray = vec![0x80u8; 320 * 240 * 4];
216
217		for sequence in 0..groups {
218			let mut group = track.create_group(sequence.into()).unwrap();
219			for index in 0..frames {
220				let timestamp = (sequence * frames + index) * 33_333;
221				if index == 0 {
222					encoder.keyframe();
223				}
224				for encoded in encoder.encode(&gray_frame(&gray, timestamp)).unwrap() {
225					let frame = hang::container::Frame {
226						timestamp: encoded.timestamp,
227						payload: encoded.payload,
228					};
229					frame.write_to(&mut group).unwrap();
230				}
231			}
232			group.finish().unwrap();
233		}
234
235		Source {
236			broadcast,
237			_catalog: catalog,
238			_track: track,
239		}
240	}
241
242	/// A source like [`source_broadcast`], but the groups arrive over (paused)
243	/// time instead of all at once, so several rungs can attach to the shared
244	/// live feed before the first group exists. Returns the broadcast plus the
245	/// producing task's handle (the track producer lives inside it).
246	fn source_broadcast_live(groups: u64, frames: u64) -> (Source, tokio::task::JoinHandle<()>) {
247		let mut broadcast = moq_net::broadcast::Info::default().produce();
248		let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap();
249
250		let mut video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
251			inline: true,
252			profile: 0x42,
253			constraints: 0,
254			level: 30,
255		});
256		video.coded_width = Some(320);
257		video.coded_height = Some(240);
258		video.bitrate = Some(1_000_000);
259		video.framerate = Some(30.0);
260		catalog.lock().video.insert("video", video).unwrap();
261
262		let info = hang::container::track_info();
263		let mut track = broadcast.create_track("video", info).unwrap();
264
265		let source = Source {
266			broadcast,
267			_catalog: catalog,
268			// The producing task owns the real track producer; park a clone so
269			// the struct shape matches `source_broadcast`.
270			_track: track.clone(),
271		};
272
273		let task = tokio::spawn(async move {
274			let mut encoder = moq_video::encode::Encoder::new(&{
275				let mut config = moq_video::encode::Config::new(320, 240, 30);
276				config.kind = moq_video::encode::Kind::Software;
277				config
278			})
279			.unwrap();
280			let gray = vec![0x80u8; 320 * 240 * 4];
281
282			for sequence in 0..groups {
283				// Paces the source: a real sleep, since the rungs encode off the
284				// executor and cannot be sequenced by paused-time idle detection.
285				// Also the window the subscribers attach in, before group 0.
286				tokio::time::sleep(std::time::Duration::from_millis(100)).await;
287				let mut group = track.create_group(sequence.into()).unwrap();
288				for index in 0..frames {
289					let timestamp = (sequence * frames + index) * 33_333;
290					if index == 0 {
291						encoder.keyframe();
292					}
293					for encoded in encoder.encode(&gray_frame(&gray, timestamp)).unwrap() {
294						let frame = hang::container::Frame {
295							timestamp: encoded.timestamp,
296							payload: encoded.payload,
297						};
298						frame.write_to(&mut group).unwrap();
299					}
300				}
301				group.finish().unwrap();
302			}
303			// Keep the track open until aborted, like a live source.
304			std::future::pending::<()>().await;
305		});
306
307		(source, task)
308	}
309
310	/// Two rungs subscribed at once ride one shared live decode (the feed):
311	/// both must produce complete groups mirroring the source sequences.
312	#[tokio::test]
313	async fn live_multi_rung() {
314		// Real time on purpose, unlike most timed tests here. The rungs encode on
315		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
316		// to tokio and `pause()` auto-advances the source's sleep while the encode
317		// is still in flight. The source then outruns the feed's bounded broadcast
318		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
319		// source against the encoders the way a live source does.
320		let (source, producer_task) = source_broadcast_live(3, 5);
321		let config = Config {
322			rungs: vec![Rung::new(120, 100_000), Rung::new(60, 50_000)],
323			encoder: moq_video::encode::Kind::Software,
324			decoder: moq_video::decode::Kind::Software,
325			source: None,
326			..Default::default()
327		};
328
329		let output = moq_net::broadcast::Info::default().produce();
330		let consumer = output.consume();
331		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
332
333		// Attach both rungs before the first source group exists (paused time:
334		// the producer's sleep only fires once every rung is parked on the feed).
335		let mut subscribers = Vec::new();
336		for name in ["video/120p", "video/60p"] {
337			let track = loop {
338				match consumer.track(name) {
339					Ok(track) => break track,
340					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
341					Err(err) => panic!("rung track {name}: {err}"),
342				}
343			};
344			subscribers.push((name, track.subscribe(None).await.unwrap()));
345		}
346
347		// Every rung receives a complete group with all 5 source frames.
348		for (name, subscriber) in &mut subscribers {
349			let mut group = subscriber.next_group().await.unwrap().unwrap();
350			let payload = group.read_frame().await.unwrap().unwrap();
351			let frame = hang::container::Frame::decode(payload.payload).unwrap();
352			assert!(
353				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
354				"{name} output is not Annex-B"
355			);
356			let total = group.finished().await.unwrap();
357			assert_eq!(total, 5, "{name} dropped frames");
358		}
359
360		producer_task.abort();
361		transcoder.abort();
362	}
363
364	/// The multi-rung live path on real hardware: one shared NVDEC session
365	/// decodes the source, the GPU box filter resizes per rung, and each rung's
366	/// NVENC session encodes the CUDA frame in place. Skips without a GPU.
367	#[cfg_attr(
368		target_os = "windows",
369		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
370	)]
371	#[tokio::test]
372	async fn live_multi_rung_hardware() {
373		if !hardware_available() {
374			eprintln!("skipping: no hardware decoder + encoder available");
375			return;
376		}
377		// Real time on purpose, unlike most timed tests here. The rungs encode on
378		// their own threads (`encode::Sink`), so a rung waiting on one looks idle
379		// to tokio and `pause()` auto-advances the source's sleep while the encode
380		// is still in flight. The source then outruns the feed's bounded broadcast
381		// and every rung sees `Lagged` instead of its frames. Real sleeps pace the
382		// source against the encoders the way a live source does.
383		let (source, producer_task) = source_broadcast_live(3, 5);
384		// 180p and 120p: NVENC rejects tiny frames (80x60 is below its minimum
385		// encode resolution), so the hardware ladder stays a bit larger than the
386		// software test's.
387		let mut config = Config {
388			rungs: vec![Rung::new(180, 200_000), Rung::new(120, 100_000)],
389			encoder: moq_video::encode::Kind::Hardware,
390			decoder: moq_video::decode::Kind::Hardware,
391			source: None,
392			..Default::default()
393		};
394		config.resize.acceleration = moq_video::resize::Acceleration::Gpu;
395
396		let output = moq_net::broadcast::Info::default().produce();
397		let consumer = output.consume();
398		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
399
400		let mut subscribers = Vec::new();
401		for name in ["video/180p", "video/120p"] {
402			let track = loop {
403				match consumer.track(name) {
404					Ok(track) => break track,
405					Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
406					Err(err) => panic!("rung track {name}: {err}"),
407				}
408			};
409			subscribers.push((name, track.subscribe(None).await.unwrap()));
410		}
411
412		for (name, subscriber) in &mut subscribers {
413			let mut group = subscriber.next_group().await.unwrap().unwrap();
414			let payload = group.read_frame().await.unwrap().unwrap();
415			let frame = hang::container::Frame::decode(payload.payload).unwrap();
416			assert!(
417				frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
418				"{name} output is not Annex-B"
419			);
420			let total = group.finished().await.unwrap();
421			assert_eq!(total, 5, "{name} dropped frames");
422		}
423
424		producer_task.abort();
425		transcoder.abort();
426	}
427
428	/// Whether a hardware decoder AND encoder are usable here (e.g. a Linux box
429	/// with the NVIDIA driver). Probed through the public API so the hardware
430	/// test skips cleanly on GPU-less CI.
431	fn hardware_available() -> bool {
432		let mut encode = moq_video::encode::Config::new(160, 120, 30);
433		encode.kind = moq_video::encode::Kind::Hardware;
434		if moq_video::encode::Encoder::new(&encode).is_err() {
435			return false;
436		}
437
438		let video = hang::catalog::VideoConfig::new(hang::catalog::H264 {
439			inline: true,
440			profile: 0x42,
441			constraints: 0,
442			level: 30,
443		});
444		let mut decode = moq_video::decode::Config::new();
445		decode.kind = moq_video::decode::Kind::Hardware;
446		moq_video::decode::Decoder::new(&video, &decode).is_ok()
447	}
448
449	/// The GPU pipeline end to end: hardware decode (NVDEC, scaling in the
450	/// decoder) into hardware encode (NVENC, consuming the CUDA frame in place).
451	/// Skips on machines without both; on a Linux + NVIDIA box this is the
452	/// zero-copy transcode path under the real broadcast plumbing.
453	#[cfg_attr(
454		target_os = "windows",
455		ignore = "explicit live-DXVA GPU probe; VideoProcessorBlt can hang on affected drivers"
456	)]
457	#[tokio::test]
458	async fn end_to_end_hardware() {
459		if !hardware_available() {
460			eprintln!("skipping: no hardware decoder + encoder available");
461			return;
462		}
463
464		let source = source_broadcast(2, 5);
465		let mut config = Config {
466			rungs: vec![Rung::new(120, 100_000)],
467			encoder: moq_video::encode::Kind::Hardware,
468			decoder: moq_video::decode::Kind::Hardware,
469			source: None,
470			..Default::default()
471		};
472		config.resize.acceleration = moq_video::resize::Acceleration::Gpu;
473
474		let output = moq_net::broadcast::Info::default().produce();
475		let consumer = output.consume();
476		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
477
478		// Fetch a specific group: runs a one-shot pipeline to completion, so all
479		// 5 source frames must come through the GPU path.
480		let track = loop {
481			match consumer.track("video/120p") {
482				Ok(track) => break track,
483				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
484				Err(err) => panic!("rung track: {err}"),
485			}
486		};
487		let mut fetched = track.fetch_group(0, None).await.unwrap();
488		let payload = fetched.read_frame().await.unwrap().unwrap();
489		let frame = hang::container::Frame::decode(payload.payload).unwrap();
490		assert!(
491			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
492			"hardware rung output is not Annex-B"
493		);
494		let total = fetched.finished().await.unwrap();
495		assert_eq!(total, 5, "hardware transcode dropped frames");
496
497		transcoder.abort();
498	}
499
500	#[tokio::test]
501	async fn end_to_end() {
502		let source = source_broadcast(2, 5);
503
504		let config = Config {
505			rungs: vec![Rung::new(120, 100_000)],
506			encoder: moq_video::encode::Kind::Software,
507			decoder: moq_video::decode::Kind::Software,
508			source: Some(moq_net::PathRelativeOwned::from(".".to_string())),
509			..Default::default()
510		};
511
512		let output = moq_net::broadcast::Info::default().produce();
513		let consumer = output.consume();
514		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
515
516		// The derivative catalog appears before anything is encoded, with the
517		// rung sized against the source and the passthrough reference. Yield
518		// until the spawned transcoder has run its synchronous prologue (the
519		// catalog tracks and dynamic handler register before its first await).
520		let track = loop {
521			match consumer.track(hang::Catalog::DEFAULT_NAME) {
522				Ok(track) => break track,
523				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
524				Err(err) => panic!("catalog track: {err}"),
525			}
526		};
527		let track = track.subscribe(None).await.unwrap();
528		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track);
529		// The catalog track exists from the start but may open empty; the rung
530		// appears once the transcoder has read the source catalog.
531		let derived = loop {
532			let snapshot = catalogs.next().await.unwrap().unwrap();
533			if snapshot.video.renditions.contains_key("video/120p") {
534				break snapshot;
535			}
536		};
537
538		let rung = derived.video.renditions.get("video/120p").expect("rung missing");
539		assert_eq!(rung.coded_width, Some(160));
540		assert_eq!(rung.coded_height, Some(120));
541		assert_eq!(rung.bitrate, Some(100_000));
542		assert!(rung.codec.to_string().starts_with("avc3."));
543
544		let passthrough = derived.video.renditions.get("video").expect("passthrough missing");
545		assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some("."));
546
547		// Subscribing to the rung starts the live loop, which mirrors source
548		// group sequences 1:1.
549		let mut subscriber = consumer.track("video/120p").unwrap().subscribe(None).await.unwrap();
550		let mut group = subscriber.next_group().await.unwrap().unwrap();
551		assert!(group.sequence <= 1, "unexpected sequence {}", group.sequence);
552		let payload = group.read_frame().await.unwrap().unwrap();
553		let frame = hang::container::Frame::decode(payload.payload).unwrap();
554		assert!(
555			frame.payload.starts_with(&[0, 0, 0, 1]) || frame.payload.starts_with(&[0, 0, 1]),
556			"rung output is not Annex-B"
557		);
558
559		// Fetching a specific past group transcodes source group 0 on demand.
560		let mut fetched = consumer
561			.track("video/120p")
562			.unwrap()
563			.fetch_group(0, None)
564			.await
565			.unwrap();
566		let mut timestamps = Vec::new();
567		let mut first_payload = None;
568		while let Some(payload) = fetched.read_frame().await.unwrap() {
569			let frame = hang::container::Frame::decode(payload.payload).unwrap();
570			assert!(!frame.payload.is_empty());
571			timestamps.push(frame.timestamp.as_micros());
572			first_payload = first_payload.or(Some(frame.payload));
573		}
574
575		// The group has to open on an IDR, or a subscriber starting here decodes
576		// nothing: the rung asks its encoder for one at every group boundary. An
577		// Annex-B start code alone doesn't prove it, since a delta frame has one too,
578		// so check the NAL types: SPS (7) and PPS (8) inline ahead of an IDR (5),
579		// which is what avc3 promises.
580		let types = nal_types(&first_payload.expect("the group had no frames"));
581		assert!(types.contains(&7), "group does not open with an SPS: {types:?}");
582		assert!(types.contains(&8), "group does not open with a PPS: {types:?}");
583		assert!(types.contains(&5), "group does not open with an IDR: {types:?}");
584		// Each output frame keeps the presentation time of the source frame it was
585		// transcoded from, including the tail the encoder drains at the end of the
586		// group. Collapsing them onto one instant would stall playback here.
587		assert_eq!(timestamps, (0..5).map(|i| i * 33_333).collect::<Vec<u128>>());
588		// The fetched group is complete: the source group had 5 frames, and a
589		// finished transcode carries them all through.
590		let total = fetched.finished().await.unwrap();
591		assert_eq!(total, 5);
592
593		transcoder.abort();
594	}
595
596	/// `run` must terminate (not hang in its shutdown drain) when the source
597	/// broadcast goes away, even with a rung task that was never subscribed.
598	#[tokio::test]
599	async fn shuts_down_on_source_end() {
600		let source = source_broadcast(1, 3);
601
602		let config = Config {
603			rungs: vec![Rung::new(120, 100_000)],
604			encoder: moq_video::encode::Kind::Software,
605			decoder: moq_video::decode::Kind::Software,
606			source: None,
607			..Default::default()
608		};
609
610		let output = moq_net::broadcast::Info::default().produce();
611		let consumer = output.consume();
612		let transcoder = tokio::spawn(run(source.broadcast.consume(), output, config));
613
614		// Wait until the derivative catalog is up, so the transcoder is past
615		// startup and into its serve loop.
616		let track = loop {
617			match consumer.track(hang::Catalog::DEFAULT_NAME) {
618				Ok(track) => break track,
619				Err(moq_net::Error::NotFound) => tokio::task::yield_now().await,
620				Err(err) => panic!("catalog track: {err}"),
621			}
622		};
623		let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap());
624		catalogs.next().await.unwrap().unwrap();
625
626		// Drop the source: the catalog track ends and the broadcast closes, so
627		// `run` should observe the end and return rather than block in the drain.
628		drop(source);
629
630		let result = tokio::time::timeout(std::time::Duration::from_secs(5), transcoder).await;
631		result.expect("run did not shut down within 5s").unwrap().unwrap();
632	}
633}