1.6.0: remove recovery strategy (moved to freemkv-engine) + trim dead surface

The sweep/patch recovery strategy, mapfile, retry-decision state machine,
section-recover, and damage classification move out of libfreemkv into the
new freemkv-engine crate. libfreemkv keeps the raw single-shot read and
SCSI-fact translation (SenseFamily stays in scsi).

- Delete disc/{sweep,patch,mapfile,read_error,section_recover}.rs, the
  Disc::copy/sweep/patch methods, the Copy/Sweep/Patch option+result types,
  classify_damage/DamageSeverity, progress_snapshot_from_mapfile, and the
  three recovery integration tests.
- Trim public surface the recovery deletion orphaned: delete the dead
  READ_PIPELINE_DEPTH const, the write-side SectorSink/FileSectorSink (no
  consumer), and the DriveSpeed enum (its one live use — set max drive
  speed — becomes Drive::SPEED_MAX_KBPS). Make mapfile_path_for,
  decrypt_sectors_mapped pub(crate); gate NoopEvents to test.
- Version 1.6.0.
This commit is contained in:
Matthew Jackson
2026-07-28 15:35:19 -07:00
parent 3f238ab3dc
commit bbb5a953f1
21 changed files with 36 additions and 12398 deletions
-844
View File
@@ -1,844 +0,0 @@
//! Integration tests for progress reporting, halt behavior, drop safety,
//! and the file-backed sector reader round trip.
use libfreemkv::disc::{CopyOptions, DiscRegion};
use libfreemkv::error::Result;
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::{
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource,
SectorSource,
};
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
const SECTOR_SIZE: usize = 2048;
// ── helpers ────────────────────────────────────────────────────────────────
/// Returns zeroed sectors. Always succeeds. Counts each call.
struct ZeroSectorReader {
capacity: u32,
calls: Arc<AtomicU64>,
}
impl ZeroSectorReader {
fn new(capacity: u32) -> Self {
Self {
capacity,
calls: Arc::new(AtomicU64::new(0)),
}
}
}
impl SectorSource for ZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.calls.fetch_add(1, Ordering::Relaxed);
let bytes = count as usize * SECTOR_SIZE;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
/// Like ZeroSectorReader but sleeps a configurable duration per call.
/// Used by the halt test so the copy takes >1 s.
struct SlowZeroSectorReader {
capacity: u32,
sleep_per_call: Duration,
}
impl SlowZeroSectorReader {
fn new(capacity: u32, sleep_per_call: Duration) -> Self {
Self {
capacity,
sleep_per_call,
}
}
}
impl SectorSource for SlowZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
std::thread::sleep(self.sleep_per_call);
let bytes = count as usize * SECTOR_SIZE;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
/// Build a Disc instance with a known capacity, no titles, no encryption.
/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys).
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,
}
}
/// Build a DiscTitle with a single extent of `sector_count` sectors and no
/// streams (DiscStream still iterates sectors and would emit BytesRead).
fn synthetic_title(sector_count: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: sector_count as u64 * SECTOR_SIZE as u64,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![Extent {
start_lba: 0,
sector_count,
}],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
// ── 1. BytesRead events emitted during disc copy ──────────────────────────
#[test]
fn test_bytes_read_emitted_during_disc_copy() {
// Build a tiny synthetic disc and stream it through DiscStream.
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let count = Arc::new(AtomicU64::new(0));
let count_cb = count.clone();
stream.on_event(move |ev| {
if let EventKind::BytesRead { .. } = ev.kind {
count_cb.fetch_add(1, Ordering::Relaxed);
}
});
// Drive the stream to EOF. With no streams configured, read() returns
// Ok(None) once all extents are exhausted.
loop {
match stream.read() {
Ok(Some(_frame)) => {}
Ok(None) => break,
Err(e) => panic!("stream read failed: {e:?}"),
}
}
let n = count.load(Ordering::Relaxed);
assert!(
n > 0,
"expected at least one BytesRead event during disc copy, got {n}"
);
}
// ── 2. Disc::copy on_progress callback fires (regression guard) ───────────
#[test]
fn test_disc_copy_progress_callback_fires() {
let disc = synthetic_disc(64);
let mut reader = ZeroSectorReader::new(64);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp); // we want the path, not the file handle
let calls = Arc::new(AtomicU64::new(0));
let last_bytes = Arc::new(AtomicU64::new(0));
struct CountingReporter {
calls: Arc<AtomicU64>,
last_bytes: Arc<AtomicU64>,
}
impl libfreemkv::progress::Progress for CountingReporter {
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
self.calls.fetch_add(1, Ordering::Relaxed);
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
true
}
}
let reporter = CountingReporter {
calls: calls.clone(),
last_bytes: last_bytes.clone(),
};
let opts = CopyOptions {
decrypt: false,
progress: Some(&reporter),
..Default::default()
};
let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok");
// Cleanup any sidecar mapfile + ISO before assertions.
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
assert!(result.complete, "copy should be complete");
let n = calls.load(Ordering::Relaxed);
let last = last_bytes.load(Ordering::Relaxed);
assert!(n > 0, "on_progress should fire at least once, got {n}");
assert!(
last > 0,
"final progress bytes should be non-zero, got {last}"
);
}
// ── 3. Halt aborts disc copy promptly ─────────────────────────────────────
#[test]
fn test_halt_aborts_disc_copy_promptly() {
// 6000 sectors, 60-sector batches → 100 read_sectors() calls.
// 10 ms sleep per call → ~1 s total without halt.
let capacity_sectors: u32 = 6000;
let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10));
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let halt = Arc::new(AtomicBool::new(false));
let halt_for_thread = halt.clone();
let iso_path_for_thread = iso_path.clone();
let join = std::thread::spawn(move || {
let opts = CopyOptions {
decrypt: false,
halt: Some(halt_for_thread),
..Default::default()
};
let t0 = Instant::now();
let res = disc.copy(&mut reader, &iso_path_for_thread, &opts);
(res, t0.elapsed())
});
// Let copy run, then halt.
std::thread::sleep(Duration::from_millis(200));
halt.store(true, Ordering::Relaxed);
// Bound the join: should exit far before the full 1 s otherwise needed.
let started = Instant::now();
let mut joined = None;
while started.elapsed() < Duration::from_millis(2000) {
if join.is_finished() {
joined = Some(join.join().expect("thread join"));
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt");
// Cleanup
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
let copy_result = result.expect("copy returns Ok with halted=true on halt");
assert!(
copy_result.halted,
"copy_result.halted should be true after halt"
);
assert!(
!copy_result.complete,
"copy_result.complete should be false when halted"
);
assert!(
elapsed < Duration::from_millis(2000),
"copy thread exit elapsed {elapsed:?} exceeded 2s"
);
}
// ── 4. DiscStream Drop does not panic or block ────────────────────────────
#[test]
fn test_drop_impls_do_not_panic_or_block() {
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let stream = DiscStream::new(
Box::new(reader),
title,
keys,
60,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
// Drop on a worker thread; main thread enforces the timeout.
let handle = std::thread::spawn(move || {
drop(stream);
});
let started = Instant::now();
while started.elapsed() < Duration::from_millis(100) {
if handle.is_finished() {
handle.join().expect("drop thread join");
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("DiscStream drop did not complete within 100ms");
}
// ── 5. FileSectorSource round trip ────────────────────────────────────────
#[test]
fn test_file_sector_reader_round_trip() {
// Build 8 sectors of pseudo-random bytes (sector-aligned).
const N_SECTORS: usize = 8;
let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE];
for (i, b) in data.iter_mut().enumerate() {
// Cheap PRNG: just a multiplicative pattern, deterministic for asserts.
*b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8;
}
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create");
tmp.write_all(&data).expect("write data");
tmp.flush().expect("flush");
let path = tmp.path().to_path_buf();
let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource");
assert_eq!(
fsr.capacity_sectors(),
N_SECTORS as u32,
"capacity mismatch"
);
// Read each sector individually and compare.
let mut buf = vec![0u8; SECTOR_SIZE];
for lba in 0..N_SECTORS as u32 {
let n = fsr
.read_sectors(lba, 1, &mut buf, false)
.expect("read_sectors");
assert_eq!(n, SECTOR_SIZE);
let off = lba as usize * SECTOR_SIZE;
assert_eq!(
&buf[..],
&data[off..off + SECTOR_SIZE],
"sector {lba} mismatch"
);
}
// Read all sectors at once and compare.
let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE];
let n = fsr
.read_sectors(0, N_SECTORS as u16, &mut all, false)
.expect("read all sectors");
assert_eq!(n, N_SECTORS * SECTOR_SIZE);
assert_eq!(all, data, "bulk read mismatch");
}
// ── 6. Pass 1 sweeps the entire disc even when every read fails ───────────
//
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
// regardless of how many reads fail. The only legitimate early exit is the
// halt flag. With `skip_on_error` and a reader that returns
// Err for every read, Pass 1 must:
// - mark every sector NonTrimmed (so Pass 2 can retry them)
// - return cleanly (no panic, no hang)
// - bytes_good = 0
// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile
// accounting; see disc/mapfile.rs::stats)
// - bytes_unreadable = 0 (only Pass 2 marks Unreadable)
// - complete = false (work remains for Pass 2)
// - halted = false (no user stop)
// - ISO file is `total_bytes` size on disk (sparse zeros)
/// Reader that returns Err for every read. Optionally signals a halt
/// flag on the first read so tests can exercise the halt-during-skip-forward
/// path deterministically (no wallclock dependency).
struct FailingSectorReader {
capacity: u32,
/// If set, signals halt on the first `read_sectors` call. Cleared after
/// the first signal so subsequent reads are plain Err.
halt_on_first_read: Option<Arc<AtomicBool>>,
}
impl FailingSectorReader {
fn new(capacity: u32) -> Self {
Self {
capacity,
halt_on_first_read: None,
}
}
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
Self {
capacity,
halt_on_first_read: Some(halt),
}
}
}
impl SectorSource for FailingSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
if let Some(h) = self.halt_on_first_read.take() {
h.store(true, Ordering::Relaxed);
}
// Model what a real damaged-disc read returns: CHECK CONDITION +
// MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR,
// ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must
// engage on this — `Error::DiscRead` is libfreemkv's own
// post-classification signal, not what a real reader emits.
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: 0x05,
}),
})
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
#[test]
fn test_disc_copy_completes_full_disc_with_failing_reader() {
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
// skip_on_error, Pass 1 must mark every sector NonTrimmed and return
// cleanly — no bail, no hang.
let capacity_sectors: u32 = 1024;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = FailingSectorReader::new(capacity_sectors);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let t0 = Instant::now();
let result = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
let elapsed = t0.elapsed();
// Cleanup
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Hard bound — Pass 1 must NOT infinite-loop on a fully-failing
// reader. The threshold accommodates the 2026-05-10 wedge-
// avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed
// batch). With batch=32 and 1024 sectors that's up to ~5 batch
// failures + a few damage-jump pauses before fast-trigger jumps
// us past end-of-disc — well-bounded total, ~20-30 s typical.
// The point of this test is "finishes cleanly, not infinitely",
// not "completes in milliseconds."
assert!(
elapsed < Duration::from_secs(60),
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)"
);
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
// read outcomes.
assert_eq!(
result.bytes_total, total_bytes,
"bytes_total must match disc capacity"
);
assert_eq!(
result.bytes_good, 0,
"no reads succeeded, bytes_good must be 0"
);
assert_eq!(
result.bytes_unreadable, 0,
"Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does"
);
assert_eq!(
result.bytes_pending, total_bytes,
"every sector must be NonTrimmed → counted as pending. \
Got bytes_pending={} of total {}",
result.bytes_pending, total_bytes
);
assert!(
!result.complete,
"complete=false because NonTrimmed regions remain (work for Pass 2)"
);
assert!(!result.halted, "no halt was set; halted must be false");
// ISO file should be the full disc size on disk (sparse zeros where
// reads failed).
// Note: tempfile was dropped above; the file may or may not still exist
// depending on cleanup ordering. We only assert what we can observe in
// the CopyResult.
}
// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─
//
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
// Even when every read is failing (skip-forward path), a halt must be
// honored within a small bounded time.
//
// Deterministic fixture: the reader signals halt on its FIRST read. The
// inner copy loop's halt check fires on the next iteration, breaking out
// of 'outer. This avoids any wallclock race on fast CI runners (where a
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
#[test]
fn test_disc_copy_halts_promptly_on_failing_reader() {
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
let halt = Arc::new(AtomicBool::new(false));
let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let opts = CopyOptions {
decrypt: false,
multipass: true,
halt: Some(halt),
..Default::default()
};
let t0 = Instant::now();
let result = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok on halt");
let elapsed = t0.elapsed();
// Cleanup
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
assert!(
elapsed < Duration::from_secs(2),
"halt must return within 2 s; took {elapsed:?}"
);
assert!(result.halted, "result.halted must be true");
assert!(
!result.complete,
"halted run cannot be complete (bytes_pending > 0 expected)"
);
assert!(
result.bytes_pending > 0,
"halt fired before sweep completed; bytes_pending must be > 0"
);
}
// ── 8. Hysteresis recovers data the drive can read individually ──────────
//
// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked
// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every
// multi-sector read fails produces all NonTrimmed output with zero bytes_good.
struct BlockSizeFailingReader {
capacity: u32,
}
impl SectorSource for BlockSizeFailingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
if count == 1 {
for chunk in buf.chunks_mut(SECTOR_SIZE) {
chunk.fill((lba & 0xff) as u8);
}
Ok(buf.len())
} else {
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,
}),
})
}
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
#[test]
fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = BlockSizeFailingReader {
capacity: capacity_sectors,
};
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let result = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Pass 1's job is "fast and accurate, get the most data in the
// shortest time." It no longer bisects on marginal media — that's
// Pass N's purpose-built role. So a BlockSizeFailingReader that
// fails on multi-sector reads and succeeds on single-sector
// results in: every batch fails → SkipBlock → whole 32-sector
// ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and
// recovers via single-sector reads with proper recovery semantics.
//
// Pass 1 alone:
assert_eq!(
result.bytes_good, 0,
"Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit"
);
assert_eq!(
result.bytes_pending, total_bytes,
"every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N"
);
assert!(
!result.complete,
"complete=false because NonTrimmed regions remain (Pass N's work)"
);
}
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
//
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
// pass completes. This test was rewritten from its pre-design-call shape (which
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
// get more shots at them.
#[test]
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
let capacity_sectors: u32 = 128;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = FailingSectorReader::new(capacity_sectors);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok");
assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors");
assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable");
assert_eq!(
pass1.bytes_pending, total_bytes,
"pass1: all sectors NonTrimmed"
);
let last_unreadable = Arc::new(AtomicU64::new(0));
let last_pending = Arc::new(AtomicU64::new(0));
let last_good = Arc::new(AtomicU64::new(0));
let last_dur = Arc::new(AtomicU64::new(0));
struct SnapshotReporter {
unreadable: Arc<AtomicU64>,
pending: Arc<AtomicU64>,
good: Arc<AtomicU64>,
dur: Arc<AtomicU64>,
}
impl libfreemkv::progress::Progress for SnapshotReporter {
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
self.unreadable
.store(p.bytes_unreadable_total, Ordering::Relaxed);
self.pending.store(p.bytes_pending_total, Ordering::Relaxed);
self.good.store(p.bytes_good_total, Ordering::Relaxed);
if let Some(d) = p.disc_duration_secs {
self.dur.store((d * 1000.0) as u64, Ordering::Relaxed);
}
true
}
}
let reporter = SnapshotReporter {
unreadable: last_unreadable.clone(),
pending: last_pending.clone(),
good: last_good.clone(),
dur: last_dur.clone(),
};
let pass2_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: Some(&reporter),
..Default::default()
};
let pass2 = disc
.copy(&mut reader, &iso_path, &pass2_opts)
.expect("pass2 ok");
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
assert_eq!(
pass2.bytes_good, 0,
"pass2: still no good sectors (reader always fails)"
);
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
// pass can retry. End-of-recovery promotion is an orchestrator
// concern (autorip), not the patch loop's.
assert_eq!(
pass2.bytes_unreadable, 0,
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
);
// bytes_pending stays at total_bytes because everything still
// failed and nothing got recovered or promoted out of pending.
assert_eq!(
pass2.bytes_pending, total_bytes,
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
);
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
let observed_pending = last_pending.load(Ordering::Relaxed);
assert_eq!(
observed_unreadable, 0,
"progress should report zero confirmed-unreadable mid-pass under the new design"
);
assert!(
observed_pending > 0,
"progress should report pending bytes as the reader keeps failing"
);
// Video damage time: unreadable / total * duration
// With no titles on synthetic disc, disc_duration_secs = None
assert_eq!(
last_dur.load(Ordering::Relaxed),
0,
"synthetic disc has no titles, duration should be None/0"
);
}
// ── 10. Damage time calculation (unit test) ────────────────────────────────
//
// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration
// This mirrors the CLI's print_disc_progress logic.
#[test]
fn test_damage_time_calculation() {
// 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable
let disc_bytes: u64 = 78_800_000_000;
let duration_secs: f64 = 9900.0;
let cases: Vec<(u64, &str)> = vec![
(74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible
(10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s
(100 * 1024 * 1024, "~13s"), // 100 MB → ~13s
(1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s
];
for (bad_bytes, label) in cases {
let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs;
match label {
"~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"),
"~1.3s" => assert!(
(damage_secs - 1.3).abs() < 0.2,
"{label}: {damage_secs:.2}s"
),
"~13s" => assert!(
(damage_secs - 13.0).abs() < 1.0,
"{label}: {damage_secs:.1}s"
),
"~134s" => assert!(
(damage_secs - 134.0).abs() < 2.0,
"{label}: {damage_secs:.0}s"
),
_ => {}
}
}
// 0.25s threshold: how many bad bytes = 0.25s of damage?
let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64;
assert!(
threshold_bytes > 0,
"0.25s damage threshold should be > 0 bytes"
);
// At 9900s / 78.8 GB ≈ 0.25s = ~2 MB
let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0);
assert!(
(expected_mb - 2.0).abs() < 0.5,
"0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)"
);
}
-491
View File
@@ -1,491 +0,0 @@
//! 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()),
key_fetch: None,
};
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]
);
}
File diff suppressed because it is too large Load Diff