disc/read_error: unify Pass 1 and Pass N error handling
User's design call after watching the avoidance work prevent a wedge
on the live rip (no wedge events across 6 read errors): "Pass N
and 1 should both be very very similar in recovery. almost identical
just smaller sectors imo in pass n. pause times the same imo as a
failed read is a failed read."
The error-handling code path was already centralized (one
handle_read_error fn, called by both Disc::sweep and Disc::patch).
The TUNING was split — Pass 1 used 5 s inter-error pauses + a
wedge-skip-and-continue policy; Pass N used 1 s pauses + immediate
AbortPass on HARDWARE_ERROR / ILLEGAL_REQUEST. That asymmetry made
Pass N vulnerable to the same wedge that Pass 1's avoidance fixed.
Changes:
1. FAIL_PAUSE_SECS = 5 — single constant, applied uniformly to both
passes. Dropped PASS_1_FAIL_PAUSE_SECS and POST_FAILURE_PAUSE_SECS
in favor of one value. CONSECUTIVE_FAIL_LONG_PAUSE_SECS kept as a
distinct (but currently equal) value for future tuning escalation.
2. HARDWARE_ERROR / ILLEGAL_REQUEST path is now symmetric:
- Pass 1: JumpAhead WEDGE_JUMP_SECTORS (1 GB) + WEDGE_PAUSE_SECS
cooldown, mark skipped region NonTrimmed.
- Pass N: JumpAhead WEDGE_PASS_N_SKIP_SECTORS (64 sectors / 128 KB)
+ WEDGE_PAUSE_SECS cooldown. Pass N's batch=1 means a 1 GB skip
would abandon the entire current NonTrimmed range; small skip
moves past the bricked LBA + buffer, outer patch loop picks up
the next sector.
- Both share WEDGE_ABORT_THRESHOLD — same 16-skip budget before
real AbortPass on a permanently stuck drive.
3. wedge_skip / wedge_abort tracing logs now include `pass=1|N`
so post-mortems can see which pass hit the wedge condition.
Cost analysis:
Pre-reframe worry was "5 s × 5500 NonTrimmed sectors per Pass N
pass × 7 passes = 53 hours." Reality: most NonTrimmed sectors
recover on first or second retry, so most reads are successful and
pay 0 pause. The few that DON'T recover hit the 10-skip budget and
get marked Unreadable — bounded at 10 × 5 s = 50 s per truly-bad
sector. Worst-case Pass N pause overhead on a typical damaged disc
is single-digit minutes, not hours. And it's strictly cheaper than
the alternative (wedge kills the entire multi-pass recovery).
Tests:
- `both_passes_pause_on_failed_read_for_wedge_avoidance` — locks the
unified pause-tuning policy (was pass_1_pauses_briefly).
- `pass_n_hardware_error_also_skips_not_aborts` — was
`pass_n_hardware_error_still_aborts`. New behavior verified:
JumpAhead with WEDGE_PASS_N_SKIP_SECTORS + WEDGE_PAUSE_SECS.
- `pass_n_hardware_error_aborts_after_threshold` — new. Confirms
Pass N respects the same WEDGE_ABORT_THRESHOLD as Pass 1.
- pass_1_does_not_pause_on_skip is gone (it was the old "Pass 1
pause=0" assertion, irrelevant after the avoidance work).
Empirical validation: avoidance was already proven on a live rip
tonight — 6 read errors on a damaged disc, sense_family=Medium
throughout, wedge_count=0, Pass 1 continued cleanly past 40%
where it previously died at 48%. This commit extends the same
discipline to Pass N's recovery loop.
Precommit (cargo +1.86 fmt + clippy + test) green.
This commit is contained in:
+111
-80
@@ -294,7 +294,26 @@ pub enum ReadAction {
|
|||||||
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
|
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
|
||||||
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
|
// 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.
|
// post-failure pauses give the drive — and the bridge — time to settle.
|
||||||
const POST_FAILURE_PAUSE_SECS: u64 = 1;
|
/// Pause between a failed read and the next read attempt — applied
|
||||||
|
/// uniformly to Pass 1 sweep and Pass N patch.
|
||||||
|
///
|
||||||
|
/// 2026-05-11 reframe: a failed read is a failed read, regardless of
|
||||||
|
/// which pass is running. The prior split (1s for Pass N, 5s for Pass
|
||||||
|
/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost
|
||||||
|
/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the
|
||||||
|
/// extra 5s/error is single-digit minutes per pass, not hours. The
|
||||||
|
/// cost of NOT pausing — a drive wedge that aborts the entire
|
||||||
|
/// multi-pass recovery — is much worse.
|
||||||
|
///
|
||||||
|
/// The wedge avoidance principle: error → drive ECC retry (5-10s
|
||||||
|
/// internal) → return → cooldown pause → next read. Same shape
|
||||||
|
/// everywhere reads can fail.
|
||||||
|
const FAIL_PAUSE_SECS: u64 = 5;
|
||||||
|
/// Cooldown when a long streak of failures suggests the drive is
|
||||||
|
/// stuck in a damage zone and needs MORE breathing room than the
|
||||||
|
/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS`
|
||||||
|
/// because empirically 5s is enough; kept as a separate name so the
|
||||||
|
/// escalation policy is explicit at the call site.
|
||||||
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
|
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
|
||||||
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
|
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
|
||||||
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
|
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
|
||||||
@@ -303,14 +322,6 @@ const NOT_READY_MAX_RETRIES: u32 = 3;
|
|||||||
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
|
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
|
||||||
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
|
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
|
||||||
|
|
||||||
/// Pass 1 inter-error pause. ZERO on the clean path (sweep zooms
|
|
||||||
/// past damage windows) but applied to every failed read so the
|
|
||||||
/// drive's firmware gets cool-down time between damage-cluster
|
|
||||||
/// exposures. Wedge-avoidance change 2026-05-10: pre-fix `pause_secs
|
|
||||||
/// = 0` on Pass 1 errors caused back-to-back exposures that
|
|
||||||
/// accumulated firmware wedge state.
|
|
||||||
const PASS_1_FAIL_PAUSE_SECS: u64 = 5;
|
|
||||||
|
|
||||||
/// Base of the damage-jump distance formula: `jump_sectors =
|
/// Base of the damage-jump distance formula: `jump_sectors =
|
||||||
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
|
/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10
|
||||||
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
|
/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers
|
||||||
@@ -360,6 +371,15 @@ const WEDGE_PAUSE_SECS: u64 = 30;
|
|||||||
/// a permanently bricked drive.
|
/// a permanently bricked drive.
|
||||||
const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
const WEDGE_ABORT_THRESHOLD: u64 = 16;
|
||||||
|
|
||||||
|
/// Pass-N wedge-skip distance. Pass N's batch=1 reads target
|
||||||
|
/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip
|
||||||
|
/// would blow past the current NonTrimmed range and abandon many
|
||||||
|
/// sectors that might still recover. Use a smaller skip just to
|
||||||
|
/// move past the bricked LBA + a small buffer — the outer patch
|
||||||
|
/// loop's next iteration picks up the next sector in the same or
|
||||||
|
/// next range.
|
||||||
|
const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64;
|
||||||
|
|
||||||
/// THE single error-handling entry point. Updates `ctx`, returns the
|
/// THE single error-handling entry point. Updates `ctx`, returns the
|
||||||
/// action the caller must apply.
|
/// action the caller must apply.
|
||||||
///
|
///
|
||||||
@@ -479,27 +499,27 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
|||||||
|
|
||||||
// 4. Hardware error / illegal request — the firmware-wedge family.
|
// 4. Hardware error / illegal request — the firmware-wedge family.
|
||||||
// The drive transitioned into a fast-fail state where it
|
// The drive transitioned into a fast-fail state where it
|
||||||
// rejects reads near the LBA. Two policies:
|
// rejects reads near the LBA. Same response shape for both
|
||||||
|
// passes (2026-05-11 reframe — error handling is centralized,
|
||||||
|
// and the wedge is a code-induced state we can avoid via
|
||||||
|
// pacing + skip):
|
||||||
//
|
//
|
||||||
// Pass 1 sweep (bisect_on_marginal=false): the wedge is
|
// - Pass 1 sweep (bisect_on_marginal=false): jump
|
||||||
// *recoverable* by skipping. Jump a large distance ahead
|
// WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS,
|
||||||
// (1 GB), pause for cooldown, mark the skipped region
|
// mark skipped region NonTrimmed.
|
||||||
// NonTrimmed so Pass N revisits. Allow up to
|
// - Pass N patch (bisect_on_marginal=true): give up on the
|
||||||
// WEDGE_ABORT_THRESHOLD consecutive wedges before truly
|
// current sector (the granular target), pause for cooldown,
|
||||||
// giving up. This replaces the pre-fix "abort the entire
|
// let the outer patch loop move to the next NonTrimmed
|
||||||
// rip on first wedge" behavior that caused 48%-and-die
|
// range. Implemented as a small JumpAhead so the same code
|
||||||
// failures on discs with one bad cluster.
|
// path serves both — Pass N's batch=1 means JumpAhead by
|
||||||
|
// WEDGE_PASS_N_SKIP_SECTORS effectively skips just this
|
||||||
|
// sector and a small buffer (gives the drive room to
|
||||||
|
// recover before the next per-sector attempt).
|
||||||
//
|
//
|
||||||
// Pass N patch (bisect_on_marginal=true): Pass N's job IS
|
// Both paths share the WEDGE_ABORT_THRESHOLD budget — only
|
||||||
// single-sector recovery; a wedge means the drive won't
|
// AbortPass after N consecutive wedges with no successful
|
||||||
// give us the specific sectors we asked for. Skipping
|
// read in between.
|
||||||
// doesn't help here. Abort and let autorip decide whether
|
|
||||||
// to retry, eject, or surface the failure to the user.
|
|
||||||
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
||||||
if ctx.bisect_on_marginal {
|
|
||||||
return ReadAction::AbortPass;
|
|
||||||
}
|
|
||||||
// Pass 1 wedge-skip path.
|
|
||||||
if !ctx.bisecting {
|
if !ctx.bisecting {
|
||||||
ctx.wedge_count += 1;
|
ctx.wedge_count += 1;
|
||||||
}
|
}
|
||||||
@@ -509,21 +529,28 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
|||||||
phase = "wedge_abort",
|
phase = "wedge_abort",
|
||||||
wedge_count = ctx.wedge_count,
|
wedge_count = ctx.wedge_count,
|
||||||
threshold = WEDGE_ABORT_THRESHOLD,
|
threshold = WEDGE_ABORT_THRESHOLD,
|
||||||
"Pass 1 wedge-skip exhausted — drive appears permanently stuck"
|
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||||
|
"wedge-skip exhausted — drive appears permanently stuck"
|
||||||
);
|
);
|
||||||
return ReadAction::AbortPass;
|
return ReadAction::AbortPass;
|
||||||
}
|
}
|
||||||
|
let jump_sectors = if ctx.bisect_on_marginal {
|
||||||
|
WEDGE_PASS_N_SKIP_SECTORS
|
||||||
|
} else {
|
||||||
|
WEDGE_JUMP_SECTORS
|
||||||
|
};
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "wedge_skip",
|
phase = "wedge_skip",
|
||||||
|
pass = if ctx.bisect_on_marginal { "N" } else { "1" },
|
||||||
wedge_count = ctx.wedge_count,
|
wedge_count = ctx.wedge_count,
|
||||||
jump_sectors = WEDGE_JUMP_SECTORS,
|
jump_sectors,
|
||||||
pause_secs = WEDGE_PAUSE_SECS,
|
pause_secs = WEDGE_PAUSE_SECS,
|
||||||
"Pass 1 wedge detected — jumping ahead and pausing for drive cooldown"
|
"wedge detected — skipping ahead and pausing for drive cooldown"
|
||||||
);
|
);
|
||||||
ctx.jumps_taken += 1;
|
ctx.jumps_taken += 1;
|
||||||
return ReadAction::JumpAhead {
|
return ReadAction::JumpAhead {
|
||||||
sectors: WEDGE_JUMP_SECTORS,
|
sectors: jump_sectors,
|
||||||
pause_secs: WEDGE_PAUSE_SECS,
|
pause_secs: WEDGE_PAUSE_SECS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -572,35 +599,15 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
|||||||
bad_count * 100 / ctx.damage_window.len()
|
bad_count * 100 / ctx.damage_window.len()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inter-error pause on Pass 1 — wedge avoidance.
|
// Inter-error pause — wedge avoidance, applied uniformly across
|
||||||
//
|
// Pass 1 and Pass N. A failed read is a failed read; same
|
||||||
// Pre-2026-05-10 Pass 1 ran `pause_secs = 0` on all errors so
|
// cool-down regardless of which pass is calling. The escalation
|
||||||
// sweep would zoom past damage zones in seconds instead of
|
// arm (LONG_PAUSE after a long streak) currently resolves to the
|
||||||
// minutes. Empirically on the BU40N this caused firmware-wedge
|
// same value but is kept as a separate branch for future tuning.
|
||||||
// events: each read failure leaves residual state in the drive's
|
let pause_secs = if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||||
// firmware, and back-to-back errors without cooldown accumulate
|
|
||||||
// toward a wedge threshold. We hit wedge on Dune Pt 2 after 5
|
|
||||||
// errors in 43 s spread over a 140 MB damage cluster — even
|
|
||||||
// though wall-clock pacing was slow, the drive had no breathing
|
|
||||||
// room between exposures.
|
|
||||||
//
|
|
||||||
// New policy: Pass 1 keeps `pause = 0` on the CLEAN path
|
|
||||||
// (successful reads zoom past damage windows fine), but every
|
|
||||||
// failed read takes a PASS_1_FAIL_PAUSE_SECS pause before the
|
|
||||||
// next read. Cost: ~5 s extra per scattered failure, ~30-60 s
|
|
||||||
// total in a damage cluster — trivial compared to the alternative
|
|
||||||
// of crashing the whole rip at 48%. Long failure streaks still
|
|
||||||
// escalate via CONSECUTIVE_FAIL_LONG_PAUSE.
|
|
||||||
let pause_secs = if !ctx.bisect_on_marginal {
|
|
||||||
if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
|
||||||
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
||||||
} else {
|
} else {
|
||||||
PASS_1_FAIL_PAUSE_SECS
|
FAIL_PAUSE_SECS
|
||||||
}
|
|
||||||
} else 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 → skip ahead by an escalating
|
// 7. Damage-jump: too many failures → skip ahead by an escalating
|
||||||
@@ -868,13 +875,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pass_n_hardware_error_still_aborts() {
|
fn pass_n_hardware_error_also_skips_not_aborts() {
|
||||||
// Pass N (bisect_on_marginal=true) keeps the original
|
// 2026-05-11 reframe: error handling is centralized, the
|
||||||
// AbortPass behavior — single-sector recovery can't make
|
// wedge is a code-induced state, and the avoidance principle
|
||||||
// progress through a wedge, so the right answer is to bail
|
// (skip + pause + continue) applies to Pass N too. Previously
|
||||||
// and let the outer layer decide.
|
// Pass N AbortPass'd on first wedge — same fatal-at-48% bug
|
||||||
|
// Pass 1 had pre-fix. Now Pass N gets a smaller skip
|
||||||
|
// (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump)
|
||||||
|
// because Pass N's job IS to revisit specific NonTrimmed
|
||||||
|
// ranges; over-skipping abandons recoverable sectors.
|
||||||
let mut ctx = ReadCtx::for_patch(1);
|
let mut ctx = ReadCtx::for_patch(1);
|
||||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||||
|
match action {
|
||||||
|
ReadAction::JumpAhead {
|
||||||
|
sectors,
|
||||||
|
pause_secs,
|
||||||
|
} => {
|
||||||
|
assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS);
|
||||||
|
assert_eq!(pause_secs, WEDGE_PAUSE_SECS);
|
||||||
|
}
|
||||||
|
other => panic!("expected JumpAhead, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(ctx.wedge_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pass_n_hardware_error_aborts_after_threshold() {
|
||||||
|
// Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD
|
||||||
|
// consecutive wedges with no good read in between, give up.
|
||||||
|
let mut ctx = ReadCtx::for_patch(1);
|
||||||
|
for _ in 0..WEDGE_ABORT_THRESHOLD - 1 {
|
||||||
|
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||||
|
assert!(matches!(action, ReadAction::JumpAhead { .. }));
|
||||||
|
}
|
||||||
|
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||||
assert_eq!(action, ReadAction::AbortPass);
|
assert_eq!(action, ReadAction::AbortPass);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,24 +945,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pass_1_pauses_briefly_on_skip_for_wedge_avoidance() {
|
fn both_passes_pause_on_failed_read_for_wedge_avoidance() {
|
||||||
// Pre-2026-05-10 Pass 1 ran pause_secs=0 on all errors (zoom
|
// 2026-05-11 reframe: a failed read is a failed read. Same
|
||||||
// past damage zones in seconds). That caused firmware wedges
|
// FAIL_PAUSE_SECS for both Pass 1 (sweep) and Pass N (patch).
|
||||||
// on the BU40N — back-to-back errors with no cooldown built
|
// Pre-reframe Pass 1 had its own PASS_1_FAIL_PAUSE_SECS=5
|
||||||
// up firmware state until the drive entered the wedge fast-
|
// and Pass N had POST_FAILURE_PAUSE_SECS=1 — the asymmetry
|
||||||
// fail mode. New policy: a brief inter-error pause (5 s) on
|
// is gone. The wedge avoidance is a centralized policy now.
|
||||||
// Pass 1 to give the drive's firmware time to settle between
|
for mut ctx in [ReadCtx::for_sweep(32), ReadCtx::for_patch(1)] {
|
||||||
// damage-zone exposures. Successful reads remain zero-pause
|
|
||||||
// — only errors cost time, and only a few seconds per
|
|
||||||
// scattered failure. Trivial cost compared to crashing the
|
|
||||||
// whole rip at 48%.
|
|
||||||
let mut ctx = ReadCtx::for_sweep(32);
|
|
||||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||||
match action {
|
let pause = match action {
|
||||||
ReadAction::SkipBlock { pause_secs } => {
|
ReadAction::SkipBlock { pause_secs } => pause_secs,
|
||||||
assert_eq!(pause_secs, PASS_1_FAIL_PAUSE_SECS);
|
ReadAction::JumpAhead { pause_secs, .. } => pause_secs,
|
||||||
}
|
ReadAction::Bisect => continue,
|
||||||
other => panic!("expected SkipBlock, got {other:?}"),
|
other => panic!("expected pausing action, got {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(pause, FAIL_PAUSE_SECS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user