Files
libfreemkv/tests/pass_n_size_aware_skip.rs
T

491 lines
18 KiB
Rust

//! Pass N (Disc::patch) size-aware-skip targeted tests.
//!
//! The user's failure mode (2026-05-07): "what if we have a 100 sector zone
//! and its really 2 25 sector zones and we keep jumping over the good in
//! the middle." Today's pre-fix patch escalates skip-distance based on
//! `consecutive_skips_without_recovery` with hardcoded 32 → 4096 sector
//! caps. A 100-sector bad range whose actual layout is 25 bad + 50 good +
//! 25 bad would have the patch skip 32-4096 sectors after a couple of
//! failures, leaping over the entire range AND the good middle.
//!
//! The fix: cap each skip at `range_remaining/4`. These tests exercise
//! that boundary.
use libfreemkv::disc::CopyOptions;
use libfreemkv::disc::DiscRegion;
use libfreemkv::disc::PatchOptions;
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
use libfreemkv::error::Result;
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
const SECTOR_SIZE: usize = 2048;
/// Reader where you specify exactly which LBAs return Err. Everything else
/// returns Ok with the LBA encoded in each byte for verification.
struct PatternedSectorReader {
capacity: u32,
bad_lbas: HashSet<u32>,
/// Trace every read so tests can assert what was actually attempted.
trace: Arc<Mutex<Vec<(u32, u16)>>>,
}
type ReadTrace = Arc<Mutex<Vec<(u32, u16)>>>;
impl PatternedSectorReader {
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, ReadTrace) {
let trace = Arc::new(Mutex::new(Vec::new()));
(
Self {
capacity,
bad_lbas,
trace: trace.clone(),
},
trace,
)
}
}
impl SectorSource for PatternedSectorReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.trace.lock().unwrap().push((lba, count));
// Whole-batch fails if ANY sector in the batch is bad. (Models a
// real drive: a multi-sector READ aborts on the first ECC failure.)
for offset in 0..count as u32 {
if self.bad_lbas.contains(&(lba + offset)) {
return Err(libfreemkv::error::Error::ScsiError {
opcode: libfreemkv::scsi::SCSI_READ_10,
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(libfreemkv::ScsiSense {
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
asc: 0x11,
ascq: 0x00,
}),
});
}
}
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
// byte. This matches real drive behavior: a multi-sector READ
// returns per-sector-correct data. Pre-0.18.13 only single-sector
// reads were exercised by patch tests, so the cheaper "fill the
// whole batch with one byte" worked; adaptive batching needs the
// per-sector pattern to verify correct positioning.
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
chunk.fill(((lba + i as u32) & 0xff) as u8);
}
Ok(buf.len())
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
fn synthetic_disc(capacity_sectors: u32) -> Disc {
Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors,
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
}
}
/// Pre-populate a mapfile with one large NonTrimmed range so patch's work-
/// list has something to do. Caller pre-allocates the ISO at `total_bytes`
/// so seeks don't fail.
fn prep_iso_and_mapfile(
iso_path: &std::path::Path,
total_bytes: u64,
finished_ranges: &[(u64, u64)],
nontrimmed_ranges: &[(u64, u64)],
) {
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(iso_path)
.unwrap();
f.set_len(total_bytes).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[]).unwrap();
let map_path = libfreemkv::disc::mapfile_path_for(iso_path);
let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap();
for &(pos, size) in finished_ranges {
mf.record(pos, size, SectorStatus::Finished).unwrap();
}
for &(pos, size) in nontrimmed_ranges {
mf.record(pos, size, SectorStatus::NonTrimmed).unwrap();
}
}
/// THE critical test. A 100-sector "bad" range hides 50 good sectors in
/// the middle (LBAs 125-174). Pre-fix patch would skip-escalate at 32+
/// sectors and leap over the whole range. Post-fix: skip is capped at
/// range_remaining/4 (=25 sectors initially), which forces convergence.
#[test]
fn patch_recovers_good_middle_of_a_bad_range() {
let capacity_sectors: u32 = 1024;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Bad range layout: LBAs 100-124 bad, 125-174 GOOD, 175-199 bad.
let mut bad_lbas = HashSet::new();
for lba in 100..125 {
bad_lbas.insert(lba);
}
for lba in 175..200 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
// Pre-populate: 0..100 already Finished from an imagined Pass 1,
// 100..200 NonTrimmed (the range we want patch to retry),
// 200..1024 already Finished.
let finished = [
(0, 100 * 2048),
(200 * 2048, (capacity_sectors as u64 - 200) * 2048),
];
let nontrimmed = [(100 * 2048, 100 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
// Run patch.
// disc.copy() with multipass=true auto-dispatches to patch when the
// mapfile already covers the disc and has retryable ranges.
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
// Re-load mapfile and inspect.
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
// The good middle (125..175) MUST end up Finished. If size-aware skip
// is not enabled, patch would skip 32+ sectors after a few failures
// and leap clean over LBA 125 → middle stays NonTrimmed.
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let total_finished_in_middle: u64 = finished_ranges
.iter()
.map(|&(pos, sz)| {
let start = pos.max(125 * 2048);
let end = (pos + sz).min(175 * 2048);
end.saturating_sub(start)
})
.sum();
// Allow 2 sectors (4 KB) of boundary slop — patch's bisection may
// not converge exactly on the good/bad boundary in a single pass,
// and that's acceptable. The pre-fix behaviour would have left the
// entire good middle as NonTrimmed (~0 bytes recovered).
let good_middle_bytes: u64 = 50 * 2048;
let min_acceptable: u64 = good_middle_bytes - 2 * 2048;
// Cleanup before assertions
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
assert!(
total_finished_in_middle >= min_acceptable,
"size-aware skip should have discovered most of the 50 good sectors in the middle. \
Recovered {} of {} good middle bytes (min acceptable {}). bytes_good={} bytes_total={}",
total_finished_in_middle,
good_middle_bytes,
min_acceptable,
pr.bytes_good,
pr.bytes_total,
);
}
/// Regression: `PatchOptions::block_sectors == Some(0)` must not
/// busy-spin. `block_sectors` is a public `Option<u16>` field; a zero
/// value would compute a zero-length read every iteration, never
/// advance `block_end`, and burn a CPU core until the per-range
/// watchdog fired (up to 30 min on a large range). The entry-point
/// `.max(1)` clamp turns Some(0) into a single-sector batch so the
/// range recovers and the call returns promptly.
#[test]
fn patch_block_sectors_zero_does_not_busy_spin() {
let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Small NonTrimmed range that is entirely readable (no bad LBAs), so
// single-sector patch reads recover it immediately. Without the
// clamp the loop would never progress regardless of readability.
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, HashSet::new());
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 100 * 2048),
(110 * 2048, (capacity_sectors as u64 - 110) * 2048),
];
let nontrimmed = [(100 * 2048, 10 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
// A halt watchdog bounds the run: the inner loop polls `halt` every
// iteration, so even a busy-spin regression breaks out within the
// window instead of hanging the test binary. With the clamp the run
// finishes long before the watchdog fires; without it the watchdog
// trips and the bytes_good assertion below fails loudly.
let halt = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let halt_for_watchdog = halt.clone();
let watchdog = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
halt_for_watchdog.store(true, std::sync::atomic::Ordering::Relaxed);
});
let opts = PatchOptions {
decrypt: false,
block_sectors: Some(0),
full_recovery: false,
reverse: false,
wedged_threshold: 0,
progress: None,
halt: Some(halt.clone()),
};
let outcome = disc.patch(&mut reader, &iso_path, &opts);
// Stop the watchdog regardless of outcome.
halt.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = watchdog.join();
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
let outcome = outcome.expect("patch returns Ok");
assert!(
!outcome.halted,
"patch with block_sectors=Some(0) must complete on its own \
(clamped to a 1-sector batch), not be cut off by the watchdog"
);
let bytes_good = outcome.bytes_good;
// The 10-sector NonTrimmed range was fully readable; clamped to a
// 1-sector batch it must recover. Initial good = 100 + (256-110) =
// 246 sectors; after patch the 10-sector range is also Finished.
let initial_good_sectors: u64 = 100 + (capacity_sectors as u64 - 110);
assert!(
bytes_good >= (initial_good_sectors + 10) * 2048,
"block_sectors=Some(0) clamped to 1 should recover the readable range; \
bytes_good={bytes_good}"
);
}
/// A second test: a bad range that's actually 4 small bad sub-zones
/// separated by good sectors. Demonstrates the bisection behaviour
/// converges when zones are non-uniform.
#[test]
fn patch_recovers_multiple_good_middles() {
let capacity_sectors: u32 = 2048;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Bad pattern: 1000-1024 bad, 1025-1099 good, 1100-1124 bad,
// 1125-1199 good, 1200-1224 bad, 1225-1299 good.
let mut bad_lbas = HashSet::new();
for lba in 1000..1025 {
bad_lbas.insert(lba);
}
for lba in 1100..1125 {
bad_lbas.insert(lba);
}
for lba in 1200..1225 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 1000 * 2048),
(1300 * 2048, (capacity_sectors as u64 - 1300) * 2048),
];
let nontrimmed = [(1000 * 2048, 300 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let recovered: u64 = finished_ranges
.iter()
.map(|&(pos, sz)| {
let start = pos.max(1000 * 2048);
let end = (pos + sz).min(1300 * 2048);
end.saturating_sub(start)
})
.sum();
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
// Three good middles of 75 sectors each = 225 good sectors in the
// bad range. Total bad = 75. So we want at least most of 225 sectors
// (= 460800 bytes) to be Finished after patch.
let target = 200 * 2048; // be generous — anything over 200 sectors is convincing
assert!(
recovered >= target,
"size-aware skip should find most of the 3 good middles. \
Recovered {} bytes; expected ≥ {}. bytes_good={} bytes_total={}",
recovered,
target,
pr.bytes_good,
pr.bytes_total,
);
}
/// 0.18 Pass N pipeline split: exercises the new producer/consumer
/// path end-to-end on a synthetic patterned reader. Bad range layout
/// is small (5 bad LBAs surrounded by good middle) so the producer
/// emits a mix of `Recovered` and `NonTrimmed` items and the consumer
/// thread must apply both kinds. Verifies:
///
/// - `bytes_good` advances (good sectors flow producer→consumer→file
/// →mapfile with the data preserved).
/// - The recovered LBAs end up Finished; the bad LBAs end up NonTrimmed
/// (NOT Unreadable — promotion to Unreadable is the orchestrator's job
/// after the final pass).
/// - Bytes written at the recovered offsets match what the producer
/// read from the patterned source (proves the channel hand-off
/// didn't drop or reorder buffers, and the consumer's seek+write
/// landed at the right offsets).
#[test]
fn patch_pipeline_split_recovers_and_records_correctly() {
let capacity_sectors: u32 = 512;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Layout: LBAs 200-204 inclusive are bad (5 sectors), 205-249 good.
// The pre-existing range is LBAs 200-249 NonTrimmed (100 KB).
let mut bad_lbas = HashSet::new();
for lba in 200..205 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas.clone());
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 200 * 2048),
(250 * 2048, (capacity_sectors as u64 - 250) * 2048),
];
let nontrimmed = [(200 * 2048, 50 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
// Bytes_good_total should advance — the good LBAs in the bad range
// (205-249, 45 sectors) are all reachable via per-sector retry.
// Initial bytes_good = 200 * 2048 + (512-250) * 2048 = 462 sectors.
// After patch, bytes_good should be ≥ 462 + 45 = 507 sectors worth.
let initial_good_sectors: u64 = 200 + (capacity_sectors as u64 - 250);
let min_expected_good_bytes = (initial_good_sectors + 30) * 2048;
assert!(
pr.bytes_good >= min_expected_good_bytes,
"patch should have recovered most good LBAs in the bad range via the pipeline. \
bytes_good={} (expected ≥ {}); bytes_total={}",
pr.bytes_good,
min_expected_good_bytes,
pr.bytes_total,
);
// Verify the mapfile records: every good LBA is Finished, every
// bad LBA is NonTrimmed (not Finished).
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let in_finished = |lba: u32| -> bool {
let pos = lba as u64 * 2048;
finished_ranges
.iter()
.any(|&(p, sz)| pos >= p && pos < p + sz)
};
for lba in 205..250 {
assert!(
in_finished(lba),
"good LBA {lba} should be Finished after pipeline patch run"
);
}
for lba in 200..205 {
assert!(
!in_finished(lba),
"bad LBA {lba} should NOT be Finished after pipeline patch run"
);
}
// Verify the consumer wrote the producer's bytes at the right
// offsets. PatternedSectorReader fills each sector with `(lba & 0xff)
// as u8` — picking LBA 220 (well inside the recovered region) gives
// a clean signature byte to check.
use std::io::{Read, Seek, SeekFrom};
let mut iso = std::fs::File::open(&iso_path).unwrap();
iso.seek(SeekFrom::Start(220 * 2048)).unwrap();
let mut sector = [0u8; 2048];
iso.read_exact(&mut sector).unwrap();
let expected_byte = (220u32 & 0xff) as u8;
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
assert!(
sector.iter().all(|&b| b == expected_byte),
"consumer should have written PatternedSectorReader's pattern \
(byte {expected_byte:#x} for LBA 220) to the recovered offset; \
got first 8 bytes = {:?}",
&sector[..8]
);
}