Compare commits

...
8 Commits
Author SHA1 Message Date
MattJackson 6a3d19a453 v0.17.7: sync release — no functional changes
Version bump to keep the four freemkv crates at unified versioning
after autorip's v0.17.6 + v0.17.7 work today. No libfreemkv code
changes; republished to crates.io so downstream consumers stay
aligned on the latest patch version.
2026-05-08 16:20:28 -07:00
MattJackson a35596d2d1 v0.17.5: Pass N kernel block-device fallback + per-range fixes
Direct-SATA BU40N + Dune Part Two UHD live testing exposed that the
v0.17.3 single-shot SCSI READ path matched 0/22 of the small bad-
sector LBAs that dd if=/dev/sr0 recovers on the same drive. This
release closes that gap and fixes adjacent bugs silently capping
recovery.

- /dev/sr0 pread fallback in Drive::read (Linux only): on SCSI READ
  Err, fall back to posix_fadvise(DONTNEED) + pread() against the
  corresponding block device. Kernel sr_mod runs ~5 internal retries
  with no per-attempt mid-layer escalation overhead — the mechanism
  behind dd's recovery advantage. End-to-end byte verification
  confirms the fallback path returns real disc data.

- Disc::patch per-range watchdog fix: MAX_RANGE_SECS was breaking
  'outer (one slow range killed the entire patch). Now skips to the
  next range. Pre-fix patch died after 4 sectors of range 1 of 47.

- Per-sector range budget: range_budget = sectors × 25 s, capped at
  1800 s. Replaces the flat 180 s/range that was unfair to medium
  ranges and pointlessly generous to single-sector ones.

- consecutive_failures resets per range. The wedge-exit detector is
  for stuck-on-one-range, not many-small-ranges-with-one-fail-each.

- Reverted inline 5× retry experiment (was hurting: each retry paid
  kernel SCSI escalation overhead). Restored READ_RECOVERY_TIMEOUT_MS
  to 60 s. The kernel-auto-retry pattern is now provided by sr0
  fallback.

Empirical: pass 1 recovered 94.6 MB / 11 s of main title (33 sr0
saves). Pass 2 added 0.6 MB. Remaining ~233 MB on the test disc
appears physically unrecoverable on this hardware.
2026-05-08 12:58:48 -07:00
MattJackson 318f654fed v0.17.3: fix scsi_recovery.rs Linux compile (missing Duration import)
tests/scsi_recovery.rs:
- Add `use std::time::Duration` inside both `#[cfg(target_os = "linux")]`
  blocks. Locally on macOS the linux blocks are cfg-out so the missing
  import was invisible to precommit on macOS.
- Bug pre-dated this branch but only surfaced when v0.17.2 release CI
  ran the test compile on Linux.

Cargo.toml: 0.17.2 -> 0.17.3.
2026-05-07 19:16:21 -07:00
MattJackson 5e82ef65de v0.17.2: bump version (skip 0.17.1 — stray tag exists)
Cargo.toml: 0.17.1 -> 0.17.2. Functionally identical to the prior
commit; 0.17.1 was never published to crates.io but a tag exists on
the remote pointing at an unrelated commit. Bumping past it.
2026-05-07 19:10:39 -07:00
MattJackson 8534607329 v0.17.1: cache priming, NonTrimmed marking, decrypt regression test
src/disc/mod.rs:
- Cache priming (3-sector lookback) before patch's single-sector reads.
  Drive read-ahead pulls in adjacent pages so the target may already be
  cached when we ask for it. Throwaway reads — failures here don't
  update mapfile state.
- When patch hits skip-limit on a range, leave remaining sectors
  NonTrimmed instead of marking Unreadable. We never tried to read those
  sectors, so don't give them terminal status — drive state evolves
  between passes (cache, mechanical settle), and a later pass may
  succeed.

tests/pass_n_patch_fix.rs:
- New regression test for the decrypt key inversion bug at
  src/disc/mod.rs:1938-1942. Asserts decrypt_sectors is invoked with
  the correct key when opts.decrypt=true.

tests/pass_n_size_aware_skip.rs:
- rustfmt-only changes.

Cargo.toml: 0.17.0 -> 0.17.1.
2026-05-07 19:09:17 -07:00
MattJackson 97e1a4cad3 unified read-error handler + pass N size-aware skip
New disc/read_error.rs as the single entry point all read failures flow
through. Handler classifies the error, updates the in-flight context
(damage window, retry budgets, jump multiplier), and returns a
ReadAction the caller dispatches on. Pass 1 (sweep) refactored to use
it; ~340 lines of nested if/else collapsed into ~120 lines of action
dispatch. Adding a new error class = one match arm. Logging is in one
place. Bisect inner failures don't poison the damage window. Jump
multiplier capped at 64 (max 1 GB jump for batch=32 — observed prior
unbounded behavior produce a single 56 GB jump on a wedged drive).

Pass N (patch) damage_skip is now size-aware: each skip is capped at
range_remaining/4 rather than the absolute MB-scale escalation. The
old logic could leap over a 100-sector bad range that hides a 50-sector
good middle; size-aware convergence finds the good middles instead.

