From c1e431ad2d148a4f5d4a69c8ee47a35dca5b8088 Mon Sep 17 00:00:00 2001 From: Logan Jennings Date: Mon, 19 Oct 2026 19:20:37 -0700 Subject: [PATCH] AV1 limits are the level 6.x ones (16384 wide, 8704 high, 35651584 samples); hang guard instead of per-run time asserts; encoder failure is not a skip - decoder.rs: MaxHSize 16384, MaxVSize 8704 and MaxPicSize 35651584 per AV1 Annex A (the earlier 16384 x 8704 product is four times the area limit); boundary unit tests on frame_size_allowed; README states the three limits. - testkit: with_deadline/set_progress (whole-sweep hang guard naming the last input; FILMCRAFT_HOSTILE_TIMEOUT_SECS overrides) and ffmpeg_has_encoder. - The nine fuzz_hostile tests drop their per-run elapsed assertions (which could not catch a hang and could fail a slow runner) and run under the guard. - A seed whose encoder is absent from ffmpeg still skips (and fails under FILMCRAFT_REQUIRE_ORACLES=1); an encoder that is present but fails now fails the test. - mpeg2v failure labels carry the seed name. --- crates/apv/tests/fuzz_hostile.rs | 11 +++-- crates/av1/README.md | 7 ++-- crates/av1/src/decoder.rs | 28 ++++++++----- crates/av1/tests/fuzz_hostile.rs | 18 ++++++--- crates/av1/tests/hostile_dims.rs | 11 +++-- crates/dnx/tests/fuzz_hostile.rs | 11 +++-- crates/h264/tests/fuzz_hostile.rs | 18 ++++++--- crates/hevc/tests/fuzz_hostile.rs | 18 ++++++--- crates/mpeg2v/tests/fuzz_hostile.rs | 30 +++++++++----- crates/prores/tests/fuzz_hostile.rs | 11 +++-- crates/testkit/src/deadline.rs | 62 +++++++++++++++++++++++++++++ crates/testkit/src/lib.rs | 5 ++- crates/testkit/src/oracle.rs | 23 +++++++++++ crates/vp8/tests/fuzz_hostile.rs | 18 ++++++--- crates/vp9/tests/fuzz_hostile.rs | 18 ++++++--- 15 files changed, 225 insertions(+), 64 deletions(-) create mode 100644 crates/testkit/src/deadline.rs diff --git a/crates/apv/tests/fuzz_hostile.rs b/crates/apv/tests/fuzz_hostile.rs index b04a8f1c1..bb02fdd07 100644 --- a/crates/apv/tests/fuzz_hostile.rs +++ b/crates/apv/tests/fuzz_hostile.rs @@ -2,7 +2,6 @@ //! truncation at (nearly) every length must never panic or run away (errors are fine). use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::time::{Duration, Instant}; use filmcraft_apv::*; @@ -42,7 +41,7 @@ fn seeds() -> Vec> { } fn run(d: &[u8], label: &str) { - let t = Instant::now(); + filmcraft_testkit::set_progress(label); let res = catch_unwind(AssertUnwindSafe(|| { for depth in [None, Some(8), Some(16)] { let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() }); @@ -51,11 +50,17 @@ fn run(d: &[u8], label: &str) { let _ = split_raw_bitstream(d); })); assert!(res.is_ok(), "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(10), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let seeds = seeds(); assert!(!seeds.is_empty()); for (si, seed) in seeds.iter().enumerate() { diff --git a/crates/av1/README.md b/crates/av1/README.md index a98ad74b8..3045e9d37 100644 --- a/crates/av1/README.md +++ b/crates/av1/README.md @@ -127,9 +127,10 @@ SVT-AV1's hierarchical GOPs that is the top layer, half of the frames. ## Limits -Frames larger than AV1 level 6.3 allows (16384 x 8704 luma samples, and at most 16384 per side) are refused -with `Error::Invalid("frame size")`. The sequence header can code up to 65536 x 65536, which would ask for -gigabytes of buffers from a few bytes of hostile input; no real stream comes close to level 6.3. +Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples +in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence +header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input; +no real stream comes close to level 6.3, the highest defined level. ## API diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs index 33c4e9a6c..eae16133b 100644 --- a/crates/av1/src/decoder.rs +++ b/crates/av1/src/decoder.rs @@ -23,14 +23,16 @@ use crate::stats::{DecodeStats, Stage, Timer}; use crate::tile::TileDecoder; use crate::{Error, Picture, Result}; -/// Largest accepted frame side and area: AV1 level 6.3 stops at 16384 x 8704 (the area limit is exactly that -/// product, so a conforming maximum-size frame is accepted); the sequence header allows 65536 x 65536 (gigabytes of buffers). -const MAX_FRAME_DIM: u32 = 16384; -const MAX_FRAME_AREA: u64 = 16384 * 8704; +/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize +/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536 +/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded. +const MAX_FRAME_WIDTH: u32 = 16384; +const MAX_FRAME_HEIGHT: u32 = 8704; +const MAX_FRAME_AREA: u64 = 35_651_584; /// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits. fn frame_size_allowed(width: u32, height: u32) -> bool { - width <= MAX_FRAME_DIM && height <= MAX_FRAME_DIM && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA + width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA } const OBU_SEQUENCE_HEADER_T: u32 = 1; const OBU_TEMPORAL_DELIMITER_T: u32 = 2; @@ -962,16 +964,20 @@ mod tests { use super::frame_size_allowed; #[test] - fn level_6_3_maximum_frame_is_allowed() { - assert!(frame_size_allowed(16384, 8704)); - assert!(frame_size_allowed(8704, 16384)); + fn frames_up_to_the_level_6_3_limits_are_allowed() { + assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize + assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize + assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize + assert!(frame_size_allowed(7680, 4320)); // 8K UHD + assert!(frame_size_allowed(1, 1)); } #[test] fn frames_beyond_the_limits_are_refused() { - assert!(!frame_size_allowed(16384, 8705)); - assert!(!frame_size_allowed(16385, 1)); - assert!(!frame_size_allowed(1, 16385)); + assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize + assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize + assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize + assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize assert!(!frame_size_allowed(65536, 65536)); } } diff --git a/crates/av1/tests/fuzz_hostile.rs b/crates/av1/tests/fuzz_hostile.rs index b4cf6a023..4421b18c4 100644 --- a/crates/av1/tests/fuzz_hostile.rs +++ b/crates/av1/tests/fuzz_hostile.rs @@ -4,7 +4,6 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant}; use filmcraft_av1::Decoder; @@ -70,9 +69,9 @@ fn count_panics() { } fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { for threads in [1, 3] { let mut dec = Decoder::with_threads(threads); @@ -83,7 +82,6 @@ fn run_all(frames: &[Vec], label: &str) { } })); assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } /// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). @@ -91,13 +89,23 @@ fn iters() -> usize { std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return }; for &(name, args, pix) in SEEDS { - let Some(seed) = make(&ff, name, args, pix) else { - filmcraft_testkit::skip("AV1 fuzz", &format!("ffmpeg could not encode the {name} seed (encoder missing?)")); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") { + filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})")); continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libsvtav1 but could not encode the {name} seed"); }; if seed.is_empty() { filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames")); diff --git a/crates/av1/tests/hostile_dims.rs b/crates/av1/tests/hostile_dims.rs index c0d76e51b..1312e938d 100644 --- a/crates/av1/tests/hostile_dims.rs +++ b/crates/av1/tests/hostile_dims.rs @@ -112,11 +112,14 @@ fn huge_frames_are_refused_without_allocating() { refused(30000, 30000); } -/// One row past level 6.3 (16384 x 8704) is refused. A frame of exactly 16384 x 8704 is accepted: that boundary is -/// unit-tested on `frame_size_allowed` in `decoder.rs`, because decoding such a frame end to end allocates ~1.5 GB. +/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of +/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end +/// needs gigabytes. #[test] -fn one_row_past_level_6_3_is_refused() { - refused(16384, 8705); +fn frames_past_the_level_6_limits_are_refused() { + refused(16384, 2177); + refused(16384, 8704); + refused(4096, 8705); } /// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no diff --git a/crates/dnx/tests/fuzz_hostile.rs b/crates/dnx/tests/fuzz_hostile.rs index 2c3381ffa..b3eee0037 100644 --- a/crates/dnx/tests/fuzz_hostile.rs +++ b/crates/dnx/tests/fuzz_hostile.rs @@ -3,7 +3,6 @@ //! panic or run away (errors are fine). No ffmpeg needed. use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::time::{Duration, Instant}; use filmcraft_dnx::{ChromaFormat, DecodeOptions, Encoder, EncoderConfig, Frame, Profile, decode_frame_with, probe}; @@ -46,7 +45,7 @@ fn seeds() -> Vec> { } fn run(d: &[u8], label: &str) { - let t = Instant::now(); + filmcraft_testkit::set_progress(label); let res = catch_unwind(AssertUnwindSafe(|| { let _ = probe(d); for opts in [DecodeOptions::default(), DecodeOptions { threads: true, spec_dequant: true }] { @@ -54,11 +53,17 @@ fn run(d: &[u8], label: &str) { } })); assert!(res.is_ok(), "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(10), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let seeds = seeds(); assert!(!seeds.is_empty()); for (si, seed) in seeds.iter().enumerate() { diff --git a/crates/h264/tests/fuzz_hostile.rs b/crates/h264/tests/fuzz_hostile.rs index 63d1af19c..d45af4148 100644 --- a/crates/h264/tests/fuzz_hostile.rs +++ b/crates/h264/tests/fuzz_hostile.rs @@ -5,7 +5,6 @@ mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; use filmcraft_h264::Decoder; @@ -63,9 +62,9 @@ fn count_panics() { } fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { for threads in [1, 3] { let mut dec = Decoder::with_threads(threads); @@ -78,16 +77,25 @@ fn run_all(d: &[u8], label: &str) { } })); assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("h264 fuzz") else { return }; for &(name, args, pix) in SEEDS { - let Some(seed) = make(&ff, name, args, pix) else { - filmcraft_testkit::skip("h264 fuzz", &format!("ffmpeg could not encode the {name} seed (encoder missing?)")); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx264") { + filmcraft_testkit::skip("h264 fuzz", &format!("this ffmpeg has no libx264 encoder (seed {name})")); continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libx264 but could not encode the {name} seed"); }; let stride = (seed.len() / 300).max(1); for n in (0..seed.len()).step_by(stride) { diff --git a/crates/hevc/tests/fuzz_hostile.rs b/crates/hevc/tests/fuzz_hostile.rs index f44bcebc6..9f5e2feae 100644 --- a/crates/hevc/tests/fuzz_hostile.rs +++ b/crates/hevc/tests/fuzz_hostile.rs @@ -5,7 +5,6 @@ mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; use filmcraft_hevc::Decoder; @@ -63,9 +62,9 @@ fn count_panics() { } fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { for threads in [1, 3] { let mut dec = Decoder::with_threads(threads); @@ -78,16 +77,25 @@ fn run_all(d: &[u8], label: &str) { } })); assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("hevc fuzz") else { return }; for &(name, args, pix) in SEEDS { - let Some(seed) = make(&ff, name, args, pix) else { - filmcraft_testkit::skip("hevc fuzz", &format!("ffmpeg could not encode the {name} seed (encoder missing?)")); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libx265") { + filmcraft_testkit::skip("hevc fuzz", &format!("this ffmpeg has no libx265 encoder (seed {name})")); continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libx265 but could not encode the {name} seed"); }; let stride = (seed.len() / 300).max(1); for n in (0..seed.len()).step_by(stride) { diff --git a/crates/mpeg2v/tests/fuzz_hostile.rs b/crates/mpeg2v/tests/fuzz_hostile.rs index d3f804090..d293af2d2 100644 --- a/crates/mpeg2v/tests/fuzz_hostile.rs +++ b/crates/mpeg2v/tests/fuzz_hostile.rs @@ -5,7 +5,6 @@ mod common; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::time::{Duration, Instant}; use filmcraft_mpeg2v::{Decoder, access_units, probe, scan_access_unit}; @@ -60,9 +59,9 @@ fn count_panics() { } fn run_all(d: &[u8], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(std::sync::atomic::Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { let _ = probe(d); let _ = scan_access_unit(d); @@ -78,38 +77,47 @@ fn run_all(d: &[u8], label: &str) { } })); assert!(res.is_ok() && PANICS.load(std::sync::atomic::Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpeg2v fuzz") else { return }; for spec in &SPECS { - let Some(path) = common::make(&ff, spec) else { - filmcraft_testkit::skip("mpeg2v fuzz", &format!("ffmpeg could not encode the {} seed (encoder missing?)", spec.name)); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "mpeg2video") { + filmcraft_testkit::skip("mpeg2v fuzz", &format!("this ffmpeg has no mpeg2video encoder (seed {})", spec.name)); continue; + } + let Some(path) = common::make(&ff, spec) else { + panic!("ffmpeg has mpeg2video but could not encode the {} seed", spec.name); }; - fuzz(&std::fs::read(path).unwrap()); + fuzz(spec.name, &std::fs::read(path).unwrap()); } } -fn fuzz(seed: &[u8]) { +fn fuzz(name: &str, seed: &[u8]) { // every truncation length (stride keeps it quick on larger seeds) let stride = (seed.len() / 600).max(1); for n in (0..seed.len()).step_by(stride) { - run_all(&seed[..n], &format!("truncate {n}")); + run_all(&seed[..n], &format!("{name} truncate {n}")); } // header region: every byte to 0 / 0xff / 0x80 / each single bit for p in 0..seed.len().min(96) { for v in [0u8, 0xff, 0x80, 0x01] { let mut d = seed.to_vec(); d[p] = v; - run_all(&d, &format!("byte {p}={v:#x}")); + run_all(&d, &format!("{name} byte {p}={v:#x}")); } for b in 0..8 { let mut d = seed.to_vec(); d[p] ^= 1 << b; - run_all(&d, &format!("flip {p}.{b}")); + run_all(&d, &format!("{name} flip {p}.{b}")); } } // random multi-mutations @@ -124,7 +132,7 @@ fn fuzz(seed: &[u8]) { d[p] = rng.next() as u8; } } - run_all(&d, &format!("random {i}")); + run_all(&d, &format!("{name} random {i}")); } } diff --git a/crates/prores/tests/fuzz_hostile.rs b/crates/prores/tests/fuzz_hostile.rs index 4e374e186..04a9cb35a 100644 --- a/crates/prores/tests/fuzz_hostile.rs +++ b/crates/prores/tests/fuzz_hostile.rs @@ -2,7 +2,6 @@ //! truncation at (nearly) every length must never panic or run away (errors are fine). use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::time::{Duration, Instant}; use filmcraft_prores::*; @@ -42,7 +41,7 @@ fn seeds() -> Vec> { } fn run(d: &[u8], label: &str) { - let t = Instant::now(); + filmcraft_testkit::set_progress(label); let res = catch_unwind(AssertUnwindSafe(|| { for depth in [None, Some(8), Some(16)] { let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() }); @@ -50,11 +49,17 @@ fn run(d: &[u8], label: &str) { let _ = probe(d); })); assert!(res.is_ok(), "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(10), "too slow ({:?}): {label}", t.elapsed()); } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_frames_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let seeds = seeds(); assert!(!seeds.is_empty()); for (si, seed) in seeds.iter().enumerate() { diff --git a/crates/testkit/src/deadline.rs b/crates/testkit/src/deadline.rs new file mode 100644 index 000000000..97a5b138b --- /dev/null +++ b/crates/testkit/src/deadline.rs @@ -0,0 +1,62 @@ +//! Hang guard for hostile-input sweeps: a test body runs on a worker thread, and a body that does not finish +//! within the budget fails the test (naming the input it was last working on) instead of stalling CI forever. +//! A merely slow body is not a failure, so a loaded runner or slow VM cannot fail a correct decoder. + +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +static PROGRESS: Mutex = Mutex::new(String::new()); + +/// Record the input being worked on, so a hang report can name it. Cheap enough to call per input. +pub fn set_progress(label: &str) { + let mut p = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner); + p.clear(); + p.push_str(label); +} + +/// Run `f` on a worker thread and wait at most `secs` seconds (`FILMCRAFT_HOSTILE_TIMEOUT_SECS` overrides the +/// budget). A panic in `f` is re-raised here; a timeout panics with the last [`set_progress`] label. A hung +/// worker is abandoned (the failing test process exits anyway). +pub fn with_deadline(secs: u64, f: impl FnOnce() + Send + 'static) { + let secs = std::env::var("FILMCRAFT_HOSTILE_TIMEOUT_SECS").ok().and_then(|v| v.parse::().ok()).filter(|&n| n > 0).unwrap_or(secs); + let (tx, rx) = mpsc::channel(); + // a failed spawn shows up as "worker gone" below + let _ = std::thread::Builder::new().name("hostile deadline".into()).spawn(move || { + let _ = tx.send(catch_unwind(AssertUnwindSafe(f))); + }); + match rx.recv_timeout(Duration::from_secs(secs)) { + Ok(Ok(())) => {} + Ok(Err(payload)) => resume_unwind(payload), + Err(RecvTimeoutError::Timeout) => { + let last = PROGRESS.lock().unwrap_or_else(PoisonError::into_inner).clone(); + panic!("hostile-input test did not finish in {secs}s (hang); last input: {last}"); + } + Err(RecvTimeoutError::Disconnected) => panic!("hostile-input worker thread could not run"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_slow_body_passes() { + with_deadline(30, || std::thread::sleep(Duration::from_millis(100))); + } + + #[test] + #[should_panic(expected = "boom")] + fn a_panic_in_the_body_is_re_raised() { + with_deadline(30, || panic!("boom")); + } + + #[test] + #[should_panic(expected = "last input: seed 7 byte 3")] + fn a_hang_fails_and_names_the_input() { + // a body that outlives the 1 s budget (the env override is unset in tests) + set_progress("seed 7 byte 3"); + with_deadline(1, || std::thread::sleep(Duration::from_secs(30))); + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index 9846d9072..a67fbfaa5 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -10,14 +10,17 @@ //! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked //! or shipped (AGENTS.md §2). +pub mod deadline; pub mod fixtures; pub mod golden; pub mod oracle; pub mod wav; +pub use deadline::{set_progress, with_deadline}; pub use fixtures::{fixtures_dir, temp_path, workspace_root}; pub use oracle::{ - ffmpeg, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip, + ffmpeg, ffmpeg_has_encoder, ffmpeg_or_skip, ffmpeg_supports_format, ffmpeg_version, ffmpeg_with_format_or_skip, ffprobe, ffprobe_or_skip, oracles_required, + skip, }; /// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index e0b13863c..50a1ce22c 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -11,6 +11,7 @@ //! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure. use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::OnceLock; /// Environment variable that makes missing oracles a hard failure. @@ -116,6 +117,19 @@ pub fn ffmpeg_or_skip(context: &str) -> Option { tool_or_skip("ffmpeg", ffmpeg(), context) } +/// Whether `ffmpeg -encoders` lists `encoder` (e.g. `libx265`): lets a test tell "this ffmpeg lacks the encoder" +/// (skip) from "the encoder is there but failed" (a real failure). +pub fn ffmpeg_has_encoder(ffmpeg: &Path, encoder: &str) -> bool { + let Ok(out) = Command::new(ffmpeg).args(["-hide_banner", "-encoders"]).stdin(Stdio::null()).output() else { return false }; + encoders_output_lists(&String::from_utf8_lossy(&out.stdout), encoder) +} + +/// Pure function of the `ffmpeg -encoders` output: whether `encoder` is one of the listed names. +fn encoders_output_lists(output: &str, encoder: &str) -> bool { + // rows look like ` V....D libx264 libx264 H.264 …`; the name is the second whitespace-separated token + output.lines().any(|line| line.split_whitespace().nth(1) == Some(encoder)) +} + /// ffprobe, or `None` after reporting a skip (see [`skip`]). pub fn ffprobe_or_skip(context: &str) -> Option { tool_or_skip("ffprobe", ffprobe(), context) @@ -177,6 +191,15 @@ pub fn ffmpeg_with_format_or_skip(context: &str, format_name: &str, min_version: #[cfg(test)] mod tests { use super::*; + + #[test] + fn encoder_lookup_matches_the_name_column_only() { + let out = " V....D libx264 libx264 H.264 / AVC\n V....D libx265 libx265 H.265 / HEVC (libx265)\n A....D aac AAC (libx264 inside)\n"; + assert!(encoders_output_lists(out, "libx264")); + assert!(encoders_output_lists(out, "libx265")); + assert!(!encoders_output_lists(out, "libsvtav1")); + assert!(!encoders_output_lists(out, "H.264")); + } use std::ffi::OsString; fn tmpdir(tag: &str) -> PathBuf { diff --git a/crates/vp8/tests/fuzz_hostile.rs b/crates/vp8/tests/fuzz_hostile.rs index 7869f330a..be36a3e9e 100644 --- a/crates/vp8/tests/fuzz_hostile.rs +++ b/crates/vp8/tests/fuzz_hostile.rs @@ -4,7 +4,6 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant}; use filmcraft_vp8::Decoder; @@ -73,9 +72,9 @@ fn count_panics() { } fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { for _ in 0..1 { let mut dec = Decoder::new(); @@ -85,7 +84,6 @@ fn run_all(frames: &[Vec], label: &str) { } })); assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } /// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). @@ -93,13 +91,23 @@ fn iters() -> usize { std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP8 fuzz") else { return }; for &(name, args, pix) in SEEDS { - let Some(seed) = make(&ff, name, args, pix) else { - filmcraft_testkit::skip("VP8 fuzz", &format!("ffmpeg could not encode the {name} seed (encoder missing?)")); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx") { + filmcraft_testkit::skip("VP8 fuzz", &format!("this ffmpeg has no libvpx encoder (seed {name})")); continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libvpx but could not encode the {name} seed"); }; if seed.is_empty() { filmcraft_testkit::skip("VP8 fuzz", &format!("the {name} seed has no frames")); diff --git a/crates/vp9/tests/fuzz_hostile.rs b/crates/vp9/tests/fuzz_hostile.rs index ec591ae02..5f7189442 100644 --- a/crates/vp9/tests/fuzz_hostile.rs +++ b/crates/vp9/tests/fuzz_hostile.rs @@ -4,7 +4,6 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::{Duration, Instant}; use filmcraft_vp9::Decoder; @@ -75,9 +74,9 @@ fn count_panics() { } fn run_all(frames: &[Vec], label: &str) { + filmcraft_testkit::set_progress(label); count_panics(); let before = PANICS.load(Ordering::SeqCst); - let t = Instant::now(); let res = catch_unwind(AssertUnwindSafe(|| { for threads in [1, 3] { let mut dec = Decoder::with_threads(threads); @@ -88,7 +87,6 @@ fn run_all(frames: &[Vec], label: &str) { } })); assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}"); - assert!(t.elapsed() < Duration::from_secs(20), "too slow ({:?}): {label}", t.elapsed()); } /// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt). @@ -96,13 +94,23 @@ fn iters() -> usize { std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300) } +/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS). +const DEADLINE_SECS: u64 = 600; + #[test] fn hostile_streams_never_panic() { + filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep); +} + +fn sweep() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("VP9 fuzz") else { return }; for &(name, args, pix) in SEEDS { - let Some(seed) = make(&ff, name, args, pix) else { - filmcraft_testkit::skip("VP9 fuzz", &format!("ffmpeg could not encode the {name} seed (encoder missing?)")); + if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libvpx-vp9") { + filmcraft_testkit::skip("VP9 fuzz", &format!("this ffmpeg has no libvpx-vp9 encoder (seed {name})")); continue; + } + let Some(seed) = make(&ff, name, args, pix) else { + panic!("ffmpeg has libvpx-vp9 but could not encode the {name} seed"); }; if seed.is_empty() { filmcraft_testkit::skip("VP9 fuzz", &format!("the {name} seed has no frames"));