diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52e125e..b8502a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,28 @@ jobs: with: toolchain: "1.88.0" - run: cargo check --locked --all-targets + + # The crate's `unsafe` is all libvpx FFI, which Miri refuses to execute. + # Scoped to the codec tests: the other C dependencies hold one-time + # allocations that LeakSanitizer reports as leaks. + sanitizers: + name: Sanitizers + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + sanitizer: [address, leak] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-rust + with: + toolchain: nightly + components: rust-src + - run: > + cargo test --locked --lib + -Zbuild-std + --target x86_64-unknown-linux-gnu + rtc::vpx + env: + RUSTFLAGS: -Zsanitizer=${{ matrix.sanitizer }} diff --git a/src/rtc/vpx.rs b/src/rtc/vpx.rs index 7090e22..4b6be40 100644 --- a/src/rtc/vpx.rs +++ b/src/rtc/vpx.rs @@ -239,20 +239,21 @@ impl VpxEncoder { ), "enc_init", )?; - let mut ctx = ctx.assume_init(); + let ctx = ctx.assume_init(); + let mut encoder = Self { ctx, width, height }; // Fastest realtime speed setting (CPUUSED is 0..=9 for VP9, higher is // faster / lower quality — fine for a solid backend frame). check( - vpx_codec_control_(&mut ctx, VP8E_SET_CPUUSED as c_int, 8 as c_int), + vpx_codec_control_(&mut encoder.ctx, VP8E_SET_CPUUSED as c_int, 8 as c_int), "set_cpuused", )?; if codec == VpxCodec::Vp9 { // Row-based multithreading; ignore errors on builds without it. - let _ = vpx_codec_control_(&mut ctx, VP9E_SET_ROW_MT as c_int, 1 as c_int); + let _ = vpx_codec_control_(&mut encoder.ctx, VP9E_SET_ROW_MT as c_int, 1 as c_int); } - Ok(Self { ctx, width, height }) + Ok(encoder) } } @@ -728,6 +729,61 @@ mod tests { } } + /// `unsafe impl Send` claims a libvpx context is not pinned to the thread + /// that created it. Build here, encode there. + #[test] + fn encoder_built_on_one_thread_encodes_on_another() { + let mut encoder = VpxEncoder::new(VpxCodec::Vp9, 160, 120, 400).expect("vp9 encoder"); + let input = blue_i420(160, 120); + + let frames = std::thread::spawn(move || encoder.encode(&input, 0, 33, true)) + .join() + .expect("encoder thread panicked") + .expect("encode"); + + assert!( + frames + .iter() + .any(|frame| frame.key && !frame.data.is_empty()) + ); + } + + /// The SVC callback's `user_priv` points into a boxed buffer, so relocating + /// the encoder after registration must not invalidate it. The second push + /// reallocates the `Vec` and moves the first encoder to a new address. + #[test] + fn vp9_svc_encoder_encodes_after_being_relocated() { + let mode = Vp9SvcMode::new(2, 1).expect("L2T1 mode"); + let input = blue_i420(160, 120); + let mut encoders = vec![VpxSvcEncoder::new(160, 120, 500, mode).expect("VP9 SVC encoder")]; + encoders.push(VpxSvcEncoder::new(160, 120, 500, mode).expect("VP9 SVC encoder")); + + let frames = encoders[0].encode(&input, 0, 33, true).expect("encode"); + + assert_eq!(frames.len(), 2); + assert!(frames.iter().all(|frame| !frame.data.is_empty())); + } + + /// A size that does not divide by four leaves libvpx to round each spatial + /// layer itself. Every reported layer must still be usable. + #[test] + fn vp9_svc_reports_usable_dimensions_for_odd_sizes() { + let mode = Vp9SvcMode::new(3, 1).expect("L3T1 mode"); + let mut encoder = VpxSvcEncoder::new(322, 178, 900, mode).expect("VP9 SVC encoder"); + let input = blue_i420(322, 178); + + let frames = encoder.encode(&input, 0, 33, true).expect("encode"); + + assert_eq!(frames.len(), 3); + let top = frames.last().expect("top spatial layer"); + assert_eq!((top.width, top.height), (322, 178)); + assert!( + frames + .iter() + .all(|frame| frame.width > 0 && frame.height > 0 && !frame.data.is_empty()) + ); + } + #[test] fn vp9_svc_uses_codec_reported_0212_temporal_pattern_and_resets_on_key() { let mode = Vp9SvcMode::new(1, 3).expect("L1T3 mode"); diff --git a/src/rtc/vpx_decode.rs b/src/rtc/vpx_decode.rs index 5864069..64c535b 100644 --- a/src/rtc/vpx_decode.rs +++ b/src/rtc/vpx_decode.rs @@ -266,6 +266,55 @@ mod tests { round_trip(VpxCodec::Vp9, 322, 178); } + /// `copy_i420` documents that libvpx planes die at the next `decode` call, + /// so a frame handed out earlier must own its pixels. + #[test] + fn decoded_frames_survive_the_next_decode_call() { + let mut enc = VpxEncoder::new(VpxCodec::Vp9, 320, 240, 1_000).expect("encoder"); + let mut dec = VpxDecoder::new(VpxCodec::Vp9).expect("decoder"); + let source = ramp_i420(320, 240); + + let mut held = Vec::new(); + for packet in &enc.encode(&source, 0, 33, true).expect("encode key") { + held.extend(dec.decode(&packet.data, 1).expect("decode key")); + } + assert!(!held.is_empty(), "keyframe produced no frame"); + + for packet in &enc.encode(&source, 33, 33, false).expect("encode delta") { + dec.decode(&packet.data, 2).expect("decode delta"); + } + + let frame = &held[0]; + let w = 320; + let y = &frame.data[..w * 240]; + assert_eq!(frame.rtp_timestamp, 1); + assert!( + y[0] < 40 && y[w - 1] > 215, + "the held frame lost its ramp after a later decode" + ); + } + + /// `copy_i420` reads the dimensions off each image, so one decoder must + /// follow a stream whose resolution changes. + #[test] + fn decoder_follows_a_resolution_change() { + let mut dec = VpxDecoder::new(VpxCodec::Vp9).expect("decoder"); + let mut sizes = Vec::new(); + + for (width, height) in [(320u32, 240u32), (160, 120)] { + let mut enc = VpxEncoder::new(VpxCodec::Vp9, width, height, 800).expect("encoder"); + let source = ramp_i420(width, height); + for packet in &enc.encode(&source, 0, 33, true).expect("encode") { + for frame in dec.decode(&packet.data, 0).expect("decode") { + assert_eq!(frame.data.len(), i420_len(frame.width, frame.height)); + sizes.push((frame.width, frame.height)); + } + } + } + + assert_eq!(sizes, vec![(320, 240), (160, 120)]); + } + #[test] fn empty_input_yields_no_frames() { let mut dec = VpxDecoder::new(VpxCodec::Vp9).expect("decoder");