From 2013ef8c442d468965473e455cf07708aa03ca46 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:37:59 -0700 Subject: [PATCH] =?UTF-8?q?disc/patch:=20fast=5Fcapture=20mode=20=E2=80=94?= =?UTF-8?q?=20breadth-first=20recovery=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PatchOptions.fast_capture pass reads each bad range ONCE at the full batch and leaves every FAILED block NonTrimmed for a later pass — no bisect, no per-sector grind, no retry. This lets a first retry pass grab the readable blocks (the sweep's good skip-ahead overshoot) of EVERY section quickly, before any single section's slow per-sector recovery — instead of grinding section 1 to exhaustion before even touching section 2. A later pass (fast_capture = false) does the granular bisect/retry on what's left. Load-bearing invariant (fixture test): NO data is dropped. A failed block becomes NonTrimmed (pending, retried by a granular pass), NEVER Unreadable. The test pins that the readable half of a range recovers, the bad half stays NonTrimmed (not Unreadable), and the bad block is marked in ONE batch read with zero bisection. Disc::copy's internal patch keeps fast_capture=false (single-call full recovery). --- src/disc/mod.rs | 16 +++++ src/disc/patch.rs | 25 ++++++++ tests/pass_n_size_aware_skip.rs | 1 + tests/passn_handler_ab.rs | 102 ++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+) diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 9809ee5..76cf722 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2884,6 +2884,10 @@ impl Disc { progress: opts.progress, halt: opts.halt.clone(), key_fetch: opts.key_fetch.clone(), + // Disc::copy's internal patch grinds each range fully (it's a + // single-call recovery); the breadth-first fast-capture ordering is + // an autorip multi-pass concern. + fast_capture: false, }; let pr = self.patch(reader, path, &patch_opts)?; tracing::info!( @@ -3799,6 +3803,18 @@ pub struct PatchOptions<'a> { /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N /// recover an orphan CPS unit's key when re-reading its bad range. pub key_fetch: Option, + /// Fast-capture pass: read each bad range ONCE at the full batch and leave + /// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting, + /// re-reading, or grinding it here. This lets a first retry pass grab the + /// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range + /// quickly, before any single range's slow per-sector recovery — so + /// recovered data surfaces across the whole disc first instead of grinding + /// section 1 to exhaustion before even touching section 2. A later pass + /// (`fast_capture = false`) does the granular bisect/retry on what's left. + /// No data is dropped: a failed block stays `NonTrimmed` until a granular + /// pass recovers it or finally gives up. A transport fault (bridge crash) + /// still aborts — it isn't a recoverable bad sector. + pub fast_capture: bool, } /// Result returned by [`Disc::patch`]. diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 7146ed2..689a12d 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -1972,6 +1972,30 @@ impl PatchCtx<'_, '_, R> { } } Err(err) => { + // Fast-capture pass: don't grind this block. Mark it + // NonTrimmed for a later granular pass and move on, so the + // readable blocks of EVERY range are captured before any + // single range's slow per-sector recovery. A transport fault + // (bridge crash) still falls through below — it isn't a + // recoverable bad sector. No data is dropped: the block stays + // NonTrimmed until a granular pass recovers or gives up on it. + if self.opts.fast_capture && !err.is_scsi_transport_failure() { + send_or_abort( + self.pipe, + PatchItem::NonTrimmed { + pos, + len: block_bytes, + }, + )?; + self.state.blocks_read_failed += 1; + if self.opts.reverse { + frame.block_end = frame.block_end.saturating_sub(block_bytes); + } else { + frame.block_end += block_bytes; + } + continue; + } + // First failure in this range: the fast-batched pass over // the clean overshoot is done. A genuine transport fault // (bridge crash) is NOT a recoverable bad sector — let @@ -2481,6 +2505,7 @@ mod tests { halt: None, key_fetch: None, + fast_capture: false, } } diff --git a/tests/pass_n_size_aware_skip.rs b/tests/pass_n_size_aware_skip.rs index 549dd37..fe4bec6 100644 --- a/tests/pass_n_size_aware_skip.rs +++ b/tests/pass_n_size_aware_skip.rs @@ -275,6 +275,7 @@ fn patch_block_sectors_zero_does_not_busy_spin() { progress: None, halt: Some(halt.clone()), key_fetch: None, + fast_capture: false, }; let outcome = disc.patch(&mut reader, &iso_path, &opts); diff --git a/tests/passn_handler_ab.rs b/tests/passn_handler_ab.rs index 5381c6a..1758ca1 100644 --- a/tests/passn_handler_ab.rs +++ b/tests/passn_handler_ab.rs @@ -819,3 +819,105 @@ fn profile_08_batch_fail_singles_ok() { // If the unification ever proceeds, the next step is to add a clock // injection point in `handle_read_failure` and extend this fixture // with the wedge/NOT_READY profiles too. + +// ─────────────── Fast-capture (breadth-first) recovery — #50 ─────────────── +// +// `fast_capture = true` reads each bad range ONCE at the batch size and leaves +// every FAILED block NonTrimmed for a later pass — no bisection, no per-sector +// grind. This is the breadth-first "fast-capture every section first, then +// escalate" ordering: a first retry pass grabs every range's readable blocks +// quickly instead of grinding section 1 to exhaustion before touching section 2. +// +// The load-bearing invariant: NO data is dropped. A failed block becomes +// NonTrimmed (pending, retried by a later granular pass), NEVER Unreadable. + +#[test] +fn fast_capture_keeps_readable_blocks_and_leaves_bad_nontrimmed_unbisected() { + let capacity_sectors: u32 = 256; + let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); + // One bad sector at LBA 130 — inside the LOW 32-sector block of the range. + reader.always( + 130, + ScriptStep::Err { + sense_key: 3, + asc: 0x11, + ascq: 0x05, + }, + ); + + let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; + let disc = synthetic_disc(capacity_sectors); + let tmp = tempfile::NamedTempFile::new().unwrap(); + let iso_path = tmp.path().to_path_buf(); + drop(tmp); + // 64-sector NonTrimmed range [128,192); everything else already Finished. + let nontrimmed = [(128 * 2048, 64 * 2048)]; + let finished = [ + (0, 128 * 2048), + (192 * 2048, (capacity_sectors as u64 - 192) * 2048), + ]; + prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); + + let opts = libfreemkv::disc::PatchOptions { + decrypt: false, + block_sectors: Some(32), + full_recovery: true, + reverse: true, + wedged_threshold: 50, + progress: None, + halt: None, + key_fetch: None, + fast_capture: true, + }; + disc.patch(&mut reader, &iso_path, &opts) + .expect("fast-capture patch must not error"); + + let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); + let stats = Mapfile::load(&map_path).unwrap().stats(); + + // The clean 32-block [160,192) recovered (+32 sectors over the 192 already + // Finished); the bad 32-block [128,160) is left NonTrimmed — NOT Unreadable. + // fast_capture never gives up; the next (granular) pass retries it. + // Conservation: the 64-sector range split into 32 good + 32 still-pending, + // nothing lost. + assert_eq!( + stats.bytes_unreadable, 0, + "fast capture must never mark Unreadable" + ); + assert_eq!( + stats.bytes_pending, + 32 * 2048, + "the bad block stays NonTrimmed for the next pass" + ); + assert_eq!( + stats.bytes_good, + 224 * 2048, + "192 pre-Finished + 32 newly recovered" + ); + + // No bisection: the range [128,192) is read in exactly TWO 32-sector batch + // reads (clean half + bad half). Full mode would halve [128,160) into + // count=16,8,…,1 reads to isolate sector 130; fast capture marks the whole + // 32-block NonTrimmed in one read. (Reads outside the range — e.g. a lone + // count=1 probe at the capacity edge — are unrelated and ignored.) + let t = trace.lock().unwrap(); + let range_reads: Vec<_> = t + .iter() + .filter(|&&(lba, _, _)| (128..192).contains(&lba)) + .collect(); + assert_eq!( + range_reads.len(), + 2, + "range read in 2 batches (clean + bad), no bisection; trace={:?}", + *t + ); + assert!( + range_reads.iter().all(|&&(_, count, _)| count == 32), + "fast capture must not bisect — both range reads are the full batch; trace={:?}", + *t + ); + drop(t); + + let _ = std::fs::remove_file(&iso_path); + let _ = std::fs::remove_file(&map_path); +}