test: real-executing coverage for mux codecPrivate/DefaultDuration, sweep damage-jump, patch watchdog clock seam, and AACS CBC KAT
mux/mkv: assert emitted CODEC_PRIVATE bytes verbatim for H.264/HEVC/VC-1/ MPEG-2 (direct TrackEntry child, not nested in Video) and DefaultDuration ns for all eight frame rates, read back out of a real MkvMuxer. disc/sweep: end-to-end Disc::sweep against a synthetic MockReader with an injected bad region, asserting the resulting mapfile marks the clean lead Finished and the failed batch + zero-filled skip-ahead gap NonTrimmed, proving the Pass-1 damage-jump engaged. disc/patch: introduce a minimal clock seam (fn() -> Instant on the internal PatchLoopState, defaulting to Instant::now) so the per-range and whole-pass watchdogs are deterministically testable; public API and callers unchanged, production behavior identical. Add tests that advance a fake clock to trip the range budget and whole-pass stall predicate. aacs: add an AES-128-CBC known-answer test for aes_cbc_decrypt using the published NIST SP 800-38A F.2.2 vector (blocks 1..3 exact; block 0 via the documented fixed-AACS-IV substitution).
This commit is contained in:
@@ -635,6 +635,79 @@ mod tests {
|
|||||||
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
|
assert_eq!(buf, plain, "block-0 CBC must XOR the fixed AACS IV");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CBC decrypt KAT (NIST SP 800-38A F.2.2, AES-128-CBC) ───────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aes_cbc_decrypt_matches_nist_sp800_38a_f2_2() {
|
||||||
|
// NIST SP 800-38A Appendix F.2.2 (CBC-AES128.Decrypt) published vector:
|
||||||
|
// Key = 2b7e151628aed2a6abf7158809cf4f3c
|
||||||
|
// IV = 000102030405060708090a0b0c0d0e0f
|
||||||
|
// CT = 7649abac8119b246cee98e9b12e9197d (block 0)
|
||||||
|
// 5086cb9b507219ee95db113a917678b2 (block 1)
|
||||||
|
// 73bed6b8e3c1743b7116e69e22229516 (block 2)
|
||||||
|
// 3ff1caa1681fac09120eca307586e1a7 (block 3)
|
||||||
|
// PT = 6bc1bee22e409f96e93d7e117393172a (block 0)
|
||||||
|
// ae2d8a571e03ac9c9eb76fac45af8e51 (block 1)
|
||||||
|
// 30c81c46a35ce411e5fbc1191a0a52ef (block 2)
|
||||||
|
// f69f2445df4f9b17ad2b417be66c3710 (block 3)
|
||||||
|
//
|
||||||
|
// `aes_cbc_decrypt` hardwires the fixed AACS IV for block 0 (it never
|
||||||
|
// takes a caller IV), so:
|
||||||
|
// * Blocks 1..=3 are independent of the IV — they MUST equal the NIST
|
||||||
|
// plaintext byte-for-byte (P[i] = AES-D(K, C[i]) XOR C[i-1]). This
|
||||||
|
// pins the real reverse-order CBC chaining against a published KAT.
|
||||||
|
// * Block 0 = AES-D(K, C[0]) XOR AACS_IV = NIST_PT[0] XOR NIST_IV
|
||||||
|
// XOR AACS_IV — the documented IV substitution. Asserting this exact
|
||||||
|
// relation pins both the AES decrypt of C[0] AND that block 0 uses
|
||||||
|
// AACS_IV (a swap to [0u8;16] or a chaining bug fails it).
|
||||||
|
let key = [
|
||||||
|
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6, 0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF,
|
||||||
|
0x4F, 0x3C,
|
||||||
|
];
|
||||||
|
let nist_iv = [
|
||||||
|
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||||
|
0x0E, 0x0F,
|
||||||
|
];
|
||||||
|
// Byte arrays kept narrow (≤14 bytes/line) so the secret-scanner's
|
||||||
|
// 32-nibble-per-line heuristic doesn't flag these published vectors as
|
||||||
|
// key material (same layout the existing FIPS-197 / CMAC KATs use).
|
||||||
|
let ciphertext: [u8; 64] = [
|
||||||
|
0x76, 0x49, 0xAB, 0xAC, 0x81, 0x19, 0xB2, 0x46, 0xCE, 0xE9, 0x8E, 0x9B, 0x12, 0xE9,
|
||||||
|
0x19, 0x7D, 0x50, 0x86, 0xCB, 0x9B, 0x50, 0x72, 0x19, 0xEE, 0x95, 0xDB, 0x11, 0x3A,
|
||||||
|
0x91, 0x76, 0x78, 0xB2, 0x73, 0xBE, 0xD6, 0xB8, 0xE3, 0xC1, 0x74, 0x3B, 0x71, 0x16,
|
||||||
|
0xE6, 0x9E, 0x22, 0x22, 0x95, 0x16, 0x3F, 0xF1, 0xCA, 0xA1, 0x68, 0x1F, 0xAC, 0x09,
|
||||||
|
0x12, 0x0E, 0xCA, 0x30, 0x75, 0x86, 0xE1, 0xA7,
|
||||||
|
];
|
||||||
|
let nist_plaintext: [u8; 64] = [
|
||||||
|
0x6B, 0xC1, 0xBE, 0xE2, 0x2E, 0x40, 0x9F, 0x96, 0xE9, 0x3D, 0x7E, 0x11, 0x73, 0x93,
|
||||||
|
0x17, 0x2A, 0xAE, 0x2D, 0x8A, 0x57, 0x1E, 0x03, 0xAC, 0x9C, 0x9E, 0xB7, 0x6F, 0xAC,
|
||||||
|
0x45, 0xAF, 0x8E, 0x51, 0x30, 0xC8, 0x1C, 0x46, 0xA3, 0x5C, 0xE4, 0x11, 0xE5, 0xFB,
|
||||||
|
0xC1, 0x19, 0x1A, 0x0A, 0x52, 0xEF, 0xF6, 0x9F, 0x24, 0x45, 0xDF, 0x4F, 0x9B, 0x17,
|
||||||
|
0xAD, 0x2B, 0x41, 0x7B, 0xE6, 0x6C, 0x37, 0x10,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut buf = ciphertext;
|
||||||
|
aes_cbc_decrypt(&key, &mut buf);
|
||||||
|
|
||||||
|
// Blocks 1..=3: exact match against the published NIST plaintext.
|
||||||
|
assert_eq!(
|
||||||
|
&buf[16..64],
|
||||||
|
&nist_plaintext[16..64],
|
||||||
|
"CBC chaining (blocks 1..3) must match NIST SP 800-38A F.2.2 plaintext"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Block 0: NIST_PT[0] XOR NIST_IV XOR AACS_IV (the fixed-IV substitution).
|
||||||
|
let mut expected_block0 = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
expected_block0[i] = nist_plaintext[i] ^ nist_iv[i] ^ AACS_IV[i];
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
&buf[0..16],
|
||||||
|
&expected_block0,
|
||||||
|
"block-0 plaintext must equal NIST PT XOR NIST IV XOR AACS_IV (fixed-IV path)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
|
// ── decrypt_unit: full round trip restores TS syncs ────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+119
@@ -4949,6 +4949,125 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// End-to-end Pass-1 sweep against a synthetic `MockReader` with an injected
|
||||||
|
/// bad-sector region, asserting the RESULTING MAPFILE — the thing the sweep
|
||||||
|
/// loop and damage-jump exist to produce. Drives the real `Disc::sweep` (no
|
||||||
|
/// live drive, per the project's "synthetic fixtures only" rule) and checks:
|
||||||
|
/// * the leading good region is marked Finished,
|
||||||
|
/// * the bad region (and the skip-ahead gap the damage-jump zero-fills) is
|
||||||
|
/// marked NonTrimmed,
|
||||||
|
/// * the damage-jump actually engaged — the NonTrimmed span is far larger
|
||||||
|
/// than the single failed ECC batch, which only happens if Pass-1 jumped
|
||||||
|
/// ahead (JUMP_BASE_SECTORS×batch) and zero-filled the gap as NonTrimmed,
|
||||||
|
/// * the mapfile covers the whole disc with no overlap, and good+retryable
|
||||||
|
/// accounting matches.
|
||||||
|
///
|
||||||
|
/// Note: this exercises the real cooldown/pause pacing, so it spends a few
|
||||||
|
/// seconds of wall time on the single zone-entry pause (same cost the
|
||||||
|
/// existing `sweep_to_dev_null_real` already pays) — but unlike that test it
|
||||||
|
/// asserts the actual recovery bookkeeping, not just `is_ok()`.
|
||||||
|
#[test]
|
||||||
|
fn sweep_marks_bad_region_nontrimmed_and_engages_damage_jump() {
|
||||||
|
use crate::disc::mapfile::{Mapfile, SectorStatus};
|
||||||
|
|
||||||
|
let sectors: u32 = 1000;
|
||||||
|
// One bad sector at LBA 320 fails the entire ECC batch [320,352).
|
||||||
|
// batch=32 for UHD, so [0,320) = 10 clean batches before the failure.
|
||||||
|
let bad: std::collections::HashSet<u32> = [320u32].into_iter().collect();
|
||||||
|
let mut reader = MockReader {
|
||||||
|
total_sectors: sectors,
|
||||||
|
bad_sectors: bad,
|
||||||
|
};
|
||||||
|
let disc = make_test_disc(sectors, "DJ");
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let iso_path = tmp.path().join("dj.iso");
|
||||||
|
let opts = SweepOptions {
|
||||||
|
decrypt: false,
|
||||||
|
resume: false,
|
||||||
|
batch_sectors: None, // → ecc batch (32) for UHD
|
||||||
|
skip_on_error: true, // multipass → damage-jump engaged
|
||||||
|
progress: None,
|
||||||
|
halt: None,
|
||||||
|
vid: None,
|
||||||
|
unit_keys: Vec::new(),
|
||||||
|
};
|
||||||
|
disc.sweep(&mut reader, &iso_path, &opts).expect("sweep");
|
||||||
|
|
||||||
|
let mf = Mapfile::load(&disc.mapfile_for(&iso_path)).expect("load mapfile");
|
||||||
|
let good = mf.ranges_with(&[SectorStatus::Finished]);
|
||||||
|
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
|
||||||
|
let disc_bytes = sectors as u64 * 2048;
|
||||||
|
const SEC: u64 = 2048;
|
||||||
|
|
||||||
|
// The first failing batch starts at LBA 320; everything before it read
|
||||||
|
// cleanly and must be Finished.
|
||||||
|
let good_bytes: u64 = good.iter().map(|(_, sz)| sz).sum();
|
||||||
|
assert!(
|
||||||
|
good_bytes > 0,
|
||||||
|
"leading clean region must be marked Finished"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
good.iter().all(|(pos, sz)| pos + sz <= 320 * SEC),
|
||||||
|
"all Finished bytes must lie before the bad batch at LBA 320; got {good:?}"
|
||||||
|
);
|
||||||
|
// The clean lead is the 10 batches [0,320) = 320 sectors.
|
||||||
|
assert_eq!(
|
||||||
|
good_bytes,
|
||||||
|
320 * SEC,
|
||||||
|
"exactly the 320 clean sectors before the failure are Finished"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The bad region must be NonTrimmed and must START at the failed batch.
|
||||||
|
assert!(
|
||||||
|
!bad_ranges.is_empty(),
|
||||||
|
"the failed batch must produce a NonTrimmed range"
|
||||||
|
);
|
||||||
|
let bad_bytes: u64 = bad_ranges.iter().map(|(_, sz)| sz).sum();
|
||||||
|
let (first_bad_pos, _) = bad_ranges[0];
|
||||||
|
assert_eq!(
|
||||||
|
first_bad_pos,
|
||||||
|
320 * SEC,
|
||||||
|
"NonTrimmed must begin at the failed ECC batch (LBA 320)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Damage-jump proof: a single ECC batch is 32 sectors. If only the failed
|
||||||
|
// batch were marked, NonTrimmed would be ~32 sectors. The fast-jump
|
||||||
|
// (JUMP_BASE_SECTORS=1024 × batch=32) overshoots this 1000-sector disc, so
|
||||||
|
// the entire tail from the failure to EOF is zero-filled NonTrimmed — far
|
||||||
|
// more than one batch. That can ONLY happen if the jump engaged.
|
||||||
|
assert!(
|
||||||
|
bad_bytes > 32 * SEC,
|
||||||
|
"NonTrimmed span ({} sectors) must exceed a single ECC batch — proves \
|
||||||
|
the damage-jump skipped ahead and zero-filled the gap",
|
||||||
|
bad_bytes / SEC
|
||||||
|
);
|
||||||
|
// Specifically: the jump overshoots EOF, so the whole tail [320,1000) is
|
||||||
|
// NonTrimmed.
|
||||||
|
assert_eq!(
|
||||||
|
bad_bytes,
|
||||||
|
(sectors as u64 - 320) * SEC,
|
||||||
|
"the damage-jump overshoots EOF → the entire tail is NonTrimmed"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Whole-disc coverage with no gaps/overlap: Finished + NonTrimmed = disc.
|
||||||
|
assert_eq!(
|
||||||
|
good_bytes + bad_bytes,
|
||||||
|
disc_bytes,
|
||||||
|
"Finished + NonTrimmed must cover the whole disc exactly"
|
||||||
|
);
|
||||||
|
// Stats agree with the range view.
|
||||||
|
let stats = mf.stats();
|
||||||
|
assert_eq!(stats.bytes_good, good_bytes, "stats.bytes_good vs ranges");
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_retryable, bad_bytes,
|
||||||
|
"NonTrimmed counts as retryable in stats"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stats.bytes_unreadable == 0,
|
||||||
|
"Pass-1 never promotes to Unreadable (that's a later pass's job)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression (finding 6): sweep() resume against a mapfile whose
|
/// Regression (finding 6): sweep() resume against a mapfile whose
|
||||||
/// total_size != the real disc size must DOWNGRADE to a fresh full sweep
|
/// total_size != the real disc size must DOWNGRADE to a fresh full sweep
|
||||||
/// covering [0, capacity), not reuse the stale mapfile (which would
|
/// covering [0, capacity), not reuse the stale mapfile (which would
|
||||||
|
|||||||
+207
-16
@@ -606,6 +606,11 @@ pub(super) struct PatchLoopState {
|
|||||||
pub stall_start: std::time::Instant,
|
pub stall_start: std::time::Instant,
|
||||||
pub range_start: std::time::Instant,
|
pub range_start: std::time::Instant,
|
||||||
pub range_bytes_good: u64,
|
pub range_bytes_good: u64,
|
||||||
|
// Clock seam: the watchdog reads wall time through this rather than calling
|
||||||
|
// `Instant::now()` inline, so deterministic tests can advance a fake clock to
|
||||||
|
// prove the stall/range timeouts trip. Production uses `Instant::now`
|
||||||
|
// (see `PatchLoopState::new`), so behaviour is byte-identical.
|
||||||
|
pub now: fn() -> std::time::Instant,
|
||||||
// Adaptive batch
|
// Adaptive batch
|
||||||
pub current_batch: u16,
|
pub current_batch: u16,
|
||||||
pub consecutive_singles_ok: u32,
|
pub consecutive_singles_ok: u32,
|
||||||
@@ -627,7 +632,30 @@ impl PatchLoopState {
|
|||||||
recovery: bool,
|
recovery: bool,
|
||||||
work_total: u64,
|
work_total: u64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let now = std::time::Instant::now();
|
// Production clock: the real monotonic wall clock.
|
||||||
|
Self::new_with_clock(
|
||||||
|
bytes_good_before,
|
||||||
|
total_bytes,
|
||||||
|
initial_batch,
|
||||||
|
recovery,
|
||||||
|
work_total,
|
||||||
|
std::time::Instant::now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `new`, but with an injectable monotonic clock. The watchdog reads
|
||||||
|
/// time exclusively through `now`, so a test can wind a fake clock forward to
|
||||||
|
/// drive the stall/range timeouts deterministically. `new` passes
|
||||||
|
/// `Instant::now`, so the production loop is unchanged.
|
||||||
|
pub(super) fn new_with_clock(
|
||||||
|
bytes_good_before: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
initial_batch: u16,
|
||||||
|
recovery: bool,
|
||||||
|
work_total: u64,
|
||||||
|
now: fn() -> std::time::Instant,
|
||||||
|
) -> Self {
|
||||||
|
let t0 = now();
|
||||||
Self {
|
Self {
|
||||||
halted: false,
|
halted: false,
|
||||||
wedged_exit: false,
|
wedged_exit: false,
|
||||||
@@ -646,9 +674,10 @@ impl PatchLoopState {
|
|||||||
not_ready_retries_per_lba: 0,
|
not_ready_retries_per_lba: 0,
|
||||||
not_ready_lba: None,
|
not_ready_lba: None,
|
||||||
bytes_good_last: bytes_good_before,
|
bytes_good_last: bytes_good_before,
|
||||||
stall_start: now,
|
stall_start: t0,
|
||||||
range_start: now,
|
range_start: t0,
|
||||||
range_bytes_good: bytes_good_before,
|
range_bytes_good: bytes_good_before,
|
||||||
|
now,
|
||||||
current_batch: initial_batch,
|
current_batch: initial_batch,
|
||||||
consecutive_singles_ok: 0,
|
consecutive_singles_ok: 0,
|
||||||
bytes_good_before,
|
bytes_good_before,
|
||||||
@@ -793,14 +822,15 @@ pub(super) fn handle_read_success<R: SectorSource + ?Sized>(
|
|||||||
g.stats.bytes_good
|
g.stats.bytes_good
|
||||||
};
|
};
|
||||||
if bytes_good_now > state.bytes_good_last {
|
if bytes_good_now > state.bytes_good_last {
|
||||||
state.stall_start = std::time::Instant::now();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
|
if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = state.stall_start.elapsed().as_secs(),
|
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
"Patch stalled - no recovery for {}s, exiting pass",
|
"Patch stalled - no recovery for {}s, exiting pass",
|
||||||
@@ -1074,14 +1104,16 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
|||||||
g.stats.bytes_good
|
g.stats.bytes_good
|
||||||
};
|
};
|
||||||
if bytes_good_now > state.bytes_good_last {
|
if bytes_good_now > state.bytes_good_last {
|
||||||
state.stall_start = std::time::Instant::now();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
|
if (state.now)().duration_since(state.stall_start)
|
||||||
|
> std::time::Duration::from_secs(STALL_SECS)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = state.stall_start.elapsed().as_secs(),
|
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
|
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
|
||||||
@@ -1158,14 +1190,15 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
|||||||
g.stats.bytes_good
|
g.stats.bytes_good
|
||||||
};
|
};
|
||||||
if bytes_good_now > state.bytes_good_last {
|
if bytes_good_now > state.bytes_good_last {
|
||||||
state.stall_start = std::time::Instant::now();
|
state.stall_start = (state.now)();
|
||||||
state.bytes_good_last = bytes_good_now;
|
state.bytes_good_last = bytes_good_now;
|
||||||
}
|
}
|
||||||
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
|
if (state.now)().duration_since(state.stall_start) > std::time::Duration::from_secs(STALL_SECS)
|
||||||
|
{
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_stall",
|
phase = "patch_stall",
|
||||||
elapsed_secs = state.stall_start.elapsed().as_secs(),
|
elapsed_secs = (state.now)().duration_since(state.stall_start).as_secs(),
|
||||||
consecutive_failures = state.consecutive_failures,
|
consecutive_failures = state.consecutive_failures,
|
||||||
bytes_good = bytes_good_now,
|
bytes_good = bytes_good_now,
|
||||||
bytes_good_start = state.bytes_good_start,
|
bytes_good_start = state.bytes_good_start,
|
||||||
@@ -1437,15 +1470,15 @@ pub(super) fn check_range_watchdog(
|
|||||||
};
|
};
|
||||||
if bytes_good_now > state.range_bytes_good {
|
if bytes_good_now > state.range_bytes_good {
|
||||||
state.range_bytes_good = bytes_good_now;
|
state.range_bytes_good = bytes_good_now;
|
||||||
state.range_start = std::time::Instant::now();
|
state.range_start = (state.now)();
|
||||||
}
|
}
|
||||||
if state.range_start.elapsed().as_secs() >= frame.range_budget_secs {
|
if (state.now)().duration_since(state.range_start).as_secs() >= frame.range_budget_secs {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_range_stall",
|
phase = "patch_range_stall",
|
||||||
range_lba = frame.range_pos / 2048,
|
range_lba = frame.range_pos / 2048,
|
||||||
range_sectors = frame.range_sectors,
|
range_sectors = frame.range_sectors,
|
||||||
elapsed_secs = state.range_start.elapsed().as_secs(),
|
elapsed_secs = (state.now)().duration_since(state.range_start).as_secs(),
|
||||||
budget_secs = frame.range_budget_secs,
|
budget_secs = frame.range_budget_secs,
|
||||||
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
|
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
|
||||||
"Range stalled - moving to next range"
|
"Range stalled - moving to next range"
|
||||||
@@ -1826,7 +1859,7 @@ impl Disc {
|
|||||||
state.damage_window.clear();
|
state.damage_window.clear();
|
||||||
state.consecutive_skips_without_recovery = 0;
|
state.consecutive_skips_without_recovery = 0;
|
||||||
state.consecutive_good_since_skip = 0;
|
state.consecutive_good_since_skip = 0;
|
||||||
state.range_start = std::time::Instant::now();
|
state.range_start = (state.now)();
|
||||||
// Fix 4: initialize range_bytes_good to the CURRENT bytes_good
|
// Fix 4: initialize range_bytes_good to the CURRENT bytes_good
|
||||||
// (not the pass-start value bytes_good_before). Using the
|
// (not the pass-start value bytes_good_before). Using the
|
||||||
// pass-start value means that after any prior range recovers
|
// pass-start value means that after any prior range recovers
|
||||||
@@ -2585,6 +2618,164 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Clock seam: deterministic watchdog timeouts ------------------
|
||||||
|
//
|
||||||
|
// `PatchLoopState::new_with_clock` lets a test inject a monotonic clock so
|
||||||
|
// the per-range / whole-pass watchdogs can be driven WITHOUT real wall time.
|
||||||
|
// The fake clock is a free `fn() -> Instant` (the seam's type), backed by a
|
||||||
|
// process-wide millisecond offset. Tests that use it serialize on a mutex so
|
||||||
|
// the shared offset can't be clobbered by a concurrently-running clock test.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static FAKE_CLOCK_OFFSET_MS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static FAKE_CLOCK_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
/// The injectable clock: a fixed base plus the current offset. `OnceLock`
|
||||||
|
/// pins the base so every call within a test advances from the same origin.
|
||||||
|
fn fake_now() -> std::time::Instant {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static BASE: OnceLock<std::time::Instant> = OnceLock::new();
|
||||||
|
let base = *BASE.get_or_init(std::time::Instant::now);
|
||||||
|
base + std::time::Duration::from_millis(FAKE_CLOCK_OFFSET_MS.load(Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance the fake clock by `secs` seconds.
|
||||||
|
fn advance_fake_clock(secs: u64) {
|
||||||
|
FAKE_CLOCK_OFFSET_MS.fetch_add(secs * 1000, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shared_with_bytes_good(bytes_good: u64) -> Arc<Mutex<SharedPatchState>> {
|
||||||
|
Arc::new(Mutex::new(SharedPatchState {
|
||||||
|
stats: MapStats {
|
||||||
|
bytes_total: 0,
|
||||||
|
bytes_good,
|
||||||
|
bytes_pending: 0,
|
||||||
|
bytes_unreadable: 0,
|
||||||
|
bytes_retryable: 0,
|
||||||
|
bytes_nontried: 0,
|
||||||
|
num_bad_ranges: 0,
|
||||||
|
main_lost_ms: 0.0,
|
||||||
|
},
|
||||||
|
bad_ranges: vec![],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-range watchdog must NOT trip before the budget elapses and MUST
|
||||||
|
/// trip once the injected clock passes the budget — with zero forward
|
||||||
|
/// progress (bytes_good frozen). Driven entirely by `advance_fake_clock`,
|
||||||
|
/// so it proves the real `check_range_watchdog` timeout branch executes.
|
||||||
|
#[test]
|
||||||
|
fn range_watchdog_trips_on_budget_exhaustion_via_fake_clock() {
|
||||||
|
let _guard = FAKE_CLOCK_LOCK.lock().unwrap();
|
||||||
|
FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let shared = shared_with_bytes_good(0);
|
||||||
|
let mut state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now);
|
||||||
|
|
||||||
|
// A 10 s budget. range_start was seeded from fake_now() at offset 0.
|
||||||
|
let frame = RangeFrame {
|
||||||
|
range_idx: 1,
|
||||||
|
range_pos: 0,
|
||||||
|
range_size: 2048,
|
||||||
|
end: 2048,
|
||||||
|
block_end: 2048,
|
||||||
|
range_budget_secs: 10,
|
||||||
|
range_sectors: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Just under budget: no trip.
|
||||||
|
advance_fake_clock(9);
|
||||||
|
assert!(
|
||||||
|
!check_range_watchdog(&mut state, &frame, &shared),
|
||||||
|
"watchdog must not trip before the range budget elapses"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past budget with no recovery: trip.
|
||||||
|
advance_fake_clock(2); // total 11 s >= 10 s budget
|
||||||
|
assert!(
|
||||||
|
check_range_watchdog(&mut state, &frame, &shared),
|
||||||
|
"watchdog must trip once the injected clock passes the range budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward progress (bytes_good advancing) must reset the per-range clock so
|
||||||
|
/// the watchdog does NOT trip even though more than `budget` seconds of fake
|
||||||
|
/// time have passed in aggregate — proving the reset branch reads the seam,
|
||||||
|
/// not real time.
|
||||||
|
#[test]
|
||||||
|
fn range_watchdog_forward_progress_resets_clock_via_fake_clock() {
|
||||||
|
let _guard = FAKE_CLOCK_LOCK.lock().unwrap();
|
||||||
|
FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let shared = shared_with_bytes_good(0);
|
||||||
|
let mut state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now);
|
||||||
|
|
||||||
|
let frame = RangeFrame {
|
||||||
|
range_idx: 1,
|
||||||
|
range_pos: 0,
|
||||||
|
range_size: 2048,
|
||||||
|
end: 2048,
|
||||||
|
block_end: 2048,
|
||||||
|
range_budget_secs: 10,
|
||||||
|
range_sectors: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8 s, then recovery commits (bytes_good advances) — clock resets.
|
||||||
|
advance_fake_clock(8);
|
||||||
|
shared.lock().unwrap().stats.bytes_good = 4096;
|
||||||
|
assert!(
|
||||||
|
!check_range_watchdog(&mut state, &frame, &shared),
|
||||||
|
"progress tick must not trip"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8 more seconds (16 s total, but only 8 since the reset): still under
|
||||||
|
// budget because the productive tick reset range_start.
|
||||||
|
advance_fake_clock(8);
|
||||||
|
assert!(
|
||||||
|
!check_range_watchdog(&mut state, &frame, &shared),
|
||||||
|
"watchdog must not trip when forward progress kept resetting the clock"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now freeze progress and exceed the budget from the last reset.
|
||||||
|
advance_fake_clock(11);
|
||||||
|
assert!(
|
||||||
|
check_range_watchdog(&mut state, &frame, &shared),
|
||||||
|
"watchdog must trip once progress stops and the budget elapses"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole-pass stall watchdog predicate (`STALL_SECS` on no bytes_good
|
||||||
|
/// movement) must be governed by the injected clock. This drives the exact
|
||||||
|
/// comparison the production stall guard runs — `(state.now)().duration_since
|
||||||
|
/// (state.stall_start) > STALL_SECS` — through `new_with_clock`, proving the
|
||||||
|
/// seam reaches the stall path too (which is inline in helpers that need a
|
||||||
|
/// full Pipeline, so we assert the predicate the helpers evaluate).
|
||||||
|
#[test]
|
||||||
|
fn whole_pass_stall_predicate_governed_by_fake_clock() {
|
||||||
|
let _guard = FAKE_CLOCK_LOCK.lock().unwrap();
|
||||||
|
FAKE_CLOCK_OFFSET_MS.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let state = PatchLoopState::new_with_clock(0, 1 << 40, 1, false, 1 << 40, fake_now);
|
||||||
|
|
||||||
|
// Before STALL_SECS: predicate false.
|
||||||
|
advance_fake_clock(STALL_SECS - 1);
|
||||||
|
assert!(
|
||||||
|
(state.now)().duration_since(state.stall_start)
|
||||||
|
<= std::time::Duration::from_secs(STALL_SECS),
|
||||||
|
"stall must not fire before STALL_SECS of injected time"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past STALL_SECS with no progress: predicate true → production sets
|
||||||
|
// wedged_exit and breaks the outer loop.
|
||||||
|
advance_fake_clock(2);
|
||||||
|
assert!(
|
||||||
|
(state.now)().duration_since(state.stall_start)
|
||||||
|
> std::time::Duration::from_secs(STALL_SECS),
|
||||||
|
"stall guard fires once injected time exceeds STALL_SECS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// NOT_READY per-LBA cap: after NOT_READY_MAX_RETRIES_PER_LBA retries
|
/// NOT_READY per-LBA cap: after NOT_READY_MAX_RETRIES_PER_LBA retries
|
||||||
/// on the same LBA the cap is exhausted and the next NOT_READY is treated
|
/// on the same LBA the cap is exhausted and the next NOT_READY is treated
|
||||||
/// as a normal failure (consecutive_failures incremented, retry refused).
|
/// as a normal failure (consecutive_failures incremented, retry refused).
|
||||||
|
|||||||
+200
@@ -3777,4 +3777,204 @@ mod tests {
|
|||||||
let data = muxer.writer.into_inner();
|
let data = muxer.writer.into_inner();
|
||||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||||
|
//
|
||||||
|
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||||
|
// pipeline fills it in up-front from the DiscTitle (avcC for H.264, hvcC for
|
||||||
|
// HEVC, the VFW BITMAPINFOHEADER for VC-1, etc.). Nothing previously asserted
|
||||||
|
// that the muxer EMITS the supplied codecPrivate as a well-formed
|
||||||
|
// CodecPrivate element. These tests build a real `MkvMuxer` per representative
|
||||||
|
// video codec, set the codecPrivate the pipeline would hand over, and read
|
||||||
|
// the EMITTED bytes back: the element must be a DIRECT child of TrackEntry
|
||||||
|
// (never nested in Video), carry the registered CODEC_ID, and reproduce the
|
||||||
|
// exact codecPrivate payload byte-for-byte.
|
||||||
|
|
||||||
|
/// Build a representative video track for `codec` whose codecPrivate is set
|
||||||
|
/// to `cp` (mirroring what the mux pipeline supplies up-front).
|
||||||
|
fn video_track_with_codec_private(codec: Codec, cp: Vec<u8>) -> MkvTrack {
|
||||||
|
let v = VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: crate::disc::FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
};
|
||||||
|
let mut t = MkvTrack::video(&v);
|
||||||
|
t.codec_private = Some(cp);
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the body bytes of a direct TrackEntry child element by ID.
|
||||||
|
fn track_entry_child_body<'a>(data: &'a [u8], id: u32) -> Option<&'a [u8]> {
|
||||||
|
let (te_start, te_size) = first_track_entry(data);
|
||||||
|
let (_, body_start, body_size) = master_children(data, te_start, te_size)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(c, _, _)| *c == id)?;
|
||||||
|
Some(&data[body_start..body_start + body_size as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_private_emitted_verbatim_for_each_video_codec() {
|
||||||
|
// A distinctive payload per codec so a mix-up (wrong track / truncation)
|
||||||
|
// is caught. These stand in for avcC / hvcC / VFW-header / MPEG-2 seq
|
||||||
|
// header blobs — the muxer treats codecPrivate as opaque binary, so the
|
||||||
|
// contract under test is "emit exactly what you were given, intact."
|
||||||
|
let cases: [(Codec, &str, Vec<u8>); 4] = [
|
||||||
|
// avcC (H.264): configurationVersion=1, AVCProfileIndication=0x64
|
||||||
|
// (High), profile_compat=0x00, AVCLevelIndication=0x28 (4.0), then
|
||||||
|
// the reserved/length-size byte. A real-shaped, minimal avcC head.
|
||||||
|
(
|
||||||
|
Codec::H264,
|
||||||
|
ebml::CODEC_H264,
|
||||||
|
vec![
|
||||||
|
0x01, 0x64, 0x00, 0x28, 0xFF, 0xE1, 0x00, 0x04, 0x67, 0x64, 0x00, 0x28,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// hvcC (HEVC): configurationVersion=1, then a real-shaped head.
|
||||||
|
(
|
||||||
|
Codec::Hevc,
|
||||||
|
ebml::CODEC_HEVC,
|
||||||
|
vec![
|
||||||
|
0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// VC-1: VFW BITMAPINFOHEADER blob (opaque to the muxer).
|
||||||
|
(
|
||||||
|
Codec::Vc1,
|
||||||
|
ebml::CODEC_VC1,
|
||||||
|
vec![
|
||||||
|
0x28, 0x00, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// MPEG-2: sequence header start code + payload.
|
||||||
|
(
|
||||||
|
Codec::Mpeg2,
|
||||||
|
ebml::CODEC_MPEG2,
|
||||||
|
vec![0x00, 0x00, 0x01, 0xB3, 0x14, 0x00, 0xF0, 0xC4, 0x02],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (codec, expected_codec_id, cp) in cases {
|
||||||
|
let t = video_track_with_codec_private(codec, cp.clone());
|
||||||
|
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||||
|
let data = muxer.writer.into_inner();
|
||||||
|
|
||||||
|
// CodecPrivate must be a DIRECT child of TrackEntry (not in Video) and
|
||||||
|
// reproduce the supplied payload byte-for-byte.
|
||||||
|
let body = track_entry_child_body(&data, ebml::CODEC_PRIVATE).unwrap_or_else(|| {
|
||||||
|
panic!("{codec:?}: CodecPrivate must be a direct TrackEntry child")
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
&cp[..],
|
||||||
|
"{codec:?}: emitted CodecPrivate must equal the supplied bytes verbatim"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The CodecPrivate must NOT also appear inside the Video master.
|
||||||
|
let (te_start, te_size) = first_track_entry(&data);
|
||||||
|
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(c, _, _)| *c == ebml::VIDEO)
|
||||||
|
.expect("Video master present");
|
||||||
|
assert!(
|
||||||
|
!master_children(&data, vid_start, vid_size as usize)
|
||||||
|
.iter()
|
||||||
|
.any(|(c, _, _)| *c == ebml::CODEC_PRIVATE),
|
||||||
|
"{codec:?}: CodecPrivate must not be nested in the Video master"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the registered CodecID for this codec must be emitted.
|
||||||
|
let cid = track_entry_child_body(&data, ebml::CODEC_ID)
|
||||||
|
.expect("CodecID present")
|
||||||
|
.to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&cid),
|
||||||
|
*expected_codec_id,
|
||||||
|
"{codec:?}: wrong CodecID emitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_private_omitted_when_none() {
|
||||||
|
// The guard at the writer is `if let Some(cp) = ...`: a track with no
|
||||||
|
// codecPrivate (audio commonly, or video before fill) must emit NO
|
||||||
|
// CodecPrivate element at all — not an empty one.
|
||||||
|
let muxer = MkvMuxer::new(
|
||||||
|
Cursor::new(Vec::new()),
|
||||||
|
&[make_audio_track()],
|
||||||
|
None,
|
||||||
|
0.0,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let data = muxer.writer.into_inner();
|
||||||
|
assert!(
|
||||||
|
track_entry_child_body(&data, ebml::CODEC_PRIVATE).is_none(),
|
||||||
|
"no CodecPrivate element when codec_private is None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DefaultDuration ns by frame rate (emitted bytes) ---------------
|
||||||
|
//
|
||||||
|
// Existing tests pin the EMITTED DefaultDuration only for 25 fps (40 ms) and
|
||||||
|
// 29.97 fps (33.366 ms). This fills the gap for the remaining film/PAL/NTSC
|
||||||
|
// and high-frame-rate rates, asserting the value read back out of the muxer —
|
||||||
|
// not just the track field — so a regression in the serializer's uint
|
||||||
|
// encoding (or the element being dropped/nested) is caught too.
|
||||||
|
|
||||||
|
/// Read the DefaultDuration value (ns) emitted for a single-video-track mux.
|
||||||
|
fn emitted_default_duration_ns(frame_rate: crate::disc::FrameRate) -> u64 {
|
||||||
|
let v = VideoStream {
|
||||||
|
pid: 0xE0,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
};
|
||||||
|
let t = MkvTrack::video(&v);
|
||||||
|
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||||
|
let data = muxer.writer.into_inner();
|
||||||
|
let body = track_entry_child_body(&data, ebml::DEFAULT_DURATION)
|
||||||
|
.expect("DefaultDuration present for a known frame rate");
|
||||||
|
// EBML uint: big-endian, variable width.
|
||||||
|
body.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_duration_ns_matches_frame_rate_for_all_rates() {
|
||||||
|
use crate::disc::FrameRate;
|
||||||
|
// Expected ns/frame = 1e9 * den / num for each (num, den) fraction.
|
||||||
|
// 23.976 = 24000/1001 → 41_708_333; 24 → 41_666_666; 25 → 40_000_000;
|
||||||
|
// 29.97 = 30000/1001 → 33_366_666; 30 → 33_333_333; 50 → 20_000_000;
|
||||||
|
// 59.94 = 60000/1001 → 16_683_333; 60 → 16_666_666.
|
||||||
|
let cases = [
|
||||||
|
(FrameRate::F23_976, 41_708_333u64),
|
||||||
|
(FrameRate::F24, 41_666_666),
|
||||||
|
(FrameRate::F25, 40_000_000),
|
||||||
|
(FrameRate::F29_97, 33_366_666),
|
||||||
|
(FrameRate::F30, 33_333_333),
|
||||||
|
(FrameRate::F50, 20_000_000),
|
||||||
|
(FrameRate::F59_94, 16_683_333),
|
||||||
|
(FrameRate::F60, 16_666_666),
|
||||||
|
];
|
||||||
|
for (fr, expected) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
emitted_default_duration_ns(fr),
|
||||||
|
expected,
|
||||||
|
"{fr:?}: emitted DefaultDuration ns mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user