v1.0.0-rc.1
CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
This commit is contained in:
+666
-45
@@ -274,6 +274,10 @@ use crate::sector::SectorSource;
|
||||
// Pass-N tunables. Hoisted to module scope so helpers (extracted from
|
||||
// the original `Disc::patch` body) can reference them without inheriting
|
||||
// the function's local-const scope.
|
||||
// Mirror of sweep path (read_error.rs NOT_READY_MAX_RETRIES = 3): cap
|
||||
// per-LBA NOT_READY retries so a persistently-not-ready disc cannot burn
|
||||
// up to RANGE_BUDGET_CAP_SECS per range on a single LBA.
|
||||
const NOT_READY_MAX_RETRIES_PER_LBA: u32 = 3;
|
||||
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 10;
|
||||
const POST_FAILURE_PAUSE_SECS: u64 = 1;
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE: u64 = 5;
|
||||
@@ -555,6 +559,12 @@ pub(super) struct PatchLoopState {
|
||||
pub last_skip_from: Option<u64>,
|
||||
pub skip_count: u32,
|
||||
pub damage_window: Vec<bool>,
|
||||
// Per-LBA NOT_READY retry cap (mirrors sweep NOT_READY_MAX_RETRIES=3).
|
||||
// Reset whenever the current LBA changes (i.e. the cursor advances to
|
||||
// a new sector). NOT_READY retries that push past NOT_READY_MAX_RETRIES_PER_LBA
|
||||
// fall through to normal failure handling (NonTrimmed + cursor advance).
|
||||
pub not_ready_retries_per_lba: u32,
|
||||
pub not_ready_lba: Option<u32>,
|
||||
// Stall tracking
|
||||
pub bytes_good_last: u64,
|
||||
pub stall_start: std::time::Instant,
|
||||
@@ -597,6 +607,8 @@ impl PatchLoopState {
|
||||
last_skip_from: None,
|
||||
skip_count: 0,
|
||||
damage_window: Vec::with_capacity(PASSN_DAMAGE_WINDOW),
|
||||
not_ready_retries_per_lba: 0,
|
||||
not_ready_lba: None,
|
||||
bytes_good_last: bytes_good_before,
|
||||
stall_start: now,
|
||||
range_start: now,
|
||||
@@ -637,6 +649,20 @@ pub(super) fn handle_read_success<R: SectorSource + ?Sized>(
|
||||
state.blocks_read_ok += 1;
|
||||
state.consecutive_failures = 0;
|
||||
state.consecutive_good_since_skip += 1;
|
||||
// A successful read breaks any in-progress wedge-family streak.
|
||||
// wedge_count tracks CONSECUTIVE wedge-family (HARDWARE_ERROR /
|
||||
// ILLEGAL_REQUEST) senses; a good read proves the drive is still
|
||||
// responding so the streak is over. Without this reset, intermittent
|
||||
// good reads interspersed with wedge-family failures accumulate
|
||||
// wedge_count monotonically, triggering WEDGE_ABORT_THRESHOLD (16)
|
||||
// prematurely on ranges that are actually making progress.
|
||||
// Note: handle_read_failure already resets wedge_count on any
|
||||
// non-wedge-family failure; this mirrors that for the success path.
|
||||
state.wedge_count = 0;
|
||||
// A successful read means this LBA is resolved; clear the NOT_READY
|
||||
// per-LBA counter so any future failure at a different LBA starts fresh.
|
||||
state.not_ready_retries_per_lba = 0;
|
||||
state.not_ready_lba = None;
|
||||
if state.consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
|
||||
state.consecutive_skips_without_recovery = 0;
|
||||
}
|
||||
@@ -884,11 +910,45 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
||||
}
|
||||
|
||||
state.blocks_read_failed += 1;
|
||||
state.consecutive_failures += 1;
|
||||
state.consecutive_good_since_skip = 0;
|
||||
state.consecutive_singles_ok = 0;
|
||||
state.unreadable_count += 1;
|
||||
|
||||
// Reset the per-LBA NOT_READY counter whenever the LBA changes.
|
||||
// NOT_READY retries hold the cursor in place (ContinueInner), so the
|
||||
// same LBA is re-attempted each iteration until we either succeed or
|
||||
// exhaust NOT_READY_MAX_RETRIES_PER_LBA. A different LBA means the
|
||||
// cursor has advanced (or we're on a new range), so start fresh.
|
||||
if state.not_ready_lba != Some(lba) {
|
||||
state.not_ready_retries_per_lba = 0;
|
||||
state.not_ready_lba = Some(lba);
|
||||
}
|
||||
|
||||
// Check if this is a NOT_READY error that should be retried BEFORE
|
||||
// incrementing consecutive_failures so NOT_READY retries do not
|
||||
// count toward the wedge threshold (Fix 3: false-wedge prevention).
|
||||
// Mirror of sweep path (read_error.rs handle_read_error): NOT_READY
|
||||
// is capped at NOT_READY_MAX_RETRIES and not counted toward
|
||||
// wedge/skip counters.
|
||||
let sense = err.scsi_sense();
|
||||
|
||||
// ASC values (under NOT READY, sense_key 0x02) indicating temporary
|
||||
// drive unresponsiveness worth retrying:
|
||||
// 0x02 = LUN not ready, no reference position (mechanism still seeking)
|
||||
// 0x03 = LUN not ready, manual intervention required
|
||||
// 0x04 = LUN not ready, in process of becoming ready / initializing
|
||||
// (Medium-not-present is ASC 0x3A, not handled here — nothing to retry.)
|
||||
let is_not_ready_retryable = sense
|
||||
.map(|s| s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Only count toward consecutive_failures / wedge detector when this
|
||||
// is NOT a retryable NOT_READY — those are handled below and return
|
||||
// ContinueInner without advancing the cursor.
|
||||
if !is_not_ready_retryable {
|
||||
state.consecutive_failures += 1;
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_read_err",
|
||||
@@ -904,48 +964,89 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
||||
"Read failed"
|
||||
);
|
||||
|
||||
// Check if this is a NOT_READY error that should be retried
|
||||
let sense = err.scsi_sense();
|
||||
|
||||
// ASC values (under NOT READY, sense_key 0x02) indicating temporary
|
||||
// drive unresponsiveness worth retrying:
|
||||
// 0x02 = LUN not ready, no reference position (mechanism still seeking)
|
||||
// 0x03 = LUN not ready, manual intervention required
|
||||
// 0x04 = LUN not ready, in process of becoming ready / initializing
|
||||
// (Medium-not-present is ASC 0x3A, not handled here — nothing to retry.)
|
||||
let is_not_ready_retryable = sense
|
||||
.map(|s| s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04))
|
||||
.unwrap_or(false);
|
||||
|
||||
// For retryable NOT_READY errors, pause longer and don't mark as Unreadable yet
|
||||
// For retryable NOT_READY errors, pause longer and don't mark as Unreadable yet —
|
||||
// but only up to NOT_READY_MAX_RETRIES_PER_LBA times per LBA. Beyond that, fall
|
||||
// through to normal failure handling (NonTrimmed dispatch + cursor advance) so a
|
||||
// persistently-not-ready disc cannot loop indefinitely on a single LBA and burn
|
||||
// up to RANGE_BUDGET_CAP_SECS per range. Mirrors the sweep path cap in
|
||||
// read_error.rs (NOT_READY_MAX_RETRIES = 3).
|
||||
if is_not_ready_retryable {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_not_ready_retry",
|
||||
lba,
|
||||
consecutive_failures = state.consecutive_failures,
|
||||
err_asc = sense.map(|s| s.asc as u32).unwrap_or(0),
|
||||
"NOT_READY with ASC in 0x02/0x03/0x04; pausing for drive recovery before retry"
|
||||
);
|
||||
if state.not_ready_retries_per_lba < NOT_READY_MAX_RETRIES_PER_LBA {
|
||||
state.not_ready_retries_per_lba += 1;
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_not_ready_retry",
|
||||
lba,
|
||||
not_ready_retries_per_lba = state.not_ready_retries_per_lba,
|
||||
not_ready_max = NOT_READY_MAX_RETRIES_PER_LBA,
|
||||
consecutive_failures = state.consecutive_failures,
|
||||
err_asc = sense.map(|s| s.asc as u32).unwrap_or(0),
|
||||
"NOT_READY with ASC in 0x02/0x03/0x04; pausing for drive recovery before retry"
|
||||
);
|
||||
|
||||
// Extended pause for NOT_READY - let drive complete internal mechanical recovery
|
||||
let pause_secs = 15u64;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_not_ready_pause",
|
||||
lba,
|
||||
consecutive_failures = state.consecutive_failures,
|
||||
pause_secs,
|
||||
"Waiting for drive to become ready"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
|
||||
// Extended pause for NOT_READY - let drive complete internal mechanical recovery.
|
||||
// Use sleep_secs_or_halt so a halt token can interrupt the 15 s wait
|
||||
// early (Fix 2: halt-responsive NOT_READY pause).
|
||||
let pause_secs = 15u64;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_not_ready_pause",
|
||||
lba,
|
||||
consecutive_failures = state.consecutive_failures,
|
||||
pause_secs,
|
||||
"Waiting for drive to become ready"
|
||||
);
|
||||
super::sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
|
||||
|
||||
// Don't mark as Unreadable yet - will retry on next iteration
|
||||
state.damage_window.push(false);
|
||||
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
|
||||
state.damage_window.remove(0);
|
||||
// Check stall guard here — the NOT_READY retry path bypasses the
|
||||
// normal failure path's stall guard, so total runtime could
|
||||
// otherwise grow as num_ranges × RANGE_BUDGET_CAP_SECS (disc-
|
||||
// controlled). (Fix 1: DoS prevention.)
|
||||
let bytes_good_now = {
|
||||
let g = shared
|
||||
.lock()
|
||||
.expect("PatchSink shared state mutex poisoned");
|
||||
g.stats.bytes_good
|
||||
};
|
||||
if bytes_good_now > state.bytes_good_last {
|
||||
state.stall_start = std::time::Instant::now();
|
||||
state.bytes_good_last = bytes_good_now;
|
||||
}
|
||||
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_stall",
|
||||
elapsed_secs = state.stall_start.elapsed().as_secs(),
|
||||
bytes_good = bytes_good_now,
|
||||
bytes_good_start = state.bytes_good_start,
|
||||
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
|
||||
STALL_SECS
|
||||
);
|
||||
state.wedged_exit = true;
|
||||
return Ok(FailureAction::BreakOuter);
|
||||
}
|
||||
|
||||
// Don't mark as Unreadable yet - will retry on next iteration
|
||||
state.damage_window.push(false);
|
||||
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
|
||||
state.damage_window.remove(0);
|
||||
}
|
||||
return Ok(FailureAction::ContinueInner);
|
||||
}
|
||||
return Ok(FailureAction::ContinueInner);
|
||||
|
||||
// Per-LBA cap exhausted: fall through to normal failure handling
|
||||
// (NonTrimmed dispatch + cursor advance). The drive isn't coming
|
||||
// back for this LBA in this pass; a later pass can retry.
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_not_ready_cap_exceeded",
|
||||
lba,
|
||||
not_ready_retries_per_lba = state.not_ready_retries_per_lba,
|
||||
not_ready_max = NOT_READY_MAX_RETRIES_PER_LBA,
|
||||
"NOT_READY cap exceeded for this LBA; falling through to normal failure handling"
|
||||
);
|
||||
// Count toward consecutive_failures now that we're giving up on this LBA.
|
||||
state.consecutive_failures += 1;
|
||||
}
|
||||
|
||||
// (Removed in 0.20.2) The previous code retried non-NOT_READY
|
||||
@@ -1034,6 +1135,17 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
||||
let mut probes_ok = 0;
|
||||
|
||||
for (probe_idx, &offset_sectors) in probe_offsets_sectors.iter().enumerate() {
|
||||
// Honor cancellation inside the probe loop. Each probe
|
||||
// read can block up to READ_RECOVERY_TIMEOUT_MS (60 s) on a
|
||||
// wedged drive; 3 probes × 60 s = up to 180 s before a
|
||||
// /api/stop is honored. Check the halt token before each
|
||||
// probe so cancellation is bounded by one read, not the
|
||||
// whole loop.
|
||||
if let Some(h) = &opts.halt {
|
||||
if h.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(crate::error::Error::Halted);
|
||||
}
|
||||
}
|
||||
let offset = offset_sectors.saturating_mul(2048);
|
||||
let probe_pos = pos.saturating_add(offset);
|
||||
// Skip the zero-distance re-read until failures are well
|
||||
@@ -1182,7 +1294,10 @@ pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
|
||||
pause_secs,
|
||||
"breathing room after failure"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
|
||||
// Halt-responsive: a stop request must interrupt this pause rather than
|
||||
// block for up to pause_secs (which escalates per failure), so /api/stop
|
||||
// stays responsive during the most error-prone phase of a rip.
|
||||
super::sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
|
||||
Ok(FailureAction::Continue)
|
||||
}
|
||||
|
||||
@@ -1259,7 +1374,7 @@ pub(super) fn check_range_watchdog(
|
||||
state.range_bytes_good = bytes_good_now;
|
||||
state.range_start = std::time::Instant::now();
|
||||
}
|
||||
if state.range_start.elapsed().as_secs() > frame.range_budget_secs {
|
||||
if state.range_start.elapsed().as_secs() >= frame.range_budget_secs {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_range_stall",
|
||||
@@ -1485,7 +1600,7 @@ impl Disc {
|
||||
/// `NonTrimmed` block. Returns a [`PatchOutcome`] with
|
||||
/// recovered byte counts and wedge-detection signals.
|
||||
///
|
||||
/// 0.18: paired with [`Disc::sweep`] as the library's other flat
|
||||
/// Paired with [`Disc::sweep`] as the library's other flat
|
||||
/// rip-phase verb. Caller drives the retry loop and the
|
||||
/// sweep-vs-patch dispatch.
|
||||
pub fn patch(
|
||||
@@ -1497,9 +1612,17 @@ impl Disc {
|
||||
use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH};
|
||||
use crate::sector::{DecryptingSectorSource, SectorSource};
|
||||
|
||||
let patch_t0 = std::time::Instant::now();
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
let (map, initial_stats, initial_entries, total_bytes, bad_ranges, work_total, is_regular) =
|
||||
compute_initial_state(path, opts, &mapfile_path)?;
|
||||
tracing::info!(
|
||||
target: "freemkv::scan",
|
||||
phase = "patch",
|
||||
num_ranges = bad_ranges.len(),
|
||||
reverse = opts.reverse,
|
||||
"begin"
|
||||
);
|
||||
let bytes_good_before = initial_stats.bytes_good;
|
||||
let bytes_good_start = bytes_good_before;
|
||||
let keys = if opts.decrypt {
|
||||
@@ -1625,7 +1748,20 @@ impl Disc {
|
||||
state.consecutive_skips_without_recovery = 0;
|
||||
state.consecutive_good_since_skip = 0;
|
||||
state.range_start = std::time::Instant::now();
|
||||
state.range_bytes_good = state.bytes_good_before;
|
||||
// Fix 4: initialize range_bytes_good to the CURRENT bytes_good
|
||||
// (not the pass-start value bytes_good_before). Using the
|
||||
// pass-start value means that after any prior range recovers
|
||||
// bytes, the next range's first watchdog check sees
|
||||
// bytes_good_now > range_bytes_good and spuriously resets the
|
||||
// timer, effectively giving the new range a free budget refill
|
||||
// it hasn't earned. Snapshot from shared so the per-range timer
|
||||
// starts from the actual current recovery baseline.
|
||||
state.range_bytes_good = {
|
||||
let g = shared
|
||||
.lock()
|
||||
.expect("PatchSink shared state mutex poisoned");
|
||||
g.stats.bytes_good
|
||||
};
|
||||
state.skip_count = 0;
|
||||
// Reset consecutive_failures at each range boundary. The
|
||||
// wedge-exit detector is for "stuck on the same range" — many
|
||||
@@ -1799,14 +1935,24 @@ impl Disc {
|
||||
// behaviour.
|
||||
let summary = pipe.finish()?;
|
||||
|
||||
Ok(build_outcome(
|
||||
let outcome = build_outcome(
|
||||
&state,
|
||||
&summary,
|
||||
path,
|
||||
total_bytes,
|
||||
bad_ranges.len(),
|
||||
opts.wedged_threshold,
|
||||
))
|
||||
);
|
||||
tracing::info!(
|
||||
target: "freemkv::scan",
|
||||
phase = "patch",
|
||||
recovered = outcome.bytes_recovered_this_pass,
|
||||
halted = outcome.halted,
|
||||
wedged_exit = outcome.wedged_exit,
|
||||
elapsed_ms = patch_t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,4 +2236,479 @@ mod tests {
|
||||
);
|
||||
assert_eq!(state.skip_count, 1, "exactly one skip must be recorded");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Regression tests for the four audit fixes.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/// Fix 3: NOT_READY retryable errors must NOT increment
|
||||
/// `consecutive_failures`. Pre-fix the increment happened before the
|
||||
/// `is_not_ready_retryable` check, so repeated NOT_READY events on
|
||||
/// a sluggish drive could push the counter past `wedged_threshold`
|
||||
/// (50) and trigger a false wedged_exit that skipped the rest of the
|
||||
/// pass. The fix moves the increment inside an `if !is_not_ready_retryable`
|
||||
/// guard. This test verifies that the classification logic and the
|
||||
/// conditional correctly identify the NOT_READY case and leave the
|
||||
/// counter unchanged.
|
||||
#[test]
|
||||
fn fix3_not_ready_does_not_count_toward_consecutive_failures() {
|
||||
// Construct a NOT_READY sense triple (sense_key=0x02, ASC=0x04).
|
||||
let not_ready_sense = crate::scsi::ScsiSense {
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x00,
|
||||
};
|
||||
// Verify the is_not_ready_retryable predicate on the sense triple
|
||||
// (mirrors the production code exactly — both the old and new code
|
||||
// use the same predicate; this pins its correctness).
|
||||
let is_not_ready_retryable = {
|
||||
let s = ¬_ready_sense;
|
||||
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
|
||||
};
|
||||
assert!(
|
||||
is_not_ready_retryable,
|
||||
"sense_key=0x02 asc=0x04 must be classified as retryable NOT_READY"
|
||||
);
|
||||
|
||||
// Simulate the corrected increment logic: if is_not_ready_retryable,
|
||||
// do NOT increment consecutive_failures.
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
let failures_before = state.consecutive_failures;
|
||||
if !is_not_ready_retryable {
|
||||
state.consecutive_failures += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
state.consecutive_failures, failures_before,
|
||||
"NOT_READY retry must not increment consecutive_failures"
|
||||
);
|
||||
|
||||
// Non-NOT_READY error (sense_key=0x03 = MEDIUM_ERROR) must still
|
||||
// increment the counter.
|
||||
let medium_err_sense = crate::scsi::ScsiSense {
|
||||
sense_key: 0x03,
|
||||
asc: 0x11,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let is_not_ready_medium = {
|
||||
let s = &medium_err_sense;
|
||||
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
|
||||
};
|
||||
assert!(!is_not_ready_medium, "MEDIUM_ERROR must not be NOT_READY");
|
||||
let failures_before2 = state.consecutive_failures;
|
||||
if !is_not_ready_medium {
|
||||
state.consecutive_failures += 1;
|
||||
}
|
||||
assert_eq!(
|
||||
state.consecutive_failures,
|
||||
failures_before2 + 1,
|
||||
"non-NOT_READY error must increment consecutive_failures"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 3 (ASC coverage): verify all three retryable ASC values (0x02,
|
||||
/// 0x03, 0x04) are recognised and that ASC 0x3A (medium not present,
|
||||
/// NOT retryable) is NOT recognised.
|
||||
#[test]
|
||||
fn fix3_not_ready_asc_coverage() {
|
||||
let check = |sense_key: u8, asc: u8| -> bool {
|
||||
let s = crate::scsi::ScsiSense {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq: 0,
|
||||
};
|
||||
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
|
||||
};
|
||||
assert!(check(0x02, 0x02), "ASC 0x02 must be retryable");
|
||||
assert!(check(0x02, 0x03), "ASC 0x03 must be retryable");
|
||||
assert!(check(0x02, 0x04), "ASC 0x04 must be retryable");
|
||||
assert!(
|
||||
!check(0x02, 0x3A),
|
||||
"ASC 0x3A (medium not present) must NOT be retryable"
|
||||
);
|
||||
assert!(
|
||||
!check(0x03, 0x04),
|
||||
"sense_key != 0x02 must not be retryable"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 1 + Fix 2: the stall guard and halt-interruptibility of the
|
||||
/// NOT_READY pause path. Since `handle_read_failure` requires a full
|
||||
/// Pipeline (non-trivially constructable in unit tests), this test
|
||||
/// directly exercises the two sub-behaviors that Fix 1 and Fix 2 add
|
||||
/// to that path:
|
||||
///
|
||||
/// * Fix 1: when `stall_start` is already past STALL_SECS ago,
|
||||
/// `wedged_exit` must be set and `BreakOuter` returned — the same
|
||||
/// stall guard that fires in the normal failure path must also fire
|
||||
/// on the NOT_READY retry path.
|
||||
/// * Fix 2: `sleep_secs_or_halt` exits immediately when the halt
|
||||
/// token is already set, so the 15 s NOT_READY pause does not block
|
||||
/// cancellation.
|
||||
#[test]
|
||||
fn fix1_and_fix2_not_ready_stall_guard_and_halt_responsiveness() {
|
||||
// Fix 2: halt token pre-set — sleep must return in well under 1 s.
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
let halt = Arc::new(AtomicBool::new(true)); // already signalled
|
||||
let start = std::time::Instant::now();
|
||||
// `sleep_secs_or_halt` lives in disc/mod.rs (pub(crate)); from
|
||||
// this test module (inside patch.rs which is a child of disc),
|
||||
// `super` is the patch module and `super::super` is disc.
|
||||
super::super::sleep_secs_or_halt(15, Some(&halt));
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(500),
|
||||
"sleep_secs_or_halt with pre-set halt must return immediately, \
|
||||
elapsed={elapsed:?}"
|
||||
);
|
||||
|
||||
// Fix 1: stall guard logic — simulate the stall check that the
|
||||
// NOT_READY path now executes after the sleep. The guard fires
|
||||
// when stall_start is older than STALL_SECS and bytes_good has
|
||||
// not advanced. Pre-fix: the NOT_READY path returned ContinueInner
|
||||
// before this check so it was never reached.
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
// Wind the clock back past the stall threshold.
|
||||
state.stall_start = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_secs(STALL_SECS + 10))
|
||||
.unwrap_or(state.stall_start);
|
||||
// bytes_good hasn't moved (same as bytes_good_last = 0).
|
||||
let bytes_good_now = state.bytes_good_last; // no progress
|
||||
// Reproduce the stall guard condition added to the NOT_READY path.
|
||||
let stall_fires = state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS);
|
||||
assert!(
|
||||
stall_fires,
|
||||
"stall guard must fire when stall_start is older than STALL_SECS \
|
||||
and bytes_good has not advanced (bytes_good_now={bytes_good_now})"
|
||||
);
|
||||
// If it fires, the fix sets wedged_exit and returns BreakOuter.
|
||||
state.wedged_exit = true; // mirror what the production code does
|
||||
assert!(
|
||||
state.wedged_exit,
|
||||
"wedged_exit must be set when the NOT_READY stall guard fires"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 4: `range_bytes_good` must be initialized to the CURRENT
|
||||
/// bytes_good at range entry, not the pass-start value
|
||||
/// `bytes_good_before`. Pre-fix: after range 0 recovers N bytes,
|
||||
/// range 1 entered with `range_bytes_good = bytes_good_before`, so
|
||||
/// the first `check_range_watchdog` tick saw `bytes_good_now >
|
||||
/// range_bytes_good` (because of range 0's recovery) and spuriously
|
||||
/// reset `range_start` — giving range 1 a free budget refill it
|
||||
/// hadn't earned.
|
||||
///
|
||||
/// This test verifies that if `range_bytes_good` is set to the CURRENT
|
||||
/// value (no new recovery yet in this range), the watchdog does NOT
|
||||
/// reset the timer on its first tick.
|
||||
#[test]
|
||||
fn fix4_range_watchdog_does_not_spuriously_reset_after_prior_range_recovery() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// Simulate a SharedPatchState where bytes_good has already
|
||||
// advanced (due to prior range recovery).
|
||||
let current_bytes_good: u64 = 1024 * 1024; // some non-zero recovery
|
||||
let shared = Arc::new(Mutex::new(SharedPatchState {
|
||||
stats: MapStats {
|
||||
bytes_total: 0,
|
||||
bytes_good: current_bytes_good,
|
||||
bytes_pending: 0,
|
||||
bytes_unreadable: 0,
|
||||
bytes_retryable: 0,
|
||||
bytes_nontried: 0,
|
||||
num_bad_ranges: 0,
|
||||
main_lost_ms: 0.0,
|
||||
},
|
||||
bad_ranges: vec![],
|
||||
}));
|
||||
|
||||
// Fix 4 (corrected): range_bytes_good = current_bytes_good.
|
||||
// The watchdog should see bytes_good_now == range_bytes_good and
|
||||
// NOT reset range_start.
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
state.range_bytes_good = current_bytes_good; // correct: current value
|
||||
let original_range_start = state.range_start;
|
||||
|
||||
// Set range budget to something generous so we only test the
|
||||
// timer-reset path, not the budget-exceeded path.
|
||||
let frame = RangeFrame {
|
||||
range_idx: 1,
|
||||
range_pos: 0,
|
||||
range_size: 2048,
|
||||
end: 2048,
|
||||
block_end: 2048,
|
||||
range_budget_secs: 9999,
|
||||
range_sectors: 1,
|
||||
};
|
||||
|
||||
let timed_out = check_range_watchdog(&mut state, &frame, &*shared);
|
||||
assert!(!timed_out, "range must not time out immediately");
|
||||
// With correct initialization bytes_good_now == range_bytes_good,
|
||||
// so the `bytes_good_now > range_bytes_good` branch does NOT fire
|
||||
// and range_start is NOT reset.
|
||||
//
|
||||
// The pre-fix bug: range_bytes_good = bytes_good_before (0) while
|
||||
// bytes_good_now = current_bytes_good (1 MiB), so the first tick
|
||||
// would unconditionally reset range_start, masking stalls in ranges
|
||||
// that followed productive ones.
|
||||
assert_eq!(
|
||||
state.range_bytes_good, current_bytes_good,
|
||||
"range_bytes_good must stay at the current value (no new recovery yet)"
|
||||
);
|
||||
// Verify the timer was not reset: range_start should be at or
|
||||
// before the original value (it could be the same Instant or
|
||||
// marginally later due to the lock, but it must not have jumped
|
||||
// forward). We check that range_start did not advance by more than
|
||||
// 1 ms (the watchdog logic sets it to Instant::now() on reset).
|
||||
let drift = state
|
||||
.range_start
|
||||
.checked_duration_since(original_range_start)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
drift < std::time::Duration::from_millis(100),
|
||||
"range_start must not be reset on the first tick when no new recovery \
|
||||
occurred in this range (drift={drift:?})"
|
||||
);
|
||||
}
|
||||
|
||||
/// NOT_READY per-LBA cap: after NOT_READY_MAX_RETRIES_PER_LBA retries
|
||||
/// on the same LBA the cap is exhausted and the next NOT_READY is treated
|
||||
/// as a normal failure (consecutive_failures incremented, retry refused).
|
||||
/// A different LBA resets the counter so transient NOT_READY can still
|
||||
/// recover. Mirrors the sweep path cap (read_error.rs
|
||||
/// NOT_READY_MAX_RETRIES = 3).
|
||||
///
|
||||
/// Regression for: NOT_READY retries had no per-LBA bound, so a
|
||||
/// persistently-not-ready disc could loop on a single LBA until the
|
||||
/// whole-pass STALL_SECS watchdog fired (up to 3600 s per range).
|
||||
#[test]
|
||||
fn not_ready_per_lba_cap_stops_retrying_and_resets_on_new_lba() {
|
||||
let lba_a: u32 = 100;
|
||||
let lba_b: u32 = 200;
|
||||
|
||||
// Simulate the per-LBA counter logic that handle_read_failure applies:
|
||||
// - on entry: reset counter if lba changed
|
||||
// - if is_not_ready_retryable && counter < cap: increment, return ContinueInner
|
||||
// - else if is_not_ready_retryable && counter >= cap: fall through, increment consecutive_failures
|
||||
let simulate = |state: &mut PatchLoopState, lba: u32| -> bool {
|
||||
// Reset on LBA change (mirrors production code).
|
||||
if state.not_ready_lba != Some(lba) {
|
||||
state.not_ready_retries_per_lba = 0;
|
||||
state.not_ready_lba = Some(lba);
|
||||
}
|
||||
let is_not_ready = true; // all calls in this test are NOT_READY
|
||||
if is_not_ready {
|
||||
if state.not_ready_retries_per_lba < NOT_READY_MAX_RETRIES_PER_LBA {
|
||||
state.not_ready_retries_per_lba += 1;
|
||||
return true; // ContinueInner (retry)
|
||||
}
|
||||
// cap exceeded: fall through — count toward consecutive_failures
|
||||
state.consecutive_failures += 1;
|
||||
}
|
||||
false // not retried
|
||||
};
|
||||
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
|
||||
// First NOT_READY_MAX_RETRIES_PER_LBA calls on lba_a must be retried.
|
||||
for i in 1..=NOT_READY_MAX_RETRIES_PER_LBA {
|
||||
let retried = simulate(&mut state, lba_a);
|
||||
assert!(
|
||||
retried,
|
||||
"retry {i}/{NOT_READY_MAX_RETRIES_PER_LBA} on lba_a must return ContinueInner"
|
||||
);
|
||||
assert_eq!(
|
||||
state.not_ready_retries_per_lba, i,
|
||||
"counter must be {i} after {i} retries"
|
||||
);
|
||||
assert_eq!(
|
||||
state.consecutive_failures, 0,
|
||||
"consecutive_failures must stay 0 during retries"
|
||||
);
|
||||
}
|
||||
|
||||
// The (cap+1)-th NOT_READY on the SAME lba_a must NOT be retried
|
||||
// and must increment consecutive_failures.
|
||||
let retried = simulate(&mut state, lba_a);
|
||||
assert!(
|
||||
!retried,
|
||||
"NOT_READY on lba_a after cap must NOT return ContinueInner"
|
||||
);
|
||||
assert_eq!(
|
||||
state.consecutive_failures, 1,
|
||||
"consecutive_failures must be incremented when cap is exceeded"
|
||||
);
|
||||
|
||||
// Switching to lba_b must reset the counter: the first NOT_READY on
|
||||
// lba_b should be retried again (counter = 1).
|
||||
let retried = simulate(&mut state, lba_b);
|
||||
assert!(
|
||||
retried,
|
||||
"first NOT_READY on lba_b (new LBA) must return ContinueInner \
|
||||
(counter reset on LBA change)"
|
||||
);
|
||||
assert_eq!(
|
||||
state.not_ready_retries_per_lba, 1,
|
||||
"counter must restart at 1 after LBA change"
|
||||
);
|
||||
assert_eq!(
|
||||
state.consecutive_failures, 1,
|
||||
"consecutive_failures must not change on a successful NOT_READY retry after LBA change"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fix 5: probe for-loop halt-token check.
|
||||
///
|
||||
/// Pre-fix: the probe loop in `handle_read_failure` had no halt-token
|
||||
/// check. Each probe read can block up to READ_RECOVERY_TIMEOUT_MS
|
||||
/// (60 s); with 3 probes a /api/stop could take up to ~180 s to be
|
||||
/// honored.
|
||||
///
|
||||
/// The fix adds the same pattern used by the backtrack inner loop
|
||||
/// (~line 785):
|
||||
///
|
||||
/// if let Some(h) = &opts.halt {
|
||||
/// if h.load(Ordering::Relaxed) { return Err(Halted); }
|
||||
/// }
|
||||
///
|
||||
/// `handle_read_failure` is not unit-testable in isolation because it
|
||||
/// requires a live `Pipeline` sink. This test verifies the two
|
||||
/// sub-behaviors the fix relies on:
|
||||
///
|
||||
/// 1. The probe block is reached when `consecutive_failures >= 3
|
||||
/// && consecutive_failures % 5 == 0` — confirmed by checking the
|
||||
/// gate condition directly.
|
||||
/// 2. An `AtomicBool` pre-set to `true` loaded with `Ordering::Relaxed`
|
||||
/// returns `true` immediately (i.e., the early-exit logic is sound).
|
||||
///
|
||||
/// Together these guarantee that a pre-set halt token causes the loop
|
||||
/// to exit on the first iteration without issuing a read.
|
||||
#[test]
|
||||
fn fix5_probe_loop_honors_halt_token() {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
// 1. Gate condition: consecutive_failures = 5 triggers probe block.
|
||||
// (first value satisfying >= 3 && % 5 == 0)
|
||||
let consecutive_failures: u64 = 5;
|
||||
assert!(
|
||||
consecutive_failures >= 3 && consecutive_failures % 5 == 0,
|
||||
"probe block gate must be entered at consecutive_failures=5"
|
||||
);
|
||||
|
||||
// 2. Pre-set halt token must be detected immediately via Relaxed load.
|
||||
// Use Arc to match the production type (Option<Arc<AtomicBool>>).
|
||||
let halt = Arc::new(AtomicBool::new(true));
|
||||
let detected = halt.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
detected,
|
||||
"Relaxed load of pre-set AtomicBool must return true — \
|
||||
the halt check in the probe loop relies on this"
|
||||
);
|
||||
|
||||
// 3. Zero-offset probe (offset_sectors = 0, probe_idx = 0) fires
|
||||
// only when consecutive_failures >= 5; validate that gate too.
|
||||
// (The halt check comes before this guard, so it fires first
|
||||
// regardless — but confirm the gate would otherwise let it through.)
|
||||
assert!(
|
||||
consecutive_failures >= 5,
|
||||
"zero-offset probe guard requires consecutive_failures >= 5; \
|
||||
halt check must fire before this gate is even evaluated"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for MED bug: `wedge_count` must be CONSECUTIVE, reset on
|
||||
/// success.
|
||||
///
|
||||
/// Pre-fix: `handle_read_success` never touched `wedge_count`. A
|
||||
/// sequence of wedge-family failures interspersed with good reads
|
||||
/// accumulated `wedge_count` monotonically, hitting
|
||||
/// `WEDGE_ABORT_THRESHOLD` (16) and aborting the pass even though the
|
||||
/// drive was actually making forward progress. The fix adds
|
||||
/// `state.wedge_count = 0` in `handle_read_success` so only a run of
|
||||
/// CONSECUTIVE wedge-family senses (with no intervening success) can
|
||||
/// reach the threshold.
|
||||
///
|
||||
/// Scenario A: failures with an intervening success must NOT reach the
|
||||
/// threshold.
|
||||
///
|
||||
/// Scenario B: a true run of consecutive wedge-family failures (no
|
||||
/// intervening success) must still reach the threshold and set
|
||||
/// `wedged_exit`.
|
||||
#[test]
|
||||
fn wedge_count_resets_on_success_prevents_premature_abort() {
|
||||
// Simulate the wedge_count mutation that handle_read_success now
|
||||
// performs (state.wedge_count = 0) and the wedge increment that
|
||||
// handle_read_failure performs for is_wedge_family errors.
|
||||
|
||||
// Helper: apply one wedge-family failure — mirrors the production path
|
||||
// in handle_read_failure (is_wedge_family branch).
|
||||
let wedge_failure = |state: &mut PatchLoopState| {
|
||||
state.wedge_count += 1;
|
||||
};
|
||||
|
||||
// Helper: apply one success — mirrors the production path in
|
||||
// handle_read_success after the fix.
|
||||
let success = |state: &mut PatchLoopState| {
|
||||
state.wedge_count = 0;
|
||||
};
|
||||
|
||||
// ── Scenario A: intermittent wedge failures interspersed with a
|
||||
// success do NOT reach WEDGE_ABORT_THRESHOLD. ──────────────────────
|
||||
{
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
|
||||
// Drive 10 wedge-family failures.
|
||||
for _ in 0..10 {
|
||||
wedge_failure(&mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.wedge_count, 10,
|
||||
"wedge_count must be 10 after 10 consecutive wedge failures"
|
||||
);
|
||||
|
||||
// A successful read resets the streak.
|
||||
success(&mut state);
|
||||
assert_eq!(
|
||||
state.wedge_count, 0,
|
||||
"wedge_count must reset to 0 on a successful read"
|
||||
);
|
||||
|
||||
// Drive 10 more wedge-family failures after the reset.
|
||||
for _ in 0..10 {
|
||||
wedge_failure(&mut state);
|
||||
}
|
||||
assert_eq!(
|
||||
state.wedge_count, 10,
|
||||
"wedge_count must restart at 10 after reset + 10 more failures"
|
||||
);
|
||||
|
||||
// Total events so far: 20 wedge failures across the whole pass,
|
||||
// but the longest consecutive streak is only 10 — below threshold.
|
||||
assert!(
|
||||
state.wedge_count < WEDGE_ABORT_THRESHOLD,
|
||||
"intermittent pattern (10 + success + 10) must not reach \
|
||||
WEDGE_ABORT_THRESHOLD ({WEDGE_ABORT_THRESHOLD}); \
|
||||
wedge_count = {}",
|
||||
state.wedge_count
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scenario B: an unbroken run of WEDGE_ABORT_THRESHOLD consecutive
|
||||
// wedge failures DOES reach the threshold. ─────────────────────────
|
||||
{
|
||||
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
|
||||
|
||||
for _ in 0..WEDGE_ABORT_THRESHOLD {
|
||||
wedge_failure(&mut state);
|
||||
}
|
||||
assert!(
|
||||
state.wedge_count >= WEDGE_ABORT_THRESHOLD,
|
||||
"a true run of {WEDGE_ABORT_THRESHOLD} consecutive wedge failures \
|
||||
must reach the threshold; wedge_count = {}",
|
||||
state.wedge_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user