Tests in tests/pass_n_size_aware_skip.rs exercise the size-aware skip
against synthetic patterns (25-bad/50-good/25-bad and three good
middles in a row) and prove ≥98% of good middles are recovered.
Existing test test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed
updated to reflect that MEDIUM_ERROR now triggers single-sector
bisect (which the BlockSizeFailingReader succeeds at).
2026-05-07 09:08:43 -07:00
MattJackson 3f6bf33de5 Format code 2026-05-04 20:06:10 -07:00
MattJackson 359301e8fc Pass 1 transport failure recovery without user intervention 2026-05-04 20:02:01 -07:00
11 changed files with 1357 additions and 238 deletions
+73
View File
@@ -1,5 +1,78 @@
# Changelog
## 0.17.7 (2026-05-08)
### Sync release — no functional libfreemkv changes
Version bump to keep the four freemkv crates at unified versioning.
autorip shipped UI smoothness + audit fixes today (v0.17.6, v0.17.7);
libfreemkv carries no code changes, but is bumped + republished to
crates.io so downstream `libfreemkv = "0.17"` consumers stay aligned
on the latest patch version. See autorip CHANGELOG for the
user-visible work in this release window.
## 0.17.5 (2026-05-08)
### Pass N recovery — kernel block-device fallback + per-range fixes
Live testing on direct-SATA BU40N + AACS-encrypted UHD disc revealed that
the v0.17.3 single-shot SCSI READ path matched 0/22 of the small bad-sector
LBAs that `dd if=/dev/sr0` recovers on the same drive. This release closes
that gap and fixes several adjacent bugs that were silently capping
recovery.
- **`/dev/sr0` pread fallback in `Drive::read` (Linux)**: when a SCSI READ
via `/dev/sg*` returns Err, fall back to `posix_fadvise(POSIX_FADV_DONTNEED)
+ pread()` against the corresponding block device. The kernel `sr_mod`
driver runs ~5 internal retries per command without the per-attempt
error-escalation overhead that userspace SG_IO retries pay, which is the
source of dd's recovery advantage. End-to-end byte-verification confirms
the fallback path returns real disc data (md5-equivalent to fresh dd from
the same drive session). The block fd is opened in `Drive::open` by
resolving `/sys/class/scsi_generic/sgN/device/block`; best-effort, with no
fallback if open fails.
- **`Disc::patch` per-range watchdog fix**: when a range hit `MAX_RANGE_SECS`,
the old code did `wedged_exit = true; break 'outer;` — a single slow range
killed the entire patch. Now `break;` (skip this range, advance the outer
for loop). Pre-fix, patch was dying after 4 sectors of the first slow
range and never reaching the other 46.
- **Per-sector range budget**: replaced flat `MAX_RANGE_SECS = 180` with
`range_budget_secs = (range_sectors × SECONDS_PER_SECTOR).min(RANGE_BUDGET_CAP_SECS)`.
Tiny ranges exit fast (1-sector range = 25 s); medium ranges get
proportional time (51-sector range ≈ 1275 s); large ranges still bounded
by the 1800 s cap so they cannot monopolise pass 1.
- **`consecutive_failures` resets per range**: the wedge-exit detector
(`>= 50 consecutive failures`) is for "stuck on the same range"; pre-fix
the counter persisted across ranges, so 50 small post-bisection ranges
with one failure each falsely tripped the wedge mid-pass. Reset at every
range boundary.
- **Reverted inline 5× retry in patch**: a brief experiment that was
measurably harmful — each "2 s" SCSI timeout paid ~1.5 s kernel SCSI
mid-layer error-escalation overhead, so 5× retry took ~17 s per LBA and
triggered the per-range watchdog in 4 sectors. Restored
`READ_RECOVERY_TIMEOUT_MS = 60_000` (the v0.17.3 baseline; the kernel-
auto-retry pattern is now provided by the `/dev/sr0` fallback above).
### Empirical results (Dune Part Two UHD, BU40N direct SATA)
- Pass 1: **94.6 MB recovered** (28% of formerly-bad data, 33 sr0 fallback
saves), **11 s of main-title content** restored. Patch completed all 47
retryable ranges naturally (was wedging at range 1 of 47 in v0.17.3).
- Pass 2 cumulative: 95.2 MB (+0.6 MB; diminishing returns curve).
- Remaining ~233 MB on the test disc appears physically unrecoverable on
this hardware (kernel auto-retry can't decode it either).
### Behavioural notes
- The `/dev/sr0` fallback is Linux only; macOS and Windows fall back to
the existing single-shot SCSI behaviour. The fallback is gated to
`recovery=true` reads (only fires from the patch path, not the sweep
path) to avoid page-cache pressure during multi-GB sequential ripping.
## 0.17.0 (2026-05-04)
### Code quality: unwrap safety, clippy compliance, test coverage
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.17.0"
version = "0.17.7"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+7
View File
@@ -91,6 +91,13 @@ pub struct MapStats {
/// retry" UI bucket; `bytes_pending` over-counts because it folds
/// in `bytes_nontried`.
pub bytes_retryable: u64,
/// Number of unreadable ranges (for UI display). Computed from
/// `ranges_with(&[Unreadable])`.
pub num_bad_ranges: u32,
/// Largest gap among unreadable ranges in milliseconds. Computed as
/// largest range size / bytes_per_sec * 1000. Set by caller (autorip)
/// since bytes_per_sec is application-specific.
pub main_lost_ms: f64,
}
/// Write-through mapfile. Every `record()` persists to disk immediately
+345 -220
View File
@@ -12,6 +12,7 @@ mod bluray;
mod dvd;
mod encrypt;
pub mod mapfile;
pub mod read_error;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
@@ -1428,19 +1429,15 @@ impl Disc {
let mut read_ok_count: u64 = 0;
let mut read_err_count: u64 = 0;
let mut last_log_iter: u64 = 0;
let mut not_ready_retries: u32 = 0;
const NOT_READY_MAX_RETRIES: u32 = 3;
let mut bridge_degradation_count: u32 = 0;
const BRIDGE_DEGRADATION_MAX: u32 = 5;
const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10;
const PASS1_DAMAGE_WINDOW: usize = 16;
const PASS1_DAMAGE_THRESHOLD_PCT: usize = 12;
const PASS1_JUMP_SECTORS_FACTOR: u64 = 256;
const PASS1_ESCALATION_RESET_GOOD: u64 = PASS1_DAMAGE_WINDOW as u64;
let mut damage_window: Vec<bool> = Vec::with_capacity(PASS1_DAMAGE_WINDOW);
let mut jump_multiplier: u64 = 1;
let mut consecutive_good: u64 = 0;
// ALL read state lives in one place. The single error-handling
// entry point (`read_error::handle_read_error`) owns the
// counters, retry budgets, and damage-window updates.
let mut read_ctx = read_error::ReadCtx::for_sweep(batch);
// Speed control derives from damage-zone state. We track the
// transition locally so we only call set_speed on edges, not
// every iteration.
let mut in_damage_zone = false;
const DAMAGE_ZONE_EXIT_THRESHOLD: u64 = 16;
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
@@ -1451,10 +1448,16 @@ impl Disc {
);
'outer: loop {
// Every pass retries every non-Finished range. Includes
// Unreadable so each pass gets its own shot at sectors prior
// passes gave up on — drive state may have changed (cooled
// down, bridge stabilized, etc.). Mapfile is binary in
// intent: Finished or not-yet-good.
let regions_to_do = map.ranges_with(&[
mapfile::SectorStatus::NonTried,
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable,
]);
tracing::trace!(
target: "freemkv::disc",
@@ -1500,205 +1503,225 @@ impl Disc {
recovery,
);
let mut did_skip_ahead = false;
match read_result {
Ok(_) => {
// === SUCCESS PATH ===
read_ok_count += 1;
read_ctx.on_success();
if read_result.is_ok() {
read_ok_count += 1;
damage_window.push(true);
if damage_window.len() > PASS1_DAMAGE_WINDOW {
damage_window.remove(0);
}
consecutive_good += 1;
if consecutive_good >= PASS1_ESCALATION_RESET_GOOD {
jump_multiplier = 1;
if in_damage_zone {
in_damage_zone = false;
reader.set_speed(0xFFFF);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_exit",
lba = block_lba,
"Exited damage zone; restoring max read speed"
);
}
}
bridge_degradation_count = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(
&mut buf[..block_bytes as usize],
&keys,
0,
)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..block_bytes as usize])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
} else if !opts.skip_on_error {
let (status, sense) = read_result
.as_ref()
.err()
.map(extract_scsi_context)
.unwrap_or((0, None));
return Err(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
} else {
let err = match read_result {
Ok(_) => unreachable!(),
Err(e) => e,
};
read_err_count += 1;
consecutive_good = 0;
if err.is_scsi_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "transport_failure",
lba = block_lba,
error = %err,
"transport failure (bridge crash); aborting copy — caller should USB reset + resume"
);
return Err(err);
}
if err.is_bridge_degradation() {
if bridge_degradation_count < BRIDGE_DEGRADATION_MAX {
bridge_degradation_count += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation",
lba = block_lba,
degradation_count = bridge_degradation_count,
error = %err,
"bridge degradation; cooling down 10s"
);
std::thread::sleep(std::time::Duration::from_secs(
BRIDGE_DEGRADATION_COOLDOWN_SECS,
));
continue;
}
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation_exhausted",
lba = block_lba,
"bridge degradation retries exhausted; treating as bad sector"
);
}
let sense = err.scsi_sense();
let sense_key = sense.map(|s| s.sense_key).unwrap_or(0);
let asc = sense.map(|s| s.asc).unwrap_or(0);
let ascq = sense.map(|s| s.ascq).unwrap_or(0);
if sense_key == crate::scsi::SENSE_KEY_NOT_READY
&& not_ready_retries < NOT_READY_MAX_RETRIES
{
not_ready_retries += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "not_ready_pause",
lba = block_lba,
sense_key,
asc,
ascq,
retry = not_ready_retries,
"NOT READY; pausing 3s then retrying"
);
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
not_ready_retries = 0;
tracing::warn!(
target: "freemkv::disc",
phase = "skip_ecc_block",
lba = block_lba,
sectors = block_count,
sense_key,
asc,
ascq,
error = %err,
"ECC block failed; marking NonTrimmed"
);
let zero = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
damage_window.push(false);
if damage_window.len() > PASS1_DAMAGE_WINDOW {
damage_window.remove(0);
}
let bad_count = damage_window.iter().filter(|&&b| !b).count();
if damage_window.len() >= PASS1_DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= PASS1_DAMAGE_THRESHOLD_PCT
{
let jump_sectors =
PASS1_JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier;
let jump_lba = ((pos / 2048) + jump_sectors) as u32;
let region_end_lba = (region_end / 2048) as u32;
if jump_lba < region_end_lba {
if !in_damage_zone {
in_damage_zone = true;
reader.set_speed(0x0000);
// Damage-zone exit: after enough consecutive good
// reads, restore max speed and reset jump multiplier.
if read_ctx.consecutive_good >= DAMAGE_ZONE_EXIT_THRESHOLD {
read_ctx.jump_multiplier = 1;
if in_damage_zone {
in_damage_zone = false;
reader.set_speed(0xFFFF);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_enter",
phase = "damage_exit",
lba = block_lba,
"Entered damage zone; dropping to minimum read speed"
"Exited damage zone; restoring max read speed"
);
}
let jump_pos = jump_lba as u64 * 2048;
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
let jump_mb = gap_bytes / 1_048_576;
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = jump_lba,
jump_mb,
bad_pct = bad_count * 100 / damage_window.len(),
multiplier = jump_multiplier,
"25%+ failures in last 50 blocks; jumping ahead"
);
if gap_bytes > 0 {
let zero = vec![0u8; 65536];
let mut filled: u64 = 0;
while filled < gap_bytes {
let chunk = (gap_bytes - filled).min(zero.len() as u64);
file.seek(SeekFrom::Start(gap_start + filled))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero[..chunk as usize])
.map_err(|e| Error::IoError { source: e })?;
filled += chunk;
}
read_ctx.bridge_degradation_count = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(
&mut buf[..block_bytes as usize],
&keys,
0,
)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..block_bytes as usize])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
Err(err) if !opts.skip_on_error => {
// Caller asked us not to skip. Surface the error verbatim.
let (status, sense) = extract_scsi_context(&err);
return Err(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
}
Err(err) => {
// === ERROR PATH — single source of truth ===
// ALL errors flow through handle_read_error. New
// error class = one new arm in that function.
// Logging, counter updates, retry budgets all live
// there. The dispatch below is purely the I/O side
// of each action.
read_err_count += 1;
let action = read_error::handle_read_error(&err, &mut read_ctx);
match action {
read_error::ReadAction::Retry { pause_secs } => {
if pause_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
}
map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed)
// Don't advance pos — same LBA next iteration.
}
read_error::ReadAction::Bisect => {
// Re-issue the failed batch as single-sector reads.
// ctx.bisecting=true so the inner failures don't
// recursively request another bisect.
read_ctx.bisecting = true;
let saved_batch = read_ctx.batch;
read_ctx.batch = 1;
for sector_offset in 0..block_count {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
read_ctx.bisecting = false;
read_ctx.batch = saved_batch;
break 'outer;
}
}
let sector_lba = block_lba + (sector_offset as u32);
let mut sector_buf = [0u8; 2048];
let write_pos = pos + (sector_offset as u64 * 2048);
match reader.read_sectors(
sector_lba,
1,
&mut sector_buf[..],
true,
) {
Ok(_) => {
read_ctx.on_success();
file.seek(SeekFrom::Start(write_pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&sector_buf)
.map_err(|e| Error::IoError { source: e })?;
map.record(
write_pos,
2048,
mapfile::SectorStatus::Finished,
)
.map_err(|e| Error::IoError { source: e })?;
}
Err(inner_err) => {
// Inner failure goes through the same handler — it'll
// see bisecting=true and won't recurse. We only honour
// the SkipBlock action here (the bisect by definition
// can't return another Bisect, and JumpAhead inside a
// single-sector retry doesn't make sense).
let _ = read_error::handle_read_error(
&inner_err,
&mut read_ctx,
);
let zero = [0u8; 2048];
file.seek(SeekFrom::Start(write_pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(
write_pos,
2048,
mapfile::SectorStatus::NonTrimmed,
)
.map_err(|e| Error::IoError { source: e })?;
}
}
}
read_ctx.bisecting = false;
read_ctx.batch = saved_batch;
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
read_error::ReadAction::SkipBlock { pause_secs } => {
let zero = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(gap_bytes);
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
if pause_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
}
pos += block_bytes;
}
read_error::ReadAction::JumpAhead {
sectors,
pause_secs,
} => {
// Mark the failed batch + the gap up to jump_pos NonTrimmed.
let zero_batch = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero_batch)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
// Damage-zone enter: drop to minimum read speed.
if !in_damage_zone {
in_damage_zone = true;
reader.set_speed(0x0000);
tracing::debug!(
target: "freemkv::disc",
phase = "damage_enter",
lba = block_lba,
"Entered damage zone; dropping to minimum read speed"
);
}
let jump_pos = (pos + block_bytes + sectors * 2048).min(region_end);
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
if gap_bytes > 0 {
let zero_gap = vec![0u8; 65536];
let mut filled: u64 = 0;
while filled < gap_bytes {
let chunk = (gap_bytes - filled).min(zero_gap.len() as u64);
file.seek(SeekFrom::Start(gap_start + filled))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero_gap[..chunk as usize])
.map_err(|e| Error::IoError { source: e })?;
filled += chunk;
}
map.record(
gap_start,
gap_bytes,
mapfile::SectorStatus::NonTrimmed,
)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(gap_bytes);
}
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = (jump_pos / 2048) as u32,
jump_mb = gap_bytes / 1_048_576,
"damage-jump"
);
pos = jump_pos;
if pause_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
}
}
read_error::ReadAction::AbortPass => {
let (status, sense) = extract_scsi_context(&err);
return Err(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
}
pos = jump_pos;
jump_multiplier *= 2;
did_skip_ahead = true;
}
}
}
if !did_skip_ahead {
pos += block_bytes;
}
iter_count += 1;
if iter_count - last_log_iter >= 100 {
@@ -1972,14 +1995,33 @@ impl Disc {
let mut blocks_attempted: u64 = 0;
let mut blocks_read_ok: u64 = 0;
let mut blocks_read_failed: u64 = 0;
let mut consecutive_failures: u64 = 0;
// Reset to 0 at the start of every range; declared without init
// because the per-range reset (below) always runs before any read.
let mut consecutive_failures: u64;
let mut unreadable_count: u64 = 0;
let mut bytes_good_last = bytes_good_before;
let mut stall_start = std::time::Instant::now();
let mut range_start;
let mut range_bytes_good;
const STALL_SECS: u64 = 3600;
const MAX_RANGE_SECS: u64 = 180;
// Per-range budget = sectors_in_range × SECONDS_PER_SECTOR, capped
// at RANGE_BUDGET_CAP. Replaces the old flat 180 s/range — that
// was unfair to medium ranges (a 51-sector range got the same
// 180 s as a 1-sector range, so multi-sector ranges couldn't
// even attempt every sector inside their budget) and pointlessly
// generous to single-sector ranges (180 s when ~5 s would do).
// The cap keeps catastrophic ranges (10s of MB) bounded so they
// can't consume the entire patch run; multi-pass orchestration
// raises the cap on later passes for the genuinely-stuck ones.
// Empirical per-failed-sector cost on direct-SATA BU40N (2026-05-08):
// ~3 s SCSI READ failure + ~15 s sr0 pread fallback (kernel sr_mod
// does ~5 internal retries) ≈ 18-25 s total. SECONDS_PER_SECTOR=25
// lets a small range fully sample within budget instead of bailing
// after one slow read. Previous value of 5 was too tight: a
// 3-sector range got 15 s budget but the first failed read alone
// took ~20 s, so the watchdog fired before sector 2 could be tried.
const SECONDS_PER_SECTOR: u64 = 25;
const RANGE_BUDGET_CAP_SECS: u64 = 1800;
const MAX_SKIPS_PER_RANGE: u32 = 10;
let mut skip_count: u32;
let mut buf = vec![0u8; block_sectors as usize * 2048];
@@ -2054,9 +2096,15 @@ impl Disc {
}
}
// Every retry pass acts on every non-Finished range. Including
// Unreadable means a sector that failed in pass N gets a fresh
// shot in pass N+1 — drive state evolves, the same read can
// succeed later. Each pass owns its own jumps/skips; if pass 5
// jumps over the same zone as pass 2, fine.
let mut bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable,
]);
if opts.reverse {
bad_ranges.reverse();
@@ -2103,6 +2151,24 @@ impl Disc {
range_start = std::time::Instant::now();
range_bytes_good = bytes_good_before;
skip_count = 0;
// Reset consecutive_failures at each range boundary. The
// wedge-exit detector is for "stuck on the same range" — many
// tiny ranges that each fail their one sampled sector should
// NOT trigger it. Pre-fix: pass 2 hit 134 small post-pass-1
// ranges, each contributing a single failure, and tripped
// wedged_threshold=50 around range 27/134 — a false positive
// that aborted the rest of the pass.
consecutive_failures = 0;
let range_sectors = *range_size / 2048;
let range_budget_secs = (range_sectors * SECONDS_PER_SECTOR).min(RANGE_BUDGET_CAP_SECS);
tracing::debug!(
target: "freemkv::disc",
phase = "patch_range_budget",
range_lba = *range_pos / 2048,
range_sectors,
range_budget_secs,
"Per-range time budget computed"
);
loop {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
@@ -2111,39 +2177,47 @@ impl Disc {
}
}
// Test 1: Range timeout - max 3 minutes per range
if range_start.elapsed().as_secs() > MAX_RANGE_SECS {
// Per-range watchdog: budget = range_sectors × 5 s, capped
// at RANGE_BUDGET_CAP_SECS. Tiny ranges exit fast (1-sector
// range = 5 s budget); medium ranges get proportional time
// (51-sector range = 255 s); huge ranges still bounded by
// the cap so they can't monopolise pass 1.
//
// Both the absolute-elapsed and no-progress checks share
// the same per-range budget. The progress check resets
// range_start on every byte gained, so a steadily-recovering
// range can run as long as it makes progress.
if range_start.elapsed().as_secs() > range_budget_secs {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_range_timeout",
range_lba = range_pos / 2048,
range_sectors,
elapsed_secs = range_start.elapsed().as_secs(),
bytes_recovered = bytes_good_before - range_bytes_good,
"Range timeout - no progress in {}s, aborting",
MAX_RANGE_SECS
budget_secs = range_budget_secs,
bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before),
"Range timeout - moving to next range"
);
wedged_exit = true;
break 'outer;
break;
}
// Test 2: Range progress - must recover bytes in 60 seconds
let bytes_good_now = map.stats().bytes_good;
if bytes_good_now > range_bytes_good {
range_bytes_good = bytes_good_now;
range_start = std::time::Instant::now();
}
if range_start.elapsed().as_secs() > MAX_RANGE_SECS {
if range_start.elapsed().as_secs() > range_budget_secs {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_range_stall",
range_lba = range_pos / 2048,
range_sectors,
elapsed_secs = range_start.elapsed().as_secs(),
bytes_recovered = bytes_good_before - range_bytes_good,
"Range stalled - no recovery in {}s, aborting",
MAX_RANGE_SECS
budget_secs = range_budget_secs,
bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before),
"Range stalled - moving to next range"
);
wedged_exit = true;
break 'outer;
break;
}
// Test 3: Skip count - max 10 skips per range
@@ -2153,15 +2227,21 @@ impl Disc {
phase = "patch_skip_limit",
range_lba = range_pos / 2048,
skip_count,
"Skip limit reached - marking range terminal and continuing to next",
"Skip limit reached - leaving remaining bytes NonTrimmed for next pass",
);
// Mark remaining bytes in this range as Unreadable before moving on
// CRITICAL: don't mark sectors we NEVER ATTEMPTED as
// Unreadable. Only sectors we actually read+failed get
// the terminal `-` status. Sectors we jumped over are
// hopeful — the drive may read them on a later pass
// when state has evolved (cache, mechanical settle).
// 2026-05-07 dd-as-oracle test confirmed ~36% of
// patch-marked Unreadable sectors are actually readable.
let unmarked_bytes = block_end.saturating_sub(*range_pos);
if opts.reverse {
map.record(
*range_pos,
unmarked_bytes,
mapfile::SectorStatus::Unreadable,
mapfile::SectorStatus::NonTrimmed,
)
.map_err(|e| Error::IoError { source: e })?;
} else {
@@ -2170,7 +2250,7 @@ impl Disc {
map.record(
remaining_start,
end - remaining_start,
mapfile::SectorStatus::Unreadable,
mapfile::SectorStatus::NonTrimmed,
)
.map_err(|e| Error::IoError { source: e })?;
}
@@ -2208,6 +2288,37 @@ impl Disc {
"Starting sector read"
);
// Cache priming: before reading the target sector, do
// a few single-sector reads at LBAs immediately preceding
// it. The drive's read-ahead cache prefetches forward on
// sequential reads — so by the time we ask for `lba` it
// may already be cached, even if a cold read fails. Proven
// 2026-05-07 with dd-as-oracle: 8/8 sectors recoverable
// when primed vs 6/8 cold. Throwaway reads — we already
// have those bytes Finished from a prior pass; failures
// here don't update mapfile state.
const CACHE_PRIME_SECTORS: u32 = 3;
if lba >= CACHE_PRIME_SECTORS && count == 1 {
let mut prime_buf = [0u8; 2048];
for i in 0..CACHE_PRIME_SECTORS {
let prime_lba = lba - CACHE_PRIME_SECTORS + i;
// Best-effort; ignore errors. Recovery=false is
// intentional: a fast 1.5s timeout is fine because
// we don't need the data.
let _ = reader.read_sectors(prime_lba, 1, &mut prime_buf[..], false);
}
}
// Single-shot read. Inline retry was tried 2026-05-08 and
// actively hurt: each timeout pays kernel SCSI mid-layer
// error-escalation overhead (~1.5 s per attempt on top of
// the SCSI timeout), so 5× retry made each LBA take ~17 s
// and forced MAX_RANGE_SECS to fire after 4 sectors. The
// win that motivated the experiment (matching dd via
// /dev/sr0) is being pursued instead through a /dev/sr0
// pread-based fallback layer that lets the kernel
// sr_mod driver run its own auto-retries (which don't
// pay per-attempt escalation in the same way).
let read_start = std::time::Instant::now();
let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery);
let read_duration_ms = read_start.elapsed().as_millis();
@@ -2697,9 +2808,23 @@ impl Disc {
if damage_window.len() >= PASSN_DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= PASSN_DAMAGE_THRESHOLD_PCT
{
let skip_sectors = (PASSN_SKIP_SECTORS_BASE
<< consecutive_skips_without_recovery)
// Size-aware cap: never skip more than 1/4 of the
// remaining bad range. A 100-sector bad range is
// really 25-bad + 50-good + 25-bad in disguise; a
// hardcoded MB-scale skip would leap over the
// entire thing and miss the good middle. Capping
// at range_remaining/4 forces convergence on the
// actual bad sub-zones.
let range_remaining_bytes = if opts.reverse {
block_end.saturating_sub(*range_pos)
} else {
end.saturating_sub(block_end)
};
let range_remaining_sectors = range_remaining_bytes / 2048;
let range_quarter = (range_remaining_sectors / 4).max(1);
let escalated = (PASSN_SKIP_SECTORS_BASE << consecutive_skips_without_recovery)
.min(PASSN_SKIP_SECTORS_CAP);
let skip_sectors = escalated.min(range_quarter);
let skip_bytes = skip_sectors * 2048;
let new_block_end = if opts.reverse {
block_end.saturating_sub(skip_bytes).max(*range_pos)
+374
View File
@@ -0,0 +1,374 @@
//! Single source of truth for what to do when a sector read fails.
//!
//! Both Pass 1 (`Disc::sweep`) and Pass 2-N (`Disc::patch`) call into
//! `handle_read_error` after every failed `read_sectors`. The handler
//! classifies the error, updates the in-flight context (counters,
//! damage window, retry budgets), and returns a `ReadAction` the caller
//! dispatches on. Every read goes through the same gate — no path can
//! silently skip pause/skip/jump/abort logic.
//!
//! Adding a new error class = add one arm in `handle_read_error`.
//! Adding new logging on errors = one place.
use crate::error::Error;
use crate::scsi;
/// In-flight bookkeeping a read loop must keep across iterations. The
/// handler reads and mutates this. Caller owns the storage.
pub struct ReadCtx {
/// Number of sectors per read attempt. The handler uses this to
/// decide whether to bisect (only worthwhile when batch > 1).
pub batch: u16,
/// Successful reads since the last failure. Resets to 0 on failure.
/// Used by callers to drive damage-zone exit / speed restoration.
pub consecutive_good: u64,
/// Failed reads since the last success. Resets to 0 on success.
/// Drives long-pause escalation on persistent failure.
pub consecutive_failures: u64,
/// Sliding window of recent read outcomes (true=ok, false=fail).
/// Capped at `damage_window_max`. Drives damage-jump decisions.
pub damage_window: Vec<bool>,
pub damage_window_max: usize,
pub damage_threshold_pct: usize,
/// Multiplier applied to damage-jump distance. Doubles each jump,
/// resets to 1 after `damage_window_max` consecutive good reads.
pub jump_multiplier: u64,
/// NOT_READY retries used so far for the current LBA. Reset to 0
/// on any non-NOT_READY response.
pub not_ready_retries: u32,
/// Bridge-degradation cooldowns used so far.
pub bridge_degradation_count: u32,
/// Whether we're currently inside a damage-jump bisect attempt.
/// Caller sets this true when entering single-sector mode for a
/// failed batch, so the handler doesn't recursively request another
/// bisect on the inner-sector failures.
pub bisecting: bool,
}
impl ReadCtx {
/// Initial context for a Pass 1 sweep with the documented constants.
pub fn for_sweep(batch: u16) -> Self {
Self {
batch,
consecutive_good: 0,
consecutive_failures: 0,
damage_window: Vec::with_capacity(16),
damage_window_max: 16,
damage_threshold_pct: 12,
jump_multiplier: 1,
not_ready_retries: 0,
bridge_degradation_count: 0,
bisecting: false,
}
}
/// Initial context for a Pass 2-N patch.
pub fn for_patch(batch: u16) -> Self {
Self {
batch,
consecutive_good: 0,
consecutive_failures: 0,
damage_window: Vec::with_capacity(16),
damage_window_max: 16,
damage_threshold_pct: 12,
jump_multiplier: 1,
not_ready_retries: 0,
bridge_degradation_count: 0,
bisecting: false,
}
}
/// Caller calls this after every successful read.
pub fn on_success(&mut self) {
self.consecutive_good += 1;
self.consecutive_failures = 0;
self.not_ready_retries = 0;
self.damage_window.push(true);
if self.damage_window.len() > self.damage_window_max {
self.damage_window.remove(0);
}
}
}
/// What the caller should do after a read failure. The caller owns the
/// I/O side-effects (sleep, write zeros, advance pos) — the handler
/// only decides which side-effects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReadAction {
/// Pause `pause_secs` then retry the same LBA / batch. Used for
/// transient conditions (NOT_READY, bridge degradation) that the
/// drive may recover from on its own.
Retry { pause_secs: u64 },
/// Re-issue the failed batch as `batch` single-sector reads. Each
/// inner read is itself dispatched through `handle_read_error` with
/// `bisecting = true` so it cannot recurse.
Bisect,
/// Mark the failed range NonTrimmed (zero-fill, retry in Pass N+),
/// then pause `pause_secs` before resuming the next LBA.
SkipBlock { pause_secs: u64 },
/// Mark the failed range NonTrimmed AND advance position by
/// `sectors` (zero-filling the gap as NonTrimmed). Then pause
/// `pause_secs`. Used when the damage-window threshold is crossed.
JumpAhead { sectors: u64, pause_secs: u64 },
/// Unrecoverable at this layer. Caller propagates `Err` up to the
/// outer pass loop / autorip, which can attempt USB re-enumeration,
/// drop session, etc.
AbortPass,
}
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
// post-failure pauses give the drive — and the bridge — time to settle.
const POST_FAILURE_PAUSE_SECS: u64 = 1;
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
const NOT_READY_PAUSE_SECS: u64 = 3;
const NOT_READY_MAX_RETRIES: u32 = 3;
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
/// THE single error-handling entry point. Updates `ctx`, returns the
/// action the caller must apply.
///
/// New error class = add a new arm here. New logging on errors = add
/// it once at the top. New retry policy = adjust the constants. No
/// other read site needs to change.
pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
ctx.consecutive_failures += 1;
ctx.consecutive_good = 0;
tracing::warn!(
target: "freemkv::disc",
phase = "read_error",
consecutive_failures = ctx.consecutive_failures,
batch = ctx.batch,
bisecting = ctx.bisecting,
sense_key = err.scsi_sense().map(|s| s.sense_key),
asc = err.scsi_sense().map(|s| s.asc),
ascq = err.scsi_sense().map(|s| s.ascq),
error = %err,
"read failed; classifying"
);
// 1. Transport failure: bridge crash / USB disconnect. The outer
// pass loop knows how to handle this (rediscover sg path,
// re-open drive). Inline single-sector retry here was tried in
// pre-v0.17.0 builds and observed to make wedges worse.
if err.is_scsi_transport_failure() {
return ReadAction::AbortPass;
}
// 2. Bridge degradation: NOT_READY with the well-known signature
// (sense_key=2, ASC=0x04, ASCQ=0x3E). Drive's bridge is in a
// semi-stuck state but typically recovers after a long cooldown.
// If we've exhausted our retry budget, fall through to the
// marginal/skip path below.
if err.is_bridge_degradation() && ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES
{
ctx.bridge_degradation_count += 1;
return ReadAction::Retry {
pause_secs: BRIDGE_DEGRADATION_PAUSE_SECS,
};
}
let sense_key = err.scsi_sense().map(|s| s.sense_key).unwrap_or(0);
// 3. Generic NOT_READY (other ASC codes): drive's mechanical
// pickup may be moving. Pause and retry briefly.
if sense_key == scsi::SENSE_KEY_NOT_READY && ctx.not_ready_retries < NOT_READY_MAX_RETRIES {
ctx.not_ready_retries += 1;
return ReadAction::Retry {
pause_secs: NOT_READY_PAUSE_SECS,
};
}
if sense_key != scsi::SENSE_KEY_NOT_READY {
ctx.not_ready_retries = 0;
}
// 4. Hardware / illegal request: the drive said "no, won't do it".
// Retrying won't change the answer. Surface to the outer layer
// so it can eject + prompt user.
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
return ReadAction::AbortPass;
}
// 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
// sector batch: the drive can often read the same sectors
// individually. Bisect into single-sector reads (gentler on the
// bridge too — shorter SCSI transactions). Avoid recursive
// bisect.
let is_marginal = matches!(
sense_key,
scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND
);
if is_marginal && ctx.batch > 1 && !ctx.bisecting {
return ReadAction::Bisect;
}
// 6. Single-sector failure or unbisectable error — record in
// damage window, decide between skip-in-place vs damage-jump.
//
// SKIP damage-window updates while bisecting: the window
// represents per-batch outcomes, not per-sector. Updating it
// inside a bisect inner loop (potentially 32+ sector failures
// per batch) would over-weight the window and cause runaway
// JumpAhead distance via excessive multiplier doublings.
if !ctx.bisecting {
ctx.damage_window.push(false);
if ctx.damage_window.len() > ctx.damage_window_max {
ctx.damage_window.remove(0);
}
}
let bad_count = ctx.damage_window.iter().filter(|&&b| !b).count();
let bad_pct = if ctx.damage_window.is_empty() {
0
} else {
bad_count * 100 / ctx.damage_window.len()
};
let pause_secs = if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
} else {
POST_FAILURE_PAUSE_SECS
};
// 7. Damage-jump: too many failures in window → skip ahead by an
// escalating gap. Multiplier capped so we can't accidentally
// skip the entire rest of the disc (observed 2026-05-07: a
// saturated multiplier produced a 56 GB jump). Saturating
// arithmetic on the sector calc as defence in depth.
const MAX_JUMP_MULTIPLIER: u64 = 64; // 64 × 256 × batch sectors
if ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct {
let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER);
let sectors = 256u64.saturating_mul(ctx.batch as u64).saturating_mul(mult);
ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER);
return ReadAction::JumpAhead {
sectors,
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
};
}
// 8. Default: zero-fill the failed batch as NonTrimmed and pause
// before the next read.
ReadAction::SkipBlock { pause_secs }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::scsi::ScsiSense;
fn medium_err() -> Error {
Error::DiscRead {
sector: 100,
status: Some(2),
sense: Some(ScsiSense {
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
asc: 0x11,
ascq: 0x05,
}),
}
}
fn hardware_err() -> Error {
Error::DiscRead {
sector: 100,
status: Some(2),
sense: Some(ScsiSense {
sense_key: scsi::SENSE_KEY_HARDWARE_ERROR,
asc: 0x44,
ascq: 0x00,
}),
}
}
#[test]
fn medium_error_with_batch_gt_1_bisects() {
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&medium_err(), &mut ctx);
assert_eq!(action, ReadAction::Bisect);
}
#[test]
fn medium_error_with_batch_1_skips() {
let mut ctx = ReadCtx::for_sweep(1);
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { pause_secs } => assert!(pause_secs >= 1),
other => panic!("expected SkipBlock, got {other:?}"),
}
}
#[test]
fn medium_error_while_bisecting_does_not_recurse() {
let mut ctx = ReadCtx::for_sweep(32);
ctx.bisecting = true;
let action = handle_read_error(&medium_err(), &mut ctx);
match action {
ReadAction::SkipBlock { .. } => {}
other => panic!("expected SkipBlock, got {other:?}"),
}
}
#[test]
fn hardware_error_aborts() {
let mut ctx = ReadCtx::for_sweep(32);
let action = handle_read_error(&hardware_err(), &mut ctx);
assert_eq!(action, ReadAction::AbortPass);
}
#[test]
fn long_failure_streak_extends_pause() {
let mut ctx = ReadCtx::for_sweep(1);
for _ in 0..15 {
handle_read_error(&medium_err(), &mut ctx);
}
// After many consecutive failures we should be in the long-pause regime
let final_action = handle_read_error(&medium_err(), &mut ctx);
match final_action {
ReadAction::SkipBlock { pause_secs } => {
assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
}
ReadAction::JumpAhead { pause_secs, .. } => {
assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
}
other => panic!("expected long-pause action, got {other:?}"),
}
}
#[test]
fn damage_window_fills_then_jumps() {
let mut ctx = ReadCtx::for_sweep(1);
ctx.damage_window_max = 4;
ctx.damage_threshold_pct = 50;
let mut saw_jump = false;
for _ in 0..6 {
let a = handle_read_error(&medium_err(), &mut ctx);
if matches!(a, ReadAction::JumpAhead { .. }) {
saw_jump = true;
break;
}
}
assert!(
saw_jump,
"expected at least one JumpAhead in 6 failures with 50% threshold"
);
}
#[test]
fn on_success_resets_failure_counters_and_pushes_window() {
let mut ctx = ReadCtx::for_sweep(32);
for _ in 0..3 {
handle_read_error(&medium_err(), &mut ctx);
}
assert!(ctx.consecutive_failures > 0);
ctx.bisecting = false;
ctx.on_success();
assert_eq!(ctx.consecutive_good, 1);
assert_eq!(ctx.consecutive_failures, 0);
assert_eq!(*ctx.damage_window.last().unwrap(), true);
}
}
+118
View File
@@ -69,6 +69,16 @@ pub struct Drive {
halt: Arc<AtomicBool>,
/// Event handler — fires for read errors and library-level state changes.
event_fn: Option<Box<dyn Fn(Event) + Send>>,
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
/// an error. The kernel `sr_mod` driver auto-retries failed reads
/// (~5× per command) — historically the reason `dd if=/dev/sr0`
/// recovers ~50% of bad sectors that single-shot `SG_IO` READ
/// misses on the same drive. `None` when the block device couldn't
/// be resolved or opened (no fallback in that case; SCSI read
/// errors propagate as before).
#[cfg(target_os = "linux")]
block_dev_fd: Option<std::os::unix::io::RawFd>,
}
impl Drive {
@@ -87,6 +97,9 @@ impl Drive {
None => (None, None, None),
};
#[cfg(target_os = "linux")]
let block_dev_fd = open_block_device_for_sg(device);
Ok(Drive {
scsi: transport,
driver,
@@ -96,6 +109,8 @@ impl Drive {
device_path: device.to_string_lossy().to_string(),
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
#[cfg(target_os = "linux")]
block_dev_fd,
})
}
@@ -483,6 +498,54 @@ impl Drive {
scsi_status = status,
"Drive::read checked_exec failed"
);
// /dev/sr0 pread fallback (Linux only). The kernel
// sr_mod driver auto-retries failed reads (~5× per
// command). Empirically (BU40N + Dune Part 2 UHD,
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
// sectors that a single-shot SG_IO READ misses.
#[cfg(target_os = "linux")]
if recovery {
if let Some(fd) = self.block_dev_fd {
let len = count as usize * 2048;
if buf.len() >= len {
let offset = lba as i64 * 2048;
// Drop kernel cache for this region so we get
// a fresh device read, not stale page-cache
// data from a prior successful neighbour read.
let _ = unsafe {
libc::posix_fadvise(
fd,
offset,
len as i64,
libc::POSIX_FADV_DONTNEED,
)
};
let n = unsafe {
libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset)
};
if n == len as isize {
tracing::info!(
target: "freemkv::drive",
lba,
count,
bytes = len,
"Drive::read recovered via /dev/sr0 pread fallback"
);
return Ok(len);
}
tracing::debug!(
target: "freemkv::drive",
lba,
count,
pread_ret = n as i64,
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
"/dev/sr0 pread fallback also failed"
);
}
}
}
Err(Error::DiscRead {
sector: lba as u64,
status: Some(status),
@@ -586,6 +649,61 @@ impl Drop for Drive {
fn drop(&mut self) {
self.cleanup();
// SgIoTransport::drop() runs next, calling libc::close(fd)
#[cfg(target_os = "linux")]
if let Some(fd) = self.block_dev_fd.take() {
unsafe { libc::close(fd) };
}
}
}
/// Resolve a `/dev/sg*` path to the corresponding `/dev/sr*` block
/// device by walking sysfs, then open it for read (no `O_DIRECT` —
/// `posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each
/// pread, which avoids buffer-alignment requirements while still
/// forcing fresh device reads).
///
/// Returns `None` on any error (sysfs not present, no matching block
/// device, open failed). Callers treat that as "no fallback available"
/// and propagate the original SCSI READ error.
#[cfg(target_os = "linux")]
fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> {
let basename = sg_path.file_name()?.to_str()?;
if !basename.starts_with("sg") {
return None;
}
let sysfs_dir = format!("/sys/class/scsi_generic/{}/device/block", basename);
let entries = std::fs::read_dir(&sysfs_dir).ok()?;
let block_name = entries
.flatten()
.find_map(|e| e.file_name().into_string().ok())?;
let block_path = format!("/dev/{}", block_name);
let mut bytes = block_path.as_bytes().to_vec();
bytes.push(0);
let fd = unsafe {
libc::open(
bytes.as_ptr() as *const libc::c_char,
libc::O_RDONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
tracing::debug!(
target: "freemkv::drive",
sg = basename,
block_path,
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
"Failed to open block device for fallback; sr0 fallback disabled"
);
None
} else {
tracing::info!(
target: "freemkv::drive",
sg = basename,
block_path,
fd,
"Opened /dev/sr* as recovery fallback for failed SCSI reads"
);
Some(fd)
}
}
+19 -4
View File
@@ -68,10 +68,25 @@ pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
pub(crate) const READ_TIMEOUT_MS: u32 = 10_000;
/// Timeout for content READ commands on the recovery path —
/// [`disc::Disc::patch`]'s targeted retries on bad ranges. Doubles
/// the fast-path budget so a sector that fails at 30 s gets one more
/// honest attempt. Matches sg_dd's default per-command timeout
/// (`DEF_TIMEOUT = 60000`).
/// [`disc::Disc::patch`]'s targeted retries on bad ranges. Matches
/// `sg_dd`'s 60 s ceiling: long enough that any sector the drive can
/// recover at all gets the time to do so, short enough that an
/// unresponsive bus is detected before the per-range watchdog fires.
///
/// In practice failed reads return in 1–4 s (the drive itself gives up
/// on uncorrectable ECC before the timeout); the 60 s value is a
/// safety ceiling, not a steady-state cost.
///
/// Historical note (2026-05-08): briefly lowered to 2 s with a 5×
/// inline retry loop in `Disc::patch` to mimic the kernel `sr_mod`
/// driver's auto-retry pattern. The synthetic logic worked but on the
/// live drive each "2 s" read paid ~1.5 s of kernel SCSI mid-layer
/// error escalation on top, so 5× retries took ~17 s per LBA and
/// triggered MAX_RANGE_SECS after 4 sectors — pushing recovery to
/// 0/22 ranges (worse than the 0/22 baseline of v0.17.3 single-shot
/// at 60 s, since that at least visited every range). Reverted; the
/// kernel-auto-retry approach is being pursued via a `/dev/sr0` pread
/// fallback instead.
pub(crate) const READ_RECOVERY_TIMEOUT_MS: u32 = 60_000;
// ── SCSI status bytes (SPC-4 §4.5.5) ────────────────────────────────────────
+10 -12
View File
@@ -594,7 +594,7 @@ impl SectorReader for BlockSizeFailingReader {
#[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 total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = BlockSizeFailingReader {
capacity: capacity_sectors,
@@ -620,20 +620,18 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Pass 1 reads every batch at bpt=32 (no batch reduction, no skip-ahead).
// BlockSizeFailingReader fails on all batch=32 reads, so every sector
// is marked NonTrimmed. bytes_good=0 is correct — Pass 2 recovers them.
// BlockSizeFailingReader fails on multi-sector reads but succeeds on single-sector.
// Bridge degradation handling retries failed batches as individual sectors,
// so all data is recovered as Finished. bytes_good == total_bytes is correct.
assert_eq!(
result.bytes_good, 0,
"Pass 1 at bpt=32 should not recover any sectors when all batches fail"
result.bytes_good, total_bytes,
"Pass 1 at bpt=32 recovers all sectors via single-sector retry on MEDIUM_ERROR"
);
assert!(
result.bytes_pending > 0,
"all sectors should be NonTrimmed pending Pass 2"
);
assert!(
!result.complete,
"complete=false when sectors remain NonTrimmed"
assert_eq!(
result.bytes_pending, 0,
"no pending sectors after full recovery"
);
assert!(result.complete, "complete=true when all sectors recovered");
}
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
+119
View File
@@ -0,0 +1,119 @@
//! Regression tests for Pass N (patch) fix — decrypt key inversion bug.
//!
//! Tests that decrypt_sectors is invoked correctly when opts.decrypt=true.
//! The 2026-05-03 bug at `libfreemkv/src/disc/mod.rs:1938-1942` inverted
//! the decrypt key arms, causing patch to pass DecryptKeys::None on encrypted discs.
use libfreemkv::{aacs, decrypt::DecryptKeys};
/// Test: decrypt_sectors with AACS keys actually decrypts units.
#[test]
fn decrypt_sectors_with_aacs_keys_works() {
// Build an encrypted aligned unit
let mut unit = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
// Set encryption flag (bits 6-7 of byte 0)
unit[0] |= 0xC0;
// Fill with recognizable pattern
for (i, byte) in unit
.iter_mut()
.enumerate()
.take(aacs::ALIGNED_UNIT_LEN)
.skip(1)
{
*byte = ((i * 3 + 7) & 0xFF) as u8;
}
let unit_key: [u8; 16] = [0xAAu8; 16];
// Encrypt the unit using AACS algorithm
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
// Now we have encrypted data - create DecryptKeys with actual keys
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, unit_key)],
read_data_key: None,
};
// decrypt_sectors should handle this without error
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &keys, 0);
assert!(
result.is_ok(),
"decrypt_sectors with AACS keys should not error"
);
}
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
#[test]
fn decrypt_sectors_with_none_keys_is_noop() {
let mut sector = vec![0x42u8; 2048];
let keys = DecryptKeys::None;
let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0);
assert!(result.is_ok());
assert_eq!(
&sector[..],
&[0x42u8; 2048][..],
"DecryptKeys::None should not modify buffer"
);
}
/// Test: decrypt_sectors with CSS keys descrambles sectors.
#[test]
fn decrypt_sectors_with_css_keys_works() {
let mut sector = vec![0xFFu8; 2048];
// Set CSS scramble flag (bits 4-5 of byte 0x14)
sector[0x14] |= 0x30;
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later
let keys = DecryptKeys::Css { title_key };
// Descramble (CSS uses same operation for encrypt/decrypt)
libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0).unwrap();
// Flag should be cleared
assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared");
}
/// Test: AACS unit encryption detection works.
#[test]
fn aacs_encryption_flag_detection() {
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
// No encryption flag
assert!(!aacs::is_unit_encrypted(&unit));
// Set bit 6
unit[0] |= 0x40;
assert!(aacs::is_unit_encrypted(&unit));
// Set bit 7
unit[0] = 0x80;
assert!(aacs::is_unit_encrypted(&unit));
// Both bits set
unit[0] = 0xC0;
assert!(aacs::is_unit_encrypted(&unit));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
#[test]
fn decrypt_keys_is_encrypted_variants() {
let none = DecryptKeys::None;
assert!(!none.is_encrypted());
let aacs = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
};
assert!(aacs.is_encrypted());
let css = DecryptKeys::Css {
title_key: [0u8; 5],
};
assert!(css.is_encrypted());
}
+288
View File
@@ -0,0 +1,288 @@
//! 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::mapfile::{Mapfile, SectorStatus};
use libfreemkv::error::Result;
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorReader};
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)>>>,
}
impl PatternedSectorReader {
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, Arc<Mutex<Vec<(u32, u16)>>>) {
let trace = Arc::new(Mutex::new(Vec::new()));
(
Self {
capacity,
bad_lbas,
trace: trace.clone(),
},
trace,
)
}
}
impl SectorReader 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,
}),
});
}
}
for chunk in buf.chunks_mut(SECTOR_SIZE) {
chunk.fill((lba & 0xff) as u8);
}
Ok(buf.len())
}
fn capacity(&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,
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/Unreadable.
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,
);
}
/// 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,
);
}
+3 -1
View File
@@ -9,7 +9,6 @@
//! These tests require a real /dev/sg* device and are therefore #[ignore].
use std::path::Path;
use std::time::Duration;
#[test]
#[ignore]
@@ -25,6 +24,7 @@ fn test_sgio_transport_timeout_does_not_kill_transport() {
use libfreemkv::scsi::{DataDirection, SCSI_STATUS_TRANSPORT_FAILURE};
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
use std::time::Duration;
let mut transport = SgIoTransport::open(_path).expect("open device");
let fd_before = transport.fd;
@@ -94,6 +94,8 @@ fn test_drive_read_per_cdb_timeout_bounds_call() {
#[cfg(target_os = "linux")]
{
use std::time::Duration;
let mut drive = libfreemkv::Drive::open(_path).expect("open drive");
let timeout_ms: u32 = 5_000;