From bbe911c6e94233020ebd125d5a04870cb5fb36d0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 23:44:44 -0700 Subject: [PATCH 1/9] quest(m1): claim audit-test-coverage From f88dd6c3c19a54990b7bfdf853a496b23d140282 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:20:57 -0700 Subject: [PATCH 2/9] fix(audio): open PulseAudio output with 20 ms periods, and let device tests wait for startup cpal's PulseAudio host maps BufferSize::Default to the server's default buffer: a 2 s tlength, 3.96 s end to end, filled in bursts. A new sink's writes were dropped until the next burst read them. Ask PulseAudio for 20 ms periods instead; other hosts keep their defaults. The device tests also assumed playback starts within 100 ms of opening. PulseAudio's idle null sink renders up to two seconds ahead, so on the nightly ARM runner every test failed before the device first pulled. They now wait until the device's clock moves on its own before writing. the_device_buffers_periods_not_seconds fails without the buffer fix (1.99 s ahead of the speaker). Co-Authored-By: Claude Opus 5.5 --- rs/moq-audio/src/aec.rs | 6 ++- rs/moq-audio/src/playback/device.rs | 20 ++++++++ rs/moq-audio/src/playback/driver.rs | 3 +- rs/moq-audio/tests/integration/playback.rs | 56 +++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/rs/moq-audio/src/aec.rs b/rs/moq-audio/src/aec.rs index b19552a05ea..8ed9dd2d350 100644 --- a/rs/moq-audio/src/aec.rs +++ b/rs/moq-audio/src/aec.rs @@ -1143,7 +1143,11 @@ mod tests { let mut energy = 0.0f64; let mut buf = vec![0.0f32; REFERENCE_FRAME * REFERENCE_CHANNELS]; - for _ in 0..20 { + // Until the tap hears it rather than for a fixed count: writes are dropped + // until the device first reads, and a device can take seconds to start + // (PulseAudio's idle null sink renders two seconds ahead). + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while energy <= 1.0 && tokio::time::Instant::now() < deadline { let _ = sink.write(&tone).expect("write"); tokio::time::sleep(std::time::Duration::from_millis(100)).await; diff --git a/rs/moq-audio/src/playback/device.rs b/rs/moq-audio/src/playback/device.rs index 680e6a80f7b..d3156e77ae4 100644 --- a/rs/moq-audio/src/playback/device.rs +++ b/rs/moq-audio/src/playback/device.rs @@ -148,6 +148,26 @@ pub(super) fn negotiate(device: &cpal::Device) -> Result cpal::BufferSize { + #[cfg(feature = "pulseaudio")] + if let (Ok(id), cpal::SupportedBufferSize::Range { min, max }) = (device.id(), supported.buffer_size()) + && id.host() == cpal::HostId::PulseAudio + { + return cpal::BufferSize::Fixed((supported.sample_rate() / 50).clamp(*min, *max)); + } + cpal::BufferSize::Default +} + /// Pick the best of the stream configurations a device reports. /// /// Split out from [`negotiate`] so it can be tested without a device: the diff --git a/rs/moq-audio/src/playback/driver.rs b/rs/moq-audio/src/playback/driver.rs index 03a1dc9d47a..897b21cf417 100644 --- a/rs/moq-audio/src/playback/driver.rs +++ b/rs/moq-audio/src/playback/driver.rs @@ -777,7 +777,8 @@ impl Driver { let supported = super::device::negotiate(&device)?; let format = supported.sample_format(); - let config: cpal::StreamConfig = supported.into(); + let mut config = supported.config(); + config.buffer_size = super::device::buffer_size(&device, &supported); let rate = config.sample_rate; let channels = config.channels as usize; diff --git a/rs/moq-audio/tests/integration/playback.rs b/rs/moq-audio/tests/integration/playback.rs index d2c141a0424..e1362a5a995 100644 --- a/rs/moq-audio/tests/integration/playback.rs +++ b/rs/moq-audio/tests/integration/playback.rs @@ -42,6 +42,33 @@ fn input() -> playback::Input { input } +/// Open a device with [`open_any`] and wait until it pulls on its own clock. +/// +/// A sink's writes are dropped until the device first reads them, and a device +/// can take a while to start: PulseAudio's null sink, while idle, renders up +/// to two seconds ahead and only picks a new stream up after that. Without the +/// wait, every test here passed or failed on how that startup fell. +async fn open() -> playback::Engine { + let engine = open_any().await; + let mut observer = engine.observe().expect("an observer"); + + // The first pull fills the device's buffer at once, so only a clock that + // keeps moving after it shows the device is playing. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut first = None; + loop { + let clock = observer.read().expect("an observation").clock; + if !clock.is_zero() && clock >= *first.get_or_insert(clock) + Duration::from_millis(100) { + return engine; + } + assert!( + tokio::time::Instant::now() < deadline, + "the device never started pulling" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + /// Open `$MOQ_AUDIO_DEVICE`, the system default, or the first device that will /// have us. /// @@ -51,7 +78,7 @@ fn input() -> playback::Input { /// keeps these tests about our code rather than about the box's audio routing, /// and the environment variable pins a specific device when you want to hear /// the result rather than only meter it. -async fn open() -> playback::Engine { +async fn open_any() -> playback::Engine { if let Ok(id) = std::env::var("MOQ_AUDIO_DEVICE") { let mut config = playback::Config::default(); config.device = Some(id); @@ -102,6 +129,33 @@ async fn plays_a_tone_through_the_default_device() { assert!(drained, "the device never drained the queue"); } +/// The device runs a period or two ahead of the speaker, not seconds. +/// +/// PulseAudio's unconfigured buffer is two seconds, filled in bursts, so a +/// sink's first writes were dropped until the next burst read it, and every +/// test above passed or failed on how the bursts happened to fall. +#[tokio::test] +#[ignore = "needs an output device; run by just rs audio-devices"] +async fn the_device_buffers_periods_not_seconds() { + let engine = open().await; + let mut observer = engine.observe().expect("an observer"); + + let mut latency = None; + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(50)).await; + latency = observer.read().expect("an observation").latency; + if latency.is_some() { + break; + } + } + + let latency = latency.expect("the host never reported a latency"); + assert!( + latency < Duration::from_millis(500), + "the device runs {latency:?} ahead of the speaker" + ); +} + /// Two sinks on one device, which is the case a call with several participants /// hits and the reason `Engine` and `Sink` are separate types. #[tokio::test] From 0555be6b9503962b2d115b494f079ff48aa4c367 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:22:17 -0700 Subject: [PATCH 3/9] test(net): cover the Zod frame reader `@moq/net/zod` is exported but no test loaded it. Pin that falsy JSON frames are values rather than the end of the group, and that a frame the schema refuses throws. Co-Authored-By: Claude Opus 5.5 --- js/net/src/zod.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 js/net/src/zod.test.ts diff --git a/js/net/src/zod.test.ts b/js/net/src/zod.test.ts new file mode 100644 index 00000000000..ddc8cee2798 --- /dev/null +++ b/js/net/src/zod.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import * as z from "@zod/mini"; +import { Producer } from "./group.ts"; +import { textFrame } from "./mock.ts"; +import * as Zod from "./zod.ts"; + +test("read validates each frame and ends only at the end of the group", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + // Falsy values are frames like any other, not the end of the stream. + for (const json of ["0", "false", "null", '"ok"']) producer.writeFrame(textFrame(json)); + producer.close(); + + const schema = z.union([z.number(), z.boolean(), z.null(), z.string()]); + expect(await Zod.read(consumer, schema)).toBe(0); + expect(await Zod.read(consumer, schema)).toBe(false); + expect(await Zod.read(consumer, schema)).toBe(null); + expect(await Zod.read(consumer, schema)).toBe("ok"); + expect(await Zod.read(consumer, schema)).toBeUndefined(); +}); + +test("read rejects a frame the schema refuses", async () => { + const producer = new Producer(0); + const consumer = producer.consume(); + producer.writeFrame(textFrame('{"count":"three"}')); + producer.close(); + + await expect(Zod.read(consumer, z.object({ count: z.number() }))).rejects.toThrow(); +}); From d44ccc786b359279e904c0a4bd953c63b4f25203 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:26:09 -0700 Subject: [PATCH 4/9] fix(video): drive AMD and Intel GPUs through the flake's Mesa in `just rs gpu` `vulkan_gpu_device` could not load a Vulkan loader inside the Nix shell on a non-NixOS host, so `just rs gpu` failed before testing anything. The AMD and Intel branches now use nixpkgs Mesa's RADV or ANV driver and the vulkan-loader, as `just rs render-sw` does with lavapipe. Each run loads only its vendor's ICD, so the vendor assertion still means something. NVIDIA keeps the host driver, which GPU CI owns. Co-Authored-By: Claude Opus 5.5 --- sh/rs/gpu.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/sh/rs/gpu.sh b/sh/rs/gpu.sh index 72e8b956984..cf7899e1c4e 100755 --- a/sh/rs/gpu.sh +++ b/sh/rs/gpu.sh @@ -27,9 +27,23 @@ done } for vendor in "${!vendors[@]}"; do - MOQ_GPU_VENDOR="$vendor" just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' case "$vendor" in - 0x10de) sh/rs/vulkan-cuda.sh ;; - 0x1002 | 0x8086) echo "GPU $vendor: Vulkan driver verified; encoder hardware tests arrive with its backend" ;; + 0x10de) + MOQ_GPU_VENDOR="$vendor" just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' + sh/rs/vulkan-cuda.sh + ;; + 0x1002 | 0x8086) + # The Nix shell has no Vulkan loader, and the host's Mesa links the host's + # libc, so drive the GPU through the flake's own Mesa, as `just rs render-sw` does. + mesa=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#mesa) + loader=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#vulkan-loader) + case "$vendor" in + 0x1002) icd=("$mesa"/share/vulkan/icd.d/radeon_icd.*.json) ;; + 0x8086) icd=("$mesa"/share/vulkan/icd.d/intel_icd.*.json) ;; + esac + LD_LIBRARY_PATH="$loader/lib" VK_DRIVER_FILES="${icd[0]}" MOQ_GPU_VENDOR="$vendor" \ + just rs test -p moq-video --run-ignored only -E 'test(vulkan_gpu_device)' + echo "GPU $vendor: Vulkan driver verified; encoder hardware tests arrive with its backend" + ;; esac done From ca4e7cb6185d79d3030fa4298a57e18982d70b62 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:31:37 -0700 Subject: [PATCH 5/9] test(mux): decode every video format through the import facades `import::Track` is what moq-ffi, moq-c, and moq-gst publish through, and `TrackStream` is the FFI's raw byte-stream producer. Coverage showed the Track dispatch for avc1, hev1, av01, and vp9 and every TrackStream decode and finish never ran. Land a keyframe through each, and split each raw stream in 3-byte chunks so `finish` must flush the last picture (fails if it does not). Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/import/track.rs | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 1da51bf77fc..04bff6c1fb2 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -1389,4 +1389,133 @@ mod tests { import.finish().unwrap(); } + + const H264_IDR: &[u8] = &[0x65, 0x88, 0x84, 0x21]; + const H265_IDR: &[u8] = &[0x26, 0x01, 0x80, 0xaa]; + + fn annexb(nals: &[&[u8]]) -> Vec { + nals.iter().flat_map(|nal| [&[0, 0, 0, 1], *nal].concat()).collect() + } + + fn length_prefixed(nal: &[u8]) -> Vec { + [&(nal.len() as u32).to_be_bytes()[..], nal].concat() + } + + /// `av1.mp4`'s av1C, and a temporal unit of its sequence header and a key frame. + fn av1_init_and_keyframe() -> (Vec, Vec) { + let mp4 = include_bytes!("../container/fmp4/test_data/av1.mp4"); + let mut cursor = std::io::Cursor::new(&mp4[..]); + let moov = loop { + if let mp4_atom::Any::Moov(moov) = ::decode(&mut cursor).unwrap() { + break moov; + } + }; + let mp4_atom::Codec::Av01(av01) = &moov.trak[0].mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected av01"); + }; + let mut av1c = Vec::new(); + mp4_atom::Encode::encode(&av01.av1c, &mut av1c).unwrap(); + // Skip the box header: the importer takes the record itself. + let av1c = av1c[8..].to_vec(); + + let mut tu = vec![0x12, 0x00]; + tu.extend_from_slice(&av01.av1c.config_obus); + tu.extend_from_slice(&[0x32, 0x01, 0x10]); + (av1c, tu) + } + + /// Subscribe to `name` and read its first frame. + async fn first_frame(consumer: &moq_net::broadcast::Consumer, name: &str) -> crate::container::Frame { + let track = consumer.track(name).unwrap().subscribe(None).await.unwrap(); + let mut media = crate::container::Consumer::new( + track, + crate::catalog::hang::Container::Legacy(crate::container::Kind::Data), + ); + tokio::time::timeout(Duration::from_secs(1), media.read_frame()) + .await + .expect("a frame") + .unwrap() + .expect("the track ended without a frame") + } + + /// Every video format a binding can hand [`Track::video`] lands a keyframe on + /// its track. The facade dispatches per codec, and before this only Avc3, + /// Hvc1, and VP8 had ever decoded through it. + #[tokio::test(start_paused = true)] + async fn every_video_format_delivers_a_keyframe() { + use crate::codec::{h264, h265}; + + let sps = &h264_init()[4..29]; + let avcc = [ + &[1, sps[1], sps[2], sps[3], 0xff, 0xe1][..], + &(sps.len() as u16).to_be_bytes(), + sps, + &[1], + &(h264::fixtures::PPS.len() as u16).to_be_bytes(), + h264::fixtures::PPS, + ] + .concat(); + let (av1c, av1_keyframe) = av1_init_and_keyframe(); + let vp9_keyframe = vec![0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]; + + let cases = [ + (VideoFormat::Avc1, avcc, length_prefixed(H264_IDR)), + ( + VideoFormat::Hev1, + Vec::new(), + annexb(&[h265::fixtures::VPS, h265::fixtures::SPS, h265::fixtures::PPS, H265_IDR]), + ), + (VideoFormat::Av01, av1c, av1_keyframe), + (VideoFormat::Vp9, Vec::new(), vp9_keyframe), + ]; + for (format, init, keyframe) in cases { + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("video").unwrap(); + let mut import = Track::video(request, catalog.reserve(), VideoInit::new(format, init)).unwrap(); + + import.decode(keyframe, Some(Timestamp::from_micros(1_000).unwrap())).unwrap(); + let frame = first_frame(&consumer, "video").await; + assert!(frame.keyframe, "{format}: the first frame opens a group"); + assert_eq!(frame.timestamp, Timestamp::from_micros(1_000).unwrap(), "{format}"); + assert!( + catalog.snapshot().video.renditions.contains_key("video"), + "{format}: no rendition" + ); + import.finish().unwrap(); + } + } + + /// [`TrackStream`] finds the frames in a raw byte stream however it is chunked, + /// and `finish` flushes the last one. Nothing had decoded through it before. + #[tokio::test(start_paused = true)] + async fn track_stream_splits_a_chunked_byte_stream() { + use crate::codec::h265; + + let (_, av1_keyframe) = av1_init_and_keyframe(); + let cases = [ + (VideoFormat::Avc3, [h264_init(), annexb(&[H264_IDR])].concat(), H264_IDR), + ( + VideoFormat::Hev1, + annexb(&[h265::fixtures::VPS, h265::fixtures::SPS, h265::fixtures::PPS, H265_IDR]), + H265_IDR, + ), + (VideoFormat::Av01, av1_keyframe.clone(), &av1_keyframe[av1_keyframe.len() - 3..]), + ]; + for (format, stream, last) in cases { + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("video").unwrap(); + let mut import = TrackStream::video(request, catalog.reserve(), VideoInit::new(format, Vec::new())).unwrap(); + + for chunk in stream.chunks(3) { + import.decode(chunk).unwrap(); + } + import.finish().unwrap(); + + let frame = first_frame(&consumer, "video").await; + assert!(frame.keyframe, "{format}: the first frame opens a group"); + assert!(frame.payload.ends_with(last), "{format}: the frame ends with its picture"); + } + } } From b2f663ffa680574dec4593c7b0e03ec9e91b54ca Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:34:33 -0700 Subject: [PATCH 6/9] test(ffi): drive the codec-only video encoder and the raw-stream producer `MoqVideoEncoder` backs `moq.video.Encoder` in every wrapper, and `MoqMediaTrackStreamProducer::write` is how they publish raw Annex-B; no test in any language reached either. Encode pictures and check keyframes land at the start and at a cut, and write a chunked H.264 stream through the stream producer. The stream producer's `finish` doc (and the Swift wrapper's copy) said a trailing access unit is dropped. It is published: `finish` flushes the splitter, which the new test shows. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/media.rs | 6 +---- rs/moq-ffi/src/test.rs | 33 ++++++++++++++++++++++++++ rs/moq-ffi/src/video.rs | 44 +++++++++++++++++++++++++++++++++++ swift/Sources/Moq/Media.swift | 4 ++-- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 2ada4028234..c9262b2a458 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -1066,11 +1066,7 @@ impl MoqMediaTrackStreamProducer { Ok(()) } - /// Finalize the track. - /// - /// The importer emits each access unit when the *next* one's start code arrives, so a trailing - /// access unit with no following delimiter (e.g. the last frame at EOF) is not emitted. This - /// matches moq-cli's stdin path. + /// Finalize the track, publishing the last access unit still waiting for a following start code. pub fn finish(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); let mut guard = self.inner.lock().unwrap(); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index f64b91adc8e..7328cbc9f44 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -2208,6 +2208,39 @@ async fn video_publish_named_track() { assert!(catalog.video.contains_key("hd"), "catalog: {:?}", catalog.video.keys()); } +/// The raw-stream producer frames Annex-B written in arbitrary chunks, and +/// `finish` publishes the last access unit even with no start code after it. +#[tokio::test] +async fn track_stream_producer_publishes_its_last_access_unit() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let consumer = broadcast.consume().unwrap(); + let stream = MoqMediaTrackStreamProducer::video( + &broadcast, + MoqMediaTarget::Named { + name: Some("video".into()), + }, + video_init(MoqVideoFormat::Avc3, Vec::new()), + ) + .unwrap(); + let track = consumer.subscribe_track("video".into(), None).await.unwrap(); + + let idr = [0x65, 0x88, 0x84, 0x21]; + let mut bytes = h264_init(); + bytes.extend_from_slice(&[0, 0, 0, 1]); + bytes.extend_from_slice(&idr); + for chunk in bytes.chunks(5) { + stream.write(chunk.to_vec()).unwrap(); + } + stream.finish().unwrap(); + + let frame = tokio::time::timeout(TIMEOUT, track.read_frame()) + .await + .expect("timed out waiting for the access unit") + .unwrap() + .expect("finish dropped the last access unit"); + assert!(frame.payload.ends_with(&idr), "{:02x?}", frame.payload); +} + #[tokio::test] async fn requested_track_keeps_its_name() { let broadcast = MoqBroadcastProducer::new().unwrap(); diff --git a/rs/moq-ffi/src/video.rs b/rs/moq-ffi/src/video.rs index abaf4ca0b29..2b06f339e04 100644 --- a/rs/moq-ffi/src/video.rs +++ b/rs/moq-ffi/src/video.rs @@ -1016,6 +1016,50 @@ mod decode_tests { mod finish_tests { use super::*; + /// The codec-only encoder every wrapper exposes (`moq.video.Encoder` and + /// friends): a keyframe opens the stream, `cut` forces the next one, and + /// `finish` closes it. No test in any language had driven it. + #[test] + fn encoder_emits_keyframes_where_asked() { + let (width, height) = (64u32, 48u32); + let encoder = MoqVideoEncoder::new( + MoqVideoEncoderInput { + format: MoqVideoPixelFormat::I420, + width, + height, + framerate: 30, + }, + MoqVideoEncoderOutput { + codec: MoqVideoCodec::H264, + track: None, + bitrate: Some(500_000), + gop: None, + kind: MoqVideoEncoderKind::Software, + }, + ) + .unwrap(); + let picture = |i: u64| MoqVideoFrame { + timestamp_us: i * 33_333, + data: vec![(i * 40) as u8; (width * height * 3 / 2) as usize], + }; + + let mut packets = Vec::new(); + for i in 0..3 { + packets.extend(encoder.encode(picture(i)).unwrap()); + } + packets.extend(encoder.flush().unwrap()); + encoder.set_bitrate(250_000).unwrap(); + encoder.cut().unwrap(); + packets.extend(encoder.encode(picture(3)).unwrap()); + packets.extend(encoder.finish().unwrap()); + + let keyframes: Vec = packets.iter().filter(|p| p.keyframe).map(|p| p.timestamp_us).collect(); + assert_eq!(packets.len(), 4, "one access unit per picture"); + assert_eq!(keyframes, [0, 3 * 33_333], "keyframes at the start and the cut"); + assert!(packets.iter().all(|p| !p.data.is_empty()), "an empty access unit"); + assert!(matches!(encoder.encode(picture(4)), Err(MoqError::Closed))); + } + #[tokio::test] async fn finish_ends_the_track_after_a_rate_follower_panic() { let estimate = moq_net::bandwidth::Producer::new(); diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift index ca86c5b8fe6..a2390e3f693 100644 --- a/swift/Sources/Moq/Media.swift +++ b/swift/Sources/Moq/Media.swift @@ -335,8 +335,8 @@ public enum Media { try ffi.write(payload: payload) } - /// Finalize the track. A trailing access unit with no following delimiter is - /// not emitted (matches the moq-cli stdin path). + /// Finalize the track, publishing the last access unit still waiting for a + /// following start code. public func finish() throws { try ffi.finish() } From 34ec93331218df65f81c829b38f2bc164d1d03c6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:36:13 -0700 Subject: [PATCH 7/9] test(mux): publish MP3 through the import facade The MP3 importer never ran. Publish frames through `Track::audio` and check the catalog comes from the header and a group spans frames until the caller cuts. Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/import/track.rs | 68 ++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 04bff6c1fb2..4647726e3ce 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -1474,7 +1474,9 @@ mod tests { let request = broadcast.reserve_track("video").unwrap(); let mut import = Track::video(request, catalog.reserve(), VideoInit::new(format, init)).unwrap(); - import.decode(keyframe, Some(Timestamp::from_micros(1_000).unwrap())).unwrap(); + import + .decode(keyframe, Some(Timestamp::from_micros(1_000).unwrap())) + .unwrap(); let frame = first_frame(&consumer, "video").await; assert!(frame.keyframe, "{format}: the first frame opens a group"); assert_eq!(frame.timestamp, Timestamp::from_micros(1_000).unwrap(), "{format}"); @@ -1486,6 +1488,56 @@ mod tests { } } + /// MP3 through [`Track::audio`]: the catalog comes from the frame header, and + /// a group spans frames until the caller cuts it. No test had published MP3. + #[tokio::test(start_paused = true)] + async fn mp3_groups_span_frames_until_a_cut() { + // MPEG-1 Layer III, 128 kbps, 44.1 kHz, joint stereo. + let header = [0xFF, 0xFB, 0x90, 0x44]; + let (broadcast, catalog) = new_broadcast(); + let consumer = broadcast.consume(); + let request = broadcast.reserve_track("audio").unwrap(); + let mut import = Track::audio( + request, + catalog.reserve(), + AudioInit::new(AudioFormat::Mp3, header.to_vec()), + ) + .unwrap(); + + let audio = catalog.snapshot().audio.renditions["audio"].clone(); + assert_eq!((audio.sample_rate, audio.channel_count), (44_100, 2)); + + // Delay-tolerant, so the first group is still delivered once the second exists. + let replay = moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(30)); + let mut track = consumer.track("audio").unwrap().subscribe(Some(replay)).await.unwrap(); + for i in 0..4u64 { + if i == 2 { + import.cut(None).unwrap(); + } + let frame = [&header[..], &[i as u8; 8]].concat(); + import + .decode(frame, Some(Timestamp::from_micros(i * 26_122).unwrap())) + .unwrap(); + } + import.finish().unwrap(); + + // Frames carrying audio per group, ignoring any empty end-of-group marker. + let mut groups = Vec::new(); + while let Some(mut group) = track.recv_group().await.unwrap() { + let mut frames = 0; + while let Some(frame) = group.read_frame().await.unwrap() { + frames += usize::from( + !hang::container::Frame::decode(frame.payload) + .unwrap() + .payload + .is_empty(), + ); + } + groups.push(frames); + } + assert_eq!(groups, [2, 2]); + } + /// [`TrackStream`] finds the frames in a raw byte stream however it is chunked, /// and `finish` flushes the last one. Nothing had decoded through it before. #[tokio::test(start_paused = true)] @@ -1500,13 +1552,18 @@ mod tests { annexb(&[h265::fixtures::VPS, h265::fixtures::SPS, h265::fixtures::PPS, H265_IDR]), H265_IDR, ), - (VideoFormat::Av01, av1_keyframe.clone(), &av1_keyframe[av1_keyframe.len() - 3..]), + ( + VideoFormat::Av01, + av1_keyframe.clone(), + &av1_keyframe[av1_keyframe.len() - 3..], + ), ]; for (format, stream, last) in cases { let (broadcast, catalog) = new_broadcast(); let consumer = broadcast.consume(); let request = broadcast.reserve_track("video").unwrap(); - let mut import = TrackStream::video(request, catalog.reserve(), VideoInit::new(format, Vec::new())).unwrap(); + let mut import = + TrackStream::video(request, catalog.reserve(), VideoInit::new(format, Vec::new())).unwrap(); for chunk in stream.chunks(3) { import.decode(chunk).unwrap(); @@ -1515,7 +1572,10 @@ mod tests { let frame = first_frame(&consumer, "video").await; assert!(frame.keyframe, "{format}: the first frame opens a group"); - assert!(frame.payload.ends_with(last), "{format}: the frame ends with its picture"); + assert!( + frame.payload.ends_with(last), + "{format}: the frame ends with its picture" + ); } } } From 1e0987446c35e1c06b52c67c323180ff8df3ae80 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:38:50 -0700 Subject: [PATCH 8/9] quest: finish the m1 test coverage audit and schedule the m2 run Delete the m1 run and add its successor at the end of m2, after the release cut, with this run's baselines, findings, and what to check first. Plan the two follow-ups it found: VA-API renderer tests with no caller, and PulseAudio capture likely sharing playback's 2 s buffer. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/audit-test-coverage.md | 117 --------------------- quest/m2/README.md | 3 + quest/m2/audit-test-coverage.md | 143 ++++++++++++++++++++++++++ quest/m2/pulseaudio-capture-period.md | 32 ++++++ quest/m2/vaapi-render-tests.md | 39 +++++++ 6 files changed, 217 insertions(+), 118 deletions(-) delete mode 100644 quest/m1/audit-test-coverage.md create mode 100644 quest/m2/audit-test-coverage.md create mode 100644 quest/m2/pulseaudio-capture-period.md create mode 100644 quest/m2/vaapi-render-tests.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 0cec85655e8..5ce8e2ae50e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -122,7 +122,6 @@ lands before the cut. - [Dead code and API surface audit](/quest/m1/audit-dead-code.md) - repeat the removal audit before the next major release - [Dependency and alternatives audit](/quest/m1/audit-deps.md) - dependencies are current, deduplicated, needed, and the best maintained option -- [Test coverage audit](/quest/m1/audit-test-coverage.md) - repeat the coverage audit against the m0 baselines before the next major release - [Test cost audit](/quest/m1/audit-test-cost.md) - the slowest, flakiest, and least valuable tests are fixed, deleted, or planned - [Interop report](/quest/m1/interop-report.md) - nightly Interop results publish as a color-coded matrix on the Pages site instead of only in logs - [CI workflow audit](/quest/m1/audit-ci.md) - every workflow and job gives a signal someone sees, at a cost that signal justifies, or is fixed, moved, or deleted diff --git a/quest/m1/audit-test-coverage.md b/quest/m1/audit-test-coverage.md deleted file mode 100644 index 62355049d6e..00000000000 --- a/quest/m1/audit-test-coverage.md +++ /dev/null @@ -1,117 +0,0 @@ -# [M] Test coverage audit - -## Goal - -Behavior that matters but no test exercises is covered or planned, and every -test runs in CI. - -## Plan - -Recurs once per major release (decided 2026-10-19). The PR that finishes -this run also adds the next one with the same file name at the end of the next -milestone's `Required` (m0 to m1, m1 to m2). The successor keeps the Goal -and this paragraph; the rest of its Plan is rewritten with what this run -learned: baselines, results, and what to check first. A successor placed in m2 -also lists the [release cut](/quest/m2/release/cut.md) under `Required`, so it -waits for the release in flight to ship. Only `/quest-delete` ends the -recurrence. Fixes land in the run's PR, one commit each, rather than as follow-ups. -Only a change that needs a maintainer decision (public API, scope) or is -independently landable and too large to review alongside the rest becomes a -follow-up through `/quest-plan`. - -### Tools - -Nightly keeps both reports as artifacts: `just rs coverage` (cargo-llvm-cov -over nextest, the native workspace at default features, both fuzz packages -and the native moq-wasm stub excluded) and `just js coverage` (Bun's own -coverage, one process per package, line hits unioned across processes). -Start from those artifacts rather than re-running the instrumented -workspace, which is expensive on a shared machine. - -- `just rs coverage ` is not comparable to the workspace table: - without other packages' feature unification it compiles less of the - crate. moq-video measured 4553 / 7741 lines alone against 6431 / 9965 in - the workspace run. -- Default features hide feature-gated tests that do run nightly in the - `rs features` leg (`--all-features`), such as moq-video's encode producer - (`capture`, `openh264`). Check that leg before calling a low file untested. -- Bun 1.4.2 undercounts a module loaded again with a query suffix (signals - reported 19.38% in a run where it was 89.80%). Verify an apparent JS gap - before acting on it, and never merge packages into one `bun test` - process: package-level mocks then contaminate unrelated tests. - -### m0 baselines - -From `origin/main` at `2f1f062c6`: 8,848 native tests passed with 35 ignored, -and 18 JS package processes ran 3,678 tests. Lines covered / measured: - -| Rust crate | Lines | Rust crate | Lines | -| --- | --- | --- | --- | -| hang | 2848 / 2924 | moq-loc | 141 / 153 | -| kio | 2023 / 2115 | moq-msf | 674 / 684 | -| moq-archive | 4351 / 4480 | moq-mux | 63457 / 65700 | -| moq-audio | 5346 / 5610 | moq-net | 80294 / 84169 | -| moq-auth | 4894 / 5006 | moq-nvenc | 3445 / 7771 | -| moq-bench | 1614 / 2068 | moq-pattern | 931 / 960 | -| moq-boy | 138 / 671 | moq-quic | 21325 / 22420 | -| moq-c | 8967 / 9562 | moq-relay | 10839 / 11492 | -| moq-cli | 6578 / 7838 | moq-room | 342 / 369 | -| moq-clock | 1571 / 1625 | moq-rtc | 2500 / 2876 | -| moq-clock-macros | 27 / 33 | moq-rtmp | 6816 / 7630 | -| moq-e2ee | 679 / 759 | moq-rtsp | 1033 / 1057 | -| moq-ffi | 10756 / 11705 | moq-shaper | 2042 / 2116 | -| moq-flate | 839 / 892 | moq-sock | 1316 / 1486 | -| moq-gst | 6320 / 6698 | moq-srt | 1286 / 1481 | -| moq-hls | 8360 / 8741 | moq-stats | 4413 / 4600 | -| moq-ice | 319 / 361 | moq-tokio | 15702 / 17740 | -| moq-json | 4903 / 5237 | moq-transcode | 2758 / 2987 | -| moq-uring | 4643 / 5391 | moq-v4l | 9 / 1457 | -| moq-video | 6431 / 9965 | | | - -| JS package | Lines | Unloaded files | JS package | Lines | Unloaded files | -| --- | --- | --- | --- | --- | --- | -| common | 200 / 250 | 5 | loc | 127 / 145 | 0 | -| net | 17812 / 23595 | 4 | msf | 103 / 130 | 0 | -| p2p | 293 / 303 | 1 | video | 277 / 336 | 0 | -| clock | 286 / 300 | 1 | watch | 3075 / 4547 | 31 | -| pattern | 435 / 523 | 0 | publish | 2773 / 3308 | 22 | -| auth | 720 / 783 | 2 | room | 493 / 530 | 1 | -| e2ee | 568 / 569 | 2 | signals | 565 / 747 | 3 | -| flate | 233 / 333 | 0 | wasm | 0 / 0 | 0 | -| json | 831 / 1141 | 0 | moq-boy | 155 / 194 | 8 | -| hang | 2044 / 2859 | 1 | | | | - -These are not acceptance thresholds. Compare the next run's report against -them and explain what moved. - -### What m0 found - -Two shipped bugs hid in code no test executed: echo cancellation never -received its playback reference on a 44.1 kHz output (the resampled tap was -never marked read), and two DOM helpers dropped children or registered -cleanup against an emptied fragment. Both were found by running code that -existed but had no caller, which makes ignored and unloaded code the first -place to look. - -- Every `#[ignore]` carries a reason. Ignored benchmarks run in - `sh/gh/nightly.sh rust-test`; the m0 run gave a nightly recipe to every - other one a hosted runner can host: `rs quic-stress`, `rs audio-devices` - (a private PulseAudio null sink), `rs multicast` (mDNS loops back in the - kernel), and `rs render-sw` (wgpu on Mesa lavapipe). An `#[ignore]` whose - caller is neither a recipe nor a quest is the first finding. -- Hardware-only tests map to [GPU CI](/quest/m2/gpu-ci.md) (NVIDIA, and the - host Vulkan driver the VA-API renderer import tests also need inside the - Nix shell), [Video hardware validation](/quest/m3/video-hardware.md) - (Windows, Metal, cameras, portals), [Windows.Graphics.Capture](/quest/m2/capture-wgc.md), - [MediaCodec on an emulator](/quest/m2/android-mediacodec-ci.md), and - [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md). Check whether - those landed before re-auditing the same tests. -- Interop: [Bindings in the interop matrix](/quest/m2/interop-bindings.md) - adds Kotlin, Swift, and Dart; until it lands, those wrappers' network - delivery is untested. -- JS: `` runs in the interop player, and `` - has a Chromium test in `just test harness`. The React signals adapter is - still untested; it needs a DOM renderer. The remaining unloaded files are - type-only, worklets covered by the browser lanes, or formatters. -- The last 25 `fix` commits before the m0 run all carried a regression test - or were compile/link checks. Repeat that sample from the m0 PR forward. diff --git a/quest/m2/README.md b/quest/m2/README.md index 502885431fa..a9657ce3e04 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -53,6 +53,7 @@ ranked just below deterministic TS legs, which it requires. - [AudioToolbox encode](/quest/m2/audio-encode-audiotoolbox.md) - macOS and iOS encode AAC-LC - [CMAF surround Opus](/quest/m2/cmaf-opus-surround.md) - a released mp4-atom carries the `dOps` channel mapping table, and fMP4 import and export carry it through the OpusHead - [GPU CI](/quest/m2/gpu-ci.md) - NVIDIA tests run nightly on real hardware, and `just rs nvidia` runs them locally instead of skipping +- [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) - the ignored VA-API renderer import tests run in `just rs gpu` on AMD and Intel and fail instead of skipping - [Vendor-free CARLA bridge](/quest/m2/carla-bridge.md) - the CARLA demo's GPU bridge lets moq-video pick the encoder, and its stand-in test runs on AMD as well as NVIDIA - [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md) - V4L2 capture runs nightly against the `vivid` virtual driver instead of only on a developer's camera - [Probe](/quest/m2/probe/README.md) - a session probes up to the bitrate it wants to send or receive, so a stopped simulcast rendition resumes and a viewer up-switches @@ -75,6 +76,7 @@ ranked just below deterministic TS legs, which it requires. - [Android capture](/quest/m2/mobile-capture-android.md) - Rust captures through NDK/JNI on Android, reusing the existing codecs and setting the catalog rotation - [Time stretch](/quest/m2/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent - [Audio join budget](/quest/m2/audio-join-budget.md) - `moq-audio` joining at the latest group loses no group a publisher bursts before its budget widens +- [PulseAudio capture periods](/quest/m2/pulseaudio-capture-period.md) - capture on cpal's PulseAudio host arrives in short periods, not the server's 2 s default fragment, proven on the null sink's monitor - [Bindings size profile](/quest/m2/ffi-size-profile.md) - a benchmark decides whether the moq-ffi builds ship at opt-level "s", which halves the dylib - [The catalog publish path compiles once](/quest/m2/catalog-publish-once.md) - the catalog producer and its JSON diff codegen in `moq-mux` instead of once per importer crate, about 1 MiB of the CLI - [Zero-copy byte returns](/quest/m2/ffi-bytes-return.md) - returned payloads reach the host without a copy, if the payload numbers justify it @@ -176,3 +178,4 @@ ranked just below deterministic TS legs, which it requires. - [Relay metrics through prometheus-client](/quest/m2/relay-prometheus-client.md) - `/metrics` renders through the maintained client with the same series - [Dead routes on IETF name their cause](/quest/m2/js-ietf-dead-route-error.md) - a JS subscription on a dead moq-transport session ends with the session error, not `unauthorized` - [Error cleanups](/quest/m2/error-cleanups.md) - delete the unused `Error::SessionClosed`; raw transport failures already map to `Unroutable` +- [Test coverage audit](/quest/m2/audit-test-coverage.md) - repeat the coverage audit against the m1 baselines after the next release ships diff --git a/quest/m2/audit-test-coverage.md b/quest/m2/audit-test-coverage.md new file mode 100644 index 00000000000..45b76199ca9 --- /dev/null +++ b/quest/m2/audit-test-coverage.md @@ -0,0 +1,143 @@ +# [M] Test coverage audit + +## Goal + +Behavior that matters but no test exercises is covered or planned, and every +test runs in CI. + +## Plan + +Recurs once per major release (decided 2026-10-19). The PR that finishes +this run also adds the next one with the same file name at the end of the next +milestone's `Required` (m0 to m1, m1 to m2). The successor keeps the Goal +and this paragraph; the rest of its Plan is rewritten with what this run +learned: baselines, results, and what to check first. A successor placed in m2 +also lists the [release cut](/quest/m2/release/cut.md) under `Required`, so it +waits for the release in flight to ship. Only `/quest-delete` ends the +recurrence. Fixes land in the run's PR, one commit each, rather than as follow-ups. +Only a change that needs a maintainer decision (public API, scope) or is +independently landable and too large to review alongside the rest becomes a +follow-up through `/quest-plan`. + +### Tools + +Nightly keeps both reports as artifacts: `just rs coverage` (cargo-llvm-cov +over nextest, the native workspace at default features, both fuzz packages +and the native moq-wasm stub excluded) and `just js coverage` (Bun's own +coverage, one process per package, line hits unioned across processes). +Start from those artifacts (`gh run download -n rust-coverage` +and `-n js-coverage`) rather than re-running the instrumented workspace, +which is expensive on a shared machine. + +- `just rs coverage ` is not comparable to the workspace table: + without other packages' feature unification it compiles less of the + crate. +- Default features hide feature-gated tests that run nightly in the + `rs features` leg (`--all-features`). Check that leg passed before calling + a low file untested: on 2026-10-20 it stopped at a moq-auth example's lint, + so nothing after it ran. +- Bun 1.4.2 undercounts a module loaded again with a query suffix. Verify an + apparent JS gap before acting on it, and never merge packages into one + `bun test` process. +- The most productive scan in m1 was public functions whose signature line + the lcov report never hit: walk `DA:` records for lines matching + `pub fn`/`pub(crate) fn` outside test, bench, and fuzz files. It found the + binding facades below in minutes. Per-file uncovered line counts mostly + point at GPU code and generated bindings instead. +- When sampling `fix` commits for regression tests, count + `#[moq_clock::test]` as well as `#[test]` and `#[tokio::test]`. + +### m1 baselines + +From nightly run 39421743227 at `f13283b302` (2026-10-20): 9,050 native +tests passed with 33 ignored, and 18 JS package processes ran 3,924 tests. +Lines covered / measured: + +| Rust crate | Lines | Rust crate | Lines | +| --- | --- | --- | --- | +| hang | 2848 / 2924 | moq-loc | 141 / 153 | +| kio | 2045 / 2137 | moq-msf | 674 / 684 | +| moq-archive | 4353 / 4484 | moq-mux | 64372 / 66631 | +| moq-audio | 5455 / 5716 | moq-net | 83698 / 87593 | +| moq-auth | 5187 / 5313 | moq-nvenc | 3445 / 7771 | +| moq-bench | 1614 / 2068 | moq-pattern | 931 / 960 | +| moq-boy | 169 / 707 | moq-quic | 21696 / 22815 | +| moq-c | 8946 / 9516 | moq-relay | 10952 / 11607 | +| moq-cli | 6431 / 7566 | moq-room | 432 / 460 | +| moq-clock | 1858 / 1921 | moq-rtc | 2506 / 2882 | +| moq-e2ee | 679 / 759 | moq-rtmp | 6826 / 7638 | +| moq-ffi | 10819 / 11749 | moq-rtsp | 1033 / 1057 | +| moq-flate | 839 / 892 | moq-shaper | 2149 / 2219 | +| moq-gst | 6362 / 6740 | moq-sock | 1328 / 1486 | +| moq-hls | 8473 / 8857 | moq-srt | 1286 / 1481 | +| moq-ice | 319 / 361 | moq-stats | 4414 / 4601 | +| moq-json | 4897 / 5231 | moq-tokio | 15962 / 18005 | +| moq-uring | 4802 / 5558 | moq-transcode | 2997 / 3239 | +| moq-v4l | 9 / 1457 | moq-video | 6462 / 9988 | +| carla bridge (demo) | 923 / 1676 | | | + +| JS package | Lines | Unloaded files | JS package | Lines | Unloaded files | +| --- | --- | --- | --- | --- | --- | +| common | 200 / 250 | 5 | loc | 127 / 145 | 0 | +| net | 19248 / 25149 | 4 | msf | 103 / 130 | 0 | +| p2p | 293 / 303 | 1 | video | 277 / 336 | 0 | +| clock | 310 / 333 | 1 | watch | 3123 / 4596 | 29 | +| pattern | 435 / 523 | 0 | publish | 2773 / 3308 | 22 | +| auth | 725 / 788 | 2 | room | 493 / 530 | 1 | +| e2ee | 568 / 569 | 2 | signals | 580 / 763 | 2 | +| flate | 233 / 333 | 0 | wasm | 0 / 0 | 0 | +| json | 804 / 1093 | 0 | moq-boy | 155 / 194 | 8 | +| hang | 2076 / 2899 | 1 | | | | + +These are not acceptance thresholds. Compare the next run's report against +them and explain what moved. Against m0, every crate held its ratio: the +growth (moq-net +3.4k measured lines, moq-auth's in-band auth, moq-clock) +arrived tested, and moq-cli and @moq/json shrank. The m1 run's new tests +land after this baseline, so the next report should show moq-mux's +`import/track.rs` and moq-ffi's `video.rs` and `media.rs` rise. + +### What m1 found + +- `just rs audio-devices`, added by the m0 run, failed all five tests on its + first nightly. Two causes: cpal's PulseAudio host opened playback with the + server's default 2 s buffer (3.96 s end to end), and the tests assumed a + device starts within 100 ms while PulseAudio's idle null sink renders up to + two seconds ahead. Playback now asks PulseAudio for 20 ms periods, and the + tests wait until the device's clock moves. Watch that the first nightly + after this run is green, and keep any new device test behind the same wait. + Capture likely shares the buffer default: [PulseAudio capture periods](/quest/m2/pulseaudio-capture-period.md). +- The binding facades were the largest untested public surface: + `moq_mux::import::Track` had decoded only Avc3, Hvc1, VP8, and the audio + codecs other than MP3; `TrackStream` (the FFI raw-stream producer) had + never decoded a byte; and `MoqVideoEncoder` (`moq.video.Encoder` in every + wrapper) had no test in any language. Its doc and the Swift copy also + claimed `finish` drops the trailing access unit, which it does not. +- `just rs gpu` could not load a Vulkan loader inside the Nix shell on a + non-NixOS host. AMD and Intel now run through the flake's Mesa; NVIDIA + stays with [GPU CI](/quest/m2/gpu-ci.md). +- The VA-API renderer import tests have no caller: the m0 run mapped them to + GPU CI, which covers only NVIDIA. [VA-API renderer tests](/quest/m2/vaapi-render-tests.md) + owns them now. +- Every `#[ignore]` has a reason, and each one's caller is a recipe or a + quest. Several reasons name neither (the WGC, MediaCodec, and NVIDIA + Vulkan/CUDA tests), but their quests name the tests. Re-run the check by + test name, not by reason text. +- All 33 `fix` commits from the m0 PR to `7d2a91fe66` that changed code + carried a regression test; the rest were dependency, CI, Nix, or bench + compile fixes. + +### What to check first + +- The nightly legs that run tests outside `just check`: `rs audio-devices`, + `rs multicast`, `rs render-sw`, `rs quic-stress`, `rs features`, and the + `rust-test` benchmark group. A red leg hides everything after it. +- Public functions never executed, by the scan above, starting with moq-c, + moq-ffi, and moq-mux's import facades. +- Whether [Bindings in the interop matrix](/quest/m2/interop-bindings.md), + [GPU CI](/quest/m2/gpu-ci.md), [V4L2 virtual camera](/quest/m2/v4l2-virtual-camera.md), + and [MediaCodec on an emulator](/quest/m2/android-mediacodec-ci.md) landed, + before re-auditing the tests they own. + +## Required + +- [Release cut](/quest/m2/release/cut.md) - the release in flight ships before this run starts diff --git a/quest/m2/pulseaudio-capture-period.md b/quest/m2/pulseaudio-capture-period.md new file mode 100644 index 00000000000..dbe3099e026 --- /dev/null +++ b/quest/m2/pulseaudio-capture-period.md @@ -0,0 +1,32 @@ +# [S] PulseAudio capture periods + +## Goal + +Capture through cpal's PulseAudio host (moq-audio's opt-in `pulseaudio` +feature) delivers microphone audio in periods of tens of milliseconds, not +the server's default fragment, and a capture test in `just rs audio-devices` +proves it against the private null sink's monitor source. + +## Plan + +Found by the m1 [coverage audit](/quest/m2/audit-test-coverage.md). Playback +on that host asked for `BufferSize::Default`, which cpal's PulseAudio host +maps to the server's default buffer attributes: a 2 s `tlength`, 3.96 s end +to end. The audit fixed playback (`playback::device::buffer_size` asks for +20 ms periods). Capture (`rs/moq-audio/src/capture.rs`, `resolve`) also opens +with the default buffer size, and PulseAudio's default record `fragsize` is +the same 2 s, so capture likely arrives in 2 s bursts. Echo cancellation +needs both halves within its delay window, so this matters for `aec` too. + +- Reproduce first: capture `moq-null.monitor` under `test/lib/pulse.py` + while a playback engine plays a tone, and measure the callback cadence. + The pulse log's `Requested fragsize` line shows what the stream asked for. +- If it reproduces, share the playback rule (only the PulseAudio host, a + 20 ms period clamped to the reported range) between capture and playback + rather than copying it, since `capture` builds without `playback`. +- Add the capture test to the `audio-devices` recipe's selection, waiting + for the device to start as the playback tests do. + +## Related + +- [A cpal release loads libasound at runtime](/quest/m2/cpal-alsa-runtime.md) - would make the PulseAudio host the default on hosts without libasound diff --git a/quest/m2/vaapi-render-tests.md b/quest/m2/vaapi-render-tests.md new file mode 100644 index 00000000000..b8bbd5de68b --- /dev/null +++ b/quest/m2/vaapi-render-tests.md @@ -0,0 +1,39 @@ +# [S] VA-API renderer tests + +## Goal + +The four ignored VA-API renderer import tests in +`rs/moq-video/src/render/renderer.rs` (`the_nv12_dmabuf_import_matches_the_cpu_path`, +`the_i420_dmabuf_import_matches_the_cpu_path`, +`decoded_frames_reach_the_gpu_without_a_download`, and +`recycled_decoder_surfaces_draw_the_latest_picture`) and +`packed_dmabuf_renders_through_vulkan` run in `just rs gpu` on AMD and Intel +hosts and fail there instead of skipping. + +## Plan + +Found by the m1 [coverage audit](/quest/m2/audit-test-coverage.md): no recipe +or quest named these tests. The m0 run mapped them to [GPU CI](/quest/m2/gpu-ci.md), +which covers only NVIDIA. + +- `just rs gpu` already drives AMD and Intel through the flake's Mesa + (`sh/rs/gpu.sh`). Run with that Mesa on 2026-10-20 (an Intel iGPU plus an + AMD dGPU): the decode tests printed "skipping: no VA-API H.264 + decoder", I420 skipped for "this driver does not do YU12 DMA-BUFs", and + NV12 failed with "no NV12 DMA-BUF could be allocated to import". Point + libva at the flake's drivers (Mesa's radeonsi for AMD, intel-media-driver's + iHD for Intel) and find out which of those remain real driver limits. +- Inside `just rs gpu` a missing driver is a failure, matching the rest of + the recipe. A driver limit the test cannot work around (YU12 on one + vendor) stays a skip that names it, outside the recipe's selection. +- `packed_dmabuf_renders_through_vulkan` needs a PipeWire desktop as well; + if the recipe cannot provide one, leave it to + [Video hardware validation](/quest/m3/video-hardware.md) and say so in its + reason. +- No hosted runner has these GPUs, so this stays a local recipe until a + GPU runner exists ([GPU CI](/quest/m2/gpu-ci.md) owns that decision). + +## Related + +- [GPU CI](/quest/m2/gpu-ci.md) - the NVIDIA half of `just rs gpu` +- [VA-API encodes an external Vulkan image](/quest/m2/vaapi-vulkan-import.md) - the encode side of the same VA-API surfaces From 40649d98028976c1997be3cdd0b9ef631c1abf5b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 00:43:00 -0700 Subject: [PATCH 9/9] chore(dart): regenerate bindings for the stream producer finish doc Co-Authored-By: Claude Opus 5.5 --- dart/moq_ffi/lib/src/moq.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index f21651d2045..f47baef063c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -18795,7 +18795,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediatrackstreamproducer_finish() != - 57446) { + 6627) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqmediatrackstreamproducer_write() !=