Mutation testing over src/mux/. No production change — 49 survivors killed, all proven red before green. The MP4 composition-time chain was entirely unconstrained: VideoTiming::ctts, build_ctts and parse_ctts could each return a constant and the suite stayed green. Confirmed on HEAD: build_ctts -> vec![] passes all 1,220 mux tests. A demuxed B-frame title presenting in decode order would have shipped. The cause is a test whose name asserts coverage its body does not deliver — stts_and_ctts_expand builds an stts box and never touches ctts, and write_then_read_round_trip asserts sample sizes and keyframe flags but not one PTS. Same shape as the set_speed forwarding finding, different disguise. mlp_num_substreams / mlp_substr_header_size: every TrueHD fixture in the crate uses one substream and no extraword, so both could return a constant and agree with all of them. These position mlp_parity_ok's window over the AU header, so a constant mis-windows the parity check on exactly the multi-substream AUs that carry 7.1 and Atmos. CodecPrivate absent vs empty: mkv.rs writes Some(bytes) verbatim and omits the element on None (RFC 9559 5.1.4.1.24), so a zero-length Some emits a track header asserting the config IS empty. Four parsers could return Some(vec![]) before any frame. Also: mandatory ISO/IEC 14496-12 boxes (tkhd, vmhd, smhd, dinf, mdhd) could each build empty; HEVC num_extra_slice_header_bits (H.265 7.3.2.3) was never non-zero in any fixture, so the slice-type offset skip was unexercised; chapter names from the disc go straight into <ChapterString> and the & escape must run first; a stray 0x47 in a payload must not latch a TS resync. Documented as equivalent rather than killed: CodecParser::flush and the three parser flush bodies that differ from the mutant only by a tracing call, and DropTally::log_summary.
241 lines
9.5 KiB
Rust
241 lines
9.5 KiB
Rust
//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
|
|
//! codec parsers.
|
|
//!
|
|
//! The user's rule: a clean mux keeps every frame it can and drops the ones it
|
|
//! can't — video always survives (it's inter-frame predicted; a per-frame drop
|
|
//! would cascade, so video resyncs/conceals instead), audio keeps every
|
|
//! decodable access unit, and a damaged audio AU is dropped rather than shipped
|
|
//! as a decoder-choking glitch.
|
|
//!
|
|
//! The DETECTION is inherently per-codec — each format carries its own
|
|
//! authoritative corruption check (DTS: the core sync/header parse per ETSI TS
|
|
//! 102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; …).
|
|
//! This type only carries the UNIFORM
|
|
//! response so every audio parser behaves identically:
|
|
//!
|
|
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
|
//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
|
|
//! once-per-track aggregate at `warn` so it surfaces without debug logging.
|
|
//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
|
|
//! a poison flag so the remainder is dropped too (a track that damaged isn't
|
|
//! worth muxing).
|
|
//!
|
|
//! **Sync preservation is the caller's responsibility**, not this type's: the
|
|
//! parser must advance its PTS clock across a dropped AU exactly as it would for
|
|
//! an emitted one, so a drop becomes a silence gap and never a shift of the
|
|
//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
|
|
|
|
/// Minimum access units observed before the whole-track drop verdict can fire.
|
|
/// Below this, a short damaged burst can't poison an otherwise-good track.
|
|
const TRACK_VERDICT_MIN_AUS: u64 = 200;
|
|
|
|
/// Per-track drop bookkeeping shared by the audio codec parsers.
|
|
pub(crate) struct DropTally {
|
|
/// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
|
|
codec: &'static str,
|
|
kept: u64,
|
|
dropped: u64,
|
|
/// AUs dropped because they were INDIVIDUALLY verified undecodable (a failed
|
|
/// CRC/header/parity check). Only these feed the whole-track poison verdict.
|
|
/// Distinct from `dropped`, which also counts *collateral* drops — AUs
|
|
/// discarded as a consequence of one corruption (TrueHD's resync-forward run,
|
|
/// or a poisoned track), which must NOT amplify a few real errors into a
|
|
/// false whole-track loss.
|
|
verified_dropped: u64,
|
|
dropped_dur_ns: u64,
|
|
poisoned: bool,
|
|
}
|
|
|
|
impl DropTally {
|
|
pub(crate) fn new(codec: &'static str) -> Self {
|
|
Self {
|
|
codec,
|
|
kept: 0,
|
|
dropped: 0,
|
|
verified_dropped: 0,
|
|
dropped_dur_ns: 0,
|
|
poisoned: false,
|
|
}
|
|
}
|
|
|
|
/// Whether the track has been judged too damaged to mux. Once `true`, the
|
|
/// caller should drop every remaining AU (passing them to [`record_drop`]
|
|
/// with a poison reason) rather than emit them.
|
|
pub(crate) fn is_poisoned(&self) -> bool {
|
|
self.poisoned
|
|
}
|
|
|
|
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
|
pub(crate) fn dropped_frames(&self) -> u64 {
|
|
self.dropped
|
|
}
|
|
|
|
/// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
|
|
pub(crate) fn dropped_duration_ns(&self) -> u64 {
|
|
self.dropped_dur_ns
|
|
}
|
|
|
|
/// Record an emitted (decodable) access unit.
|
|
pub(crate) fn record_kept(&mut self) {
|
|
self.kept += 1;
|
|
}
|
|
|
|
/// Record a dropped access unit that was INDIVIDUALLY verified undecodable
|
|
/// (a failed CRC/header/parity check). Counts toward the whole-track poison
|
|
/// verdict. `reason` is a short static label for the check that failed.
|
|
pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
|
self.verified_dropped += 1;
|
|
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
|
self.maybe_poison();
|
|
}
|
|
|
|
/// Record a COLLATERAL drop — an AU discarded as a consequence of another
|
|
/// corruption rather than being individually undecodable (TrueHD's
|
|
/// resync-forward run to the next major sync, or an already-poisoned track).
|
|
/// Counted and logged for the drop report, but deliberately does NOT feed the
|
|
/// poison verdict, so one corruption event can't amplify into a false
|
|
/// whole-track loss.
|
|
pub(crate) fn record_collateral_drop(
|
|
&mut self,
|
|
pts_ns: i64,
|
|
dur_ns: i64,
|
|
bytes: usize,
|
|
reason: &str,
|
|
) {
|
|
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
|
}
|
|
|
|
fn record_drop_common(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
|
self.dropped += 1;
|
|
self.dropped_dur_ns += dur_ns.max(0) as u64;
|
|
tracing::debug!(
|
|
target: "mux",
|
|
"{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
|
|
self.codec,
|
|
self.dropped,
|
|
pts_ns,
|
|
dur_ns,
|
|
bytes,
|
|
reason
|
|
);
|
|
}
|
|
|
|
/// Whole-track fallback: after enough AUs to judge, if more than half were
|
|
/// dropped the track is too damaged to be worth muxing — latch `poisoned`
|
|
/// and log it loudly once. The minimum-sample gate keeps a short damaged
|
|
/// burst from poisoning an otherwise-good track.
|
|
fn maybe_poison(&mut self) {
|
|
if self.poisoned {
|
|
return;
|
|
}
|
|
// Judge on VERIFIED drops vs all AUs seen: a track is only poisoned when
|
|
// a majority of its access units are individually undecodable — not when
|
|
// a couple of corruption events forced long collateral resync runs.
|
|
let total = self.kept + self.dropped;
|
|
if total >= TRACK_VERDICT_MIN_AUS && self.verified_dropped * 2 > total {
|
|
self.poisoned = true;
|
|
tracing::warn!(
|
|
target: "mux",
|
|
"{}: track too damaged to mux — {}/{} AUs individually undecodable (>50%); dropping the whole track",
|
|
self.codec,
|
|
self.verified_dropped,
|
|
total
|
|
);
|
|
}
|
|
}
|
|
|
|
/// End-of-stream aggregate report, logged at `warn` so a track's dropped
|
|
/// audio is never hidden even without debug logging. No-op if nothing was
|
|
/// dropped.
|
|
pub(crate) fn log_summary(&self) {
|
|
if self.dropped > 0 {
|
|
tracing::warn!(
|
|
target: "mux",
|
|
"{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
|
|
self.codec,
|
|
self.dropped,
|
|
self.dropped_dur_ns,
|
|
self.kept
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn counts_kept_and_dropped() {
|
|
let mut t = DropTally::new("test");
|
|
t.record_kept();
|
|
t.record_drop(0, 1000, 512, "bad");
|
|
t.record_kept();
|
|
assert_eq!(t.dropped_frames(), 1);
|
|
assert_eq!(t.dropped_duration_ns(), 1000);
|
|
assert!(!t.is_poisoned());
|
|
}
|
|
|
|
#[test]
|
|
fn poisons_after_min_aus_over_half_dropped() {
|
|
let mut t = DropTally::new("test");
|
|
// 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
|
|
for _ in 0..199 {
|
|
t.record_drop(0, 1000, 512, "bad");
|
|
}
|
|
assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
|
|
// The 200th drop reaches the minimum with >50% dropped → poison.
|
|
t.record_drop(0, 1000, 512, "bad");
|
|
assert!(t.is_poisoned());
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_poison_a_mostly_good_track() {
|
|
let mut t = DropTally::new("test");
|
|
// 400 AUs, 1 dropped: nowhere near 50%.
|
|
t.record_drop(0, 1000, 512, "bad");
|
|
for _ in 0..399 {
|
|
t.record_kept();
|
|
}
|
|
assert!(!t.is_poisoned());
|
|
}
|
|
|
|
/// The poison verdict is a RATIO — verified drops against every AU seen — so
|
|
/// the kept count is half of it. `does_not_poison_a_mostly_good_track` above
|
|
/// records its keeps AFTER the single drop, and `maybe_poison` only runs
|
|
/// inside `record_drop`, so the keeps are never in the denominator when the
|
|
/// verdict is actually computed: that test passes even with the kept count
|
|
/// never incremented. Interleaving them puts the kept count on the critical
|
|
/// path, where losing it turns the ratio into "verified drops vs verified
|
|
/// drops" — always >50% — and silently discards a healthy track.
|
|
#[test]
|
|
fn interleaved_keeps_are_in_the_poison_denominator() {
|
|
let mut t = DropTally::new("test");
|
|
// 2 kept per 1 dropped, well past the minimum-AU gate: a third of the
|
|
// track is undecodable, which is bad but nowhere near the >50% threshold.
|
|
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
|
t.record_kept();
|
|
t.record_kept();
|
|
t.record_drop(0, 1000, 512, "bad");
|
|
assert!(
|
|
!t.is_poisoned(),
|
|
"33% dropped must never poison, at any point in the run"
|
|
);
|
|
}
|
|
assert_eq!(t.dropped_frames(), TRACK_VERDICT_MIN_AUS * 3);
|
|
}
|
|
|
|
#[test]
|
|
fn collateral_drops_never_poison_the_track() {
|
|
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
|
|
// none are individually undecodable — the whole-track verdict must stay
|
|
// clean so one corruption event can't amplify into a false total loss.
|
|
let mut t = DropTally::new("test");
|
|
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
|
t.record_collateral_drop(0, 1000, 512, "resync-forward");
|
|
}
|
|
assert!(t.dropped_frames() >= TRACK_VERDICT_MIN_AUS, "drops counted");
|
|
assert!(!t.is_poisoned(), "collateral drops must not poison");
|
|
}
|
|
}
|