v0.13.15 — pos in on_progress, PatchOptions::reverse, wedged_threshold

Breaking: CopyOptions::on_progress + PatchOptions::on_progress now take
Fn(bytes_good, pos, total). Consumers display `pos` for "% swept" — the
true Pass 1 progress that advances through skip-forward bad zones, where
bytes_good (Finished sectors only) freezes. v0.13.14 live trace proved
the existing UI was lying for ~14 minutes about Dune 2 being "stuck at
30%" while Pass 1 was actually 83% through the disc via skip-forward.

PatchOptions::reverse: walk bad ranges from highest LBA to lowest. For
drives that wedge after a forward read of a bad sector, approaching the
post-bad-zone NonTrimmed range from end-of-disc reads good sectors before
the drive sees a bad one. Hypothesis informed by the BU40N + Initio
bridge live data — Pass 2 forward saw zero successful reads in 7 min
while Pass 1's pos walked all the way to end-of-disc.

PatchOptions::wedged_threshold: > 0 → exit early after that many
consecutive failures with zero successes in the same pass. Saves the
wallclock budget for productive grinding when the drive has wedged on
the bad zone for THIS pass; a different direction or block size in the
next pass may still recover. New PatchResult::wedged_exit reports it.

Trace: patch_start (block_sectors, recovery, reverse, wedged_threshold,
num_ranges) and patch_done (blocks_attempted, blocks_read_ok,
blocks_read_failed, wedged_exit, halted, bytes_recovered) at the
freemkv::disc target.
This commit is contained in:
MattJackson
2026-04-25 20:06:03 -07:00
parent 7bc54be8d3
commit e85e20f436
4 changed files with 151 additions and 11 deletions
+46
View File
@@ -1,5 +1,51 @@
# Changelog # Changelog
## 0.13.15 (2026-04-26)
### Breaking: `on_progress` callback gains `pos` parameter
Both `CopyOptions::on_progress` and `PatchOptions::on_progress` now take
`Fn(bytes_good: u64, pos: u64, total_bytes: u64)`. The new `pos` parameter
is the current sweep / retry position. Pass 1 callers should display
`pos / total_bytes` for the "% swept" UI bar — `bytes_good` only counts
clean reads (Finished sectors) and freezes during skip-forward bad zones,
which made every previous version's UI look hung at the bad-zone boundary.
This was the v0.13.9 stall-guard origin bug.
Live trace from v0.13.14: Pass 1 hit a Dune 2 bad zone at 24 GB and
appeared "stuck" for 14 minutes per autorip's UI (`bytes_good = 23.97 GB`
unchanged). Disc trace events showed `pos` actually advanced from 25.8 GB
to 70 GB during that window — Pass 1 was 83 % through the disc, marking
the post-bad-zone NonTrimmed via skip-forward exactly as designed. The
display lied. Now consumers can show the truth.
### Feature: `PatchOptions::reverse` for reverse-direction retry passes
When set, `Disc::patch` walks bad ranges from highest LBA to lowest, and
within each range reads sectors back-to-front. Hypothesis (per the live
v0.13.14 test): drives that wedge after a forward read of a bad sector
read fine when approached from end-of-disc backward — most of the
post-bad-zone NonTrimmed range is actually clean data the drive could
have read on Pass 1 had it not been wedged. autorip alternates F/R
across retry passes (Pass 2 = reverse half-batch, Pass 3 = forward
quarter-batch, ...).
### Feature: `PatchOptions::wedged_threshold` early-exit
When > 0, `Disc::patch` exits early if it sees this many consecutive
read failures with zero successful reads in the same pass. Saves the
wallclock budget for productive grinding when the drive has clearly
wedged on the bad zone for this pass — a future pass with a different
direction or block size may still recover. Reported via new
`PatchResult::wedged_exit: bool`.
### Trace: `patch_start` and `patch_done` events
`freemkv::disc` target now emits `patch_start` (block_sectors, recovery,
reverse, wedged_threshold, num_ranges) and `patch_done`
(blocks_attempted, blocks_read_ok, blocks_read_failed, wedged_exit,
halted, bytes_recovered) at Disc::patch boundaries.
## 0.13.14 (2026-04-25) ## 0.13.14 (2026-04-25)
### Sync release — no functional changes in libfreemkv ### Sync release — no functional changes in libfreemkv
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.13.14" version = "0.13.15"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+103 -9
View File
@@ -1420,7 +1420,7 @@ impl Disc {
if let Some(cb) = opts.on_progress { if let Some(cb) = opts.on_progress {
let stats = map.stats(); let stats = map.stats();
cb(stats.bytes_good, total_bytes); cb(stats.bytes_good, pos, total_bytes);
} }
} }
} }
@@ -1470,7 +1470,13 @@ pub struct CopyOptions<'a> {
/// `skip_on_error`. The skipped region is marked `non-trimmed` for later /// `skip_on_error`. The skipped region is marked `non-trimmed` for later
/// trimming/scraping by `Disc::patch`. /// trimming/scraping by `Disc::patch`.
pub skip_forward: bool, pub skip_forward: bool,
pub on_progress: Option<&'a dyn Fn(u64, u64)>, /// Callback fired per inner-loop iteration with
/// `(bytes_good, pos, total_bytes)`. `pos` is the current sweep
/// position — true Pass 1 progress, including skipped-forward NonTrimmed
/// ranges. `bytes_good` is the count of `Finished` (clean) sectors,
/// which doesn't advance through bad zones. UI should display `pos`
/// for "swept" progress and `bytes_good` for "real data recovered".
pub on_progress: Option<&'a dyn Fn(u64, u64, u64)>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
} }
@@ -1504,7 +1510,20 @@ pub struct PatchOptions<'a> {
/// Use full drive-level recovery on each read (slow but thorough). Defaults /// Use full drive-level recovery on each read (slow but thorough). Defaults
/// to true — patch is the pass where we *want* the drive to try hard. /// to true — patch is the pass where we *want* the drive to try hard.
pub full_recovery: bool, pub full_recovery: bool,
pub on_progress: Option<&'a dyn Fn(u64, u64)>, /// Walk bad ranges in reverse order, and within each range walk sectors
/// from high to low LBA. Useful for drives that wedge after a forward
/// read of a bad sector — approaching the post-bad-zone from end-of-disc
/// reads good sectors before the drive sees a bad one.
pub reverse: bool,
/// Bail out early if this many consecutive read failures occur with zero
/// successful reads in the same pass — i.e. the drive is wedged on the
/// bad zone and won't recover during this attempt. `0` disables the
/// guard (run to completion or halt).
pub wedged_threshold: u64,
/// Callback fired per inner-loop iteration with
/// `(bytes_good, pos, total_bytes)`. `pos` is the current LBA-byte
/// position within the patch walk; for reverse passes it counts down.
pub on_progress: Option<&'a dyn Fn(u64, u64, u64)>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
} }
@@ -1524,6 +1543,10 @@ pub struct PatchResult {
pub blocks_read_ok: u64, pub blocks_read_ok: u64,
/// Reads that returned Err and were marked `Unreadable`. /// Reads that returned Err and were marked `Unreadable`.
pub blocks_read_failed: u64, pub blocks_read_failed: u64,
/// Pass exited early because `wedged_threshold` consecutive failures
/// occurred with zero successful reads — drive appears wedged on the
/// bad zone for this pass.
pub wedged_exit: bool,
} }
impl Disc { impl Disc {
@@ -1561,32 +1584,64 @@ impl Disc {
let bytes_good_before = map.stats().bytes_good; let bytes_good_before = map.stats().bytes_good;
let mut halted = false; let mut halted = false;
let mut wedged_exit = false;
let mut blocks_attempted: u64 = 0; let mut blocks_attempted: u64 = 0;
let mut blocks_read_ok: u64 = 0; let mut blocks_read_ok: u64 = 0;
let mut blocks_read_failed: u64 = 0; let mut blocks_read_failed: u64 = 0;
let mut consecutive_failures: u64 = 0;
let mut buf = vec![0u8; block_sectors as usize * 2048]; let mut buf = vec![0u8; block_sectors as usize * 2048];
// Collect bad ranges up front. Iterating while mutating is fragile; // Collect bad ranges up front. Iterating while mutating is fragile;
// each recorded change is persisted, so resume works even if we crash // each recorded change is persisted, so resume works even if we crash
// mid-loop. // mid-loop.
let bad_ranges = map.ranges_with(&[ let mut bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTried, mapfile::SectorStatus::NonTried,
mapfile::SectorStatus::NonTrimmed, mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped, mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable, mapfile::SectorStatus::Unreadable,
]); ]);
// Reverse mode: walk ranges from highest LBA to lowest.
if opts.reverse {
bad_ranges.reverse();
}
tracing::trace!(
target: "freemkv::disc",
phase = "patch_start",
block_sectors,
recovery,
reverse = opts.reverse,
wedged_threshold = opts.wedged_threshold,
num_ranges = bad_ranges.len(),
"Disc::patch entered"
);
'outer: for (range_pos, range_size) in bad_ranges { 'outer: for (range_pos, range_size) in bad_ranges {
let mut pos = range_pos;
let end = range_pos + range_size; let end = range_pos + range_size;
while pos < end { // In reverse mode, walk this range from end - block_bytes back to range_pos.
// Each iteration emits the block ending at `block_end` (so reads land on
// increasing LBAs internally; we just choose blocks back-to-front).
let mut block_end = if opts.reverse { end } else { range_pos };
loop {
if let Some(ref h) = opts.halt { if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) { if h.load(std::sync::atomic::Ordering::Relaxed) {
halted = true; halted = true;
break 'outer; break 'outer;
} }
} }
let block_bytes = (end - pos).min(block_sectors as u64 * 2048); // Compute block boundaries based on direction.
let (pos, block_bytes) = if opts.reverse {
if block_end <= range_pos {
break;
}
let span = (block_end - range_pos).min(block_sectors as u64 * 2048);
(block_end - span, span)
} else {
if block_end >= end {
break;
}
let span = (end - block_end).min(block_sectors as u64 * 2048);
(block_end, span)
};
let lba = (pos / 2048) as u32; let lba = (pos / 2048) as u32;
let count = (block_bytes / 2048) as u16; let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048; let bytes = count as usize * 2048;
@@ -1596,6 +1651,7 @@ impl Disc {
.is_ok(); .is_ok();
if read_ok { if read_ok {
blocks_read_ok += 1; blocks_read_ok += 1;
consecutive_failures = 0;
if opts.decrypt { if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
} }
@@ -1607,20 +1663,57 @@ impl Disc {
.map_err(|e| Error::IoError { source: e })?; .map_err(|e| Error::IoError { source: e })?;
} else { } else {
blocks_read_failed += 1; blocks_read_failed += 1;
consecutive_failures += 1;
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable) map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?; .map_err(|e| Error::IoError { source: e })?;
} }
pos += block_bytes; // Advance block_end in chosen direction.
if opts.reverse {
block_end = block_end.saturating_sub(block_bytes);
} else {
block_end += block_bytes;
}
// Wedged-drive early-exit: many consecutive failures with zero
// recovered bytes this pass means the drive is stuck and won't
// produce data this pass. Save the wallclock budget for productive
// grinding; future passes (with smaller block size, reverse, or
// after settle) may still recover.
if opts.wedged_threshold > 0
&& consecutive_failures >= opts.wedged_threshold
&& blocks_read_ok == 0
{
tracing::trace!(
target: "freemkv::disc",
phase = "patch_wedged_exit",
consecutive_failures,
blocks_read_failed,
"Disc::patch giving up — drive appears wedged"
);
wedged_exit = true;
break 'outer;
}
if let Some(cb) = opts.on_progress { if let Some(cb) = opts.on_progress {
let s = map.stats(); let s = map.stats();
cb(s.bytes_good, total_bytes); cb(s.bytes_good, pos, total_bytes);
} }
} }
} }
file.sync_all().map_err(|e| Error::IoError { source: e })?; file.sync_all().map_err(|e| Error::IoError { source: e })?;
let stats = map.stats(); let stats = map.stats();
tracing::trace!(
target: "freemkv::disc",
phase = "patch_done",
blocks_attempted,
blocks_read_ok,
blocks_read_failed,
wedged_exit,
halted,
bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before),
"Disc::patch returning"
);
Ok(PatchResult { Ok(PatchResult {
bytes_total: total_bytes, bytes_total: total_bytes,
bytes_good: stats.bytes_good, bytes_good: stats.bytes_good,
@@ -1631,6 +1724,7 @@ impl Disc {
blocks_attempted, blocks_attempted,
blocks_read_ok, blocks_read_ok,
blocks_read_failed, blocks_read_failed,
wedged_exit,
}) })
} }
} }
+1 -1
View File
@@ -178,7 +178,7 @@ fn test_disc_copy_progress_callback_fires() {
let calls_cb = calls.clone(); let calls_cb = calls.clone();
let last_bytes_cb = last_bytes.clone(); let last_bytes_cb = last_bytes.clone();
let progress = move |bytes: u64, _total: u64| { let progress = move |bytes: u64, _pos: u64, _total: u64| {
calls_cb.fetch_add(1, Ordering::Relaxed); calls_cb.fetch_add(1, Ordering::Relaxed);
last_bytes_cb.store(bytes, Ordering::Relaxed); last_bytes_cb.store(bytes, Ordering::Relaxed);
}; };