//! Single source of truth for what to do when a sector read fails. //! //! Pass 1 (`Disc::sweep`) calls 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. Pass N patch has its own //! `handle_read_failure` in `disc/patch.rs` that does not route here. //! //! 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, /// Failed OUTER batch reads since the last outer success — bisect /// inner-sector failures are NOT counted here. Drives the /// fast-entry damage-jump on Pass 1 (skip the disc-level grind /// once we're clearly in a damaged region; Pass N will recover /// the actual sectors). Reset on outer success. pub consecutive_outer_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, /// Maximum number of outcome entries kept in `damage_window`; the /// oldest is evicted once this is exceeded. A whole count (e.g. 16). pub damage_window_max: usize, /// Fraction of `damage_window` entries that must be failures before /// the window-based damage-jump fires, as a whole-number percentage /// (e.g. `12` = 12%). pub damage_threshold_pct: usize, /// Trigger a damage-jump after this many consecutive outer-batch /// failures, even when the damage_window isn't full yet. Pass 1 /// uses a small value (1 — jump on the first outer failure; see /// the 2026-05-11 rewrite in `for_sweep`) so we don't spend ~40 /// minutes grinding to fill a 16-block window before the first jump /// on a damage zone we entered cleanly. Pass N uses a larger value /// (or disables this — see `bisect_on_marginal`) because Pass N's /// whole job IS to grind on the bad ranges. pub fast_jump_threshold: u64, /// 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, /// Whether to return `Bisect` on a marginal-media batch failure. /// Pass 1 sweep sets this false: a failed batch becomes /// SkipBlock (mark the whole 32-sector ECC block NonTrimmed, /// advance, let Pass N recover the salvageable sectors with /// proper recovery semantics). Pass N sets this true: bisection /// is its core job, and it has the right tools (single-sector /// reads, 60s recovery timeout, retry budget, escalating skip). pub bisect_on_marginal: bool, /// Count of consecutive firmware-wedge responses (HARDWARE_ERROR /// or ILLEGAL_REQUEST sense keys) since the last successful read. /// Pass 1 uses this to drive the wedge-skip path: each wedge /// triggers a 1 GB jump + cooldown pause. Reaching /// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read /// in between → real AbortPass. pub wedge_count: u64, // ── Diagnostic counters (added 2026-05-10) ── // // Aggregate state for post-mortem analysis of wedge incidents. // Every Pass 1 / Pass N sweep now produces a structured summary // at the WARN log on each error AND an end-of-pass INFO summary. // Goal: when a wedge happens, the operator should be able to tell // from the logs whether it was triggered by ONE read at a // physically-damaged sector (immediate failure) or by accumulated // exposure across MANY reads (firmware-state buildup), and what // the timing pattern looked like. /// `Instant` of the most recent successful read. Used to compute /// "time since last good" for the WARN log on each error. None /// before the first successful read. pub last_success_at: Option, /// `Instant` of the most recent failed read. Used to compute /// "time since last error" for the WARN log. None before the /// first error. pub last_error_at: Option, /// Last error's sense-key "family" (Medium / Hardware / IllegalRequest /// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when /// the family changes from Medium → Hardware/IllegalRequest, the /// drive almost certainly just entered fast-fail mode. That /// transition gets its own WARN log so the trace is unambiguous. pub last_error_family: Option, /// Sum of all errors observed during this sweep. Reported in the /// end-of-pass summary. pub total_errors: u64, /// Sum of all successful reads during this sweep. pub total_reads_ok: u64, /// Count of damage zones entered (transitions from clean → in-damage). pub zones_entered: u64, /// Count of damage-jumps executed during this sweep. pub jumps_taken: u64, /// True between "first error after a clean period" and "16 consecutive /// good reads after the last error in the cluster." Used to count /// zone entries and to bound zone_reads accurately. pub in_damage_zone: bool, } /// Coarse classification of a SCSI sense key for diagnostic logging. /// Wedge-family events (Hardware + IllegalRequest) get their own /// transition log when the sense family changes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SenseFamily { NotReady, Medium, Hardware, IllegalRequest, Other, } impl SenseFamily { pub fn from_sense_key(sense_key: u8) -> Self { match sense_key { scsi::SENSE_KEY_NOT_READY => SenseFamily::NotReady, scsi::SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium, scsi::SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware, scsi::SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest, _ => SenseFamily::Other, } } /// True for the "wedge family" — Hardware + IllegalRequest are /// the senses the BU40N firmware returns in its fast-fail state. pub fn is_wedge_family(self) -> bool { matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest) } } impl ReadCtx { /// Initial context for a Pass 1 sweep. The job is "fast and /// accurate, get the most data in the shortest time" — Pass N /// is the one that grinds on the bad ranges. So bisect-on- /// marginal is OFF (failed batches become SkipBlock; whole 32- /// sector blocks marked NonTrimmed for Pass N to revisit), and /// the damage-jump fast-path triggers after just 1 consecutive /// outer-batch failure — the user's wedge-prevention principle /// (2026-05-11): once the drive returns ANY recoverable error, /// retrying the same LBA quickly is what triggers the firmware /// fast-fail transition. Jump immediately, never retry in Pass 1. /// Pass N owns retries — it gets per-sector timeouts that don't /// hammer the firmware the same way. pub fn for_sweep(batch: u16) -> Self { Self { batch, consecutive_good: 0, consecutive_failures: 0, consecutive_outer_failures: 0, damage_window: Vec::with_capacity(16), damage_window_max: 16, damage_threshold_pct: 12, fast_jump_threshold: 1, jump_multiplier: 1, not_ready_retries: 0, bridge_degradation_count: 0, bisecting: false, bisect_on_marginal: false, wedge_count: 0, last_success_at: None, last_error_at: None, last_error_family: None, total_errors: 0, total_reads_ok: 0, zones_entered: 0, jumps_taken: 0, in_damage_zone: false, } } /// Initial context for a Pass 2-N patch. Pass N's whole reason to /// exist is to recover sectors Pass 1 skipped — bisection on /// marginal media is part of the job, and the fast-jump /// threshold is loose so we don't bail too early on a range that /// has scattered good sectors mixed in. /// /// `damage_threshold_pct = 6` mirrors `disc/patch.rs`'s /// `PASSN_DAMAGE_THRESHOLD_PCT`. Pass N triggers the damage-skip /// at half the density Pass 1 uses (Pass 1 = 12%) because the /// patch loop's whole job is to chip away at bad ranges — being /// more eager to skip clustered bad sectors converges faster on /// the recoverable good sectors inside a range. The patch-side /// `compute_damage_skip` reads its threshold directly from /// `PASSN_DAMAGE_THRESHOLD_PCT`, which is an alias for this crate's /// `PATCH_DAMAGE_THRESHOLD_PCT`, so the two are always in sync. /// The patch loop's damage-skip is not yet unified with `handle_read_error`'s /// jump path. (v0.20.8 unification attempt found the unification /// itself blocked on the size-aware `range_remaining/4` cap that /// lives in `compute_damage_skip` but not in /// `handle_read_error::JumpAhead` — see /// `tests/passn_handler_ab.rs` for the A/B fixture that pins /// the divergence point.) pub fn for_patch(batch: u16) -> Self { Self { batch, consecutive_good: 0, consecutive_failures: 0, consecutive_outer_failures: 0, damage_window: Vec::with_capacity(16), damage_window_max: 16, damage_threshold_pct: PATCH_DAMAGE_THRESHOLD_PCT, // Pass N is allowed to grind: window-based jump only, // matching the historical behaviour for patch passes. fast_jump_threshold: u64::MAX, jump_multiplier: 1, not_ready_retries: 0, bridge_degradation_count: 0, bisecting: false, bisect_on_marginal: true, wedge_count: 0, last_success_at: None, last_error_at: None, last_error_family: None, total_errors: 0, total_reads_ok: 0, zones_entered: 0, jumps_taken: 0, in_damage_zone: 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; // Any successful read clears the wedge-skip counter — the // drive recovered, so further wedges should reset the skip // budget instead of accumulating toward a real abort. self.wedge_count = 0; // A successful read also means the bridge recovered, so the // 15s-cooldown retry budget should be available again for the // next bridge-degradation event. Without this reset the budget // saturates permanently after 5 cumulative events across the // whole pass and later degradations skip the cooldown retry, // needlessly losing data. self.bridge_degradation_count = 0; // Outer-success only: a good single-sector read inside a // bisect doesn't mean we've left the damaged batch. Only an // outer-batch success resets the outer-failure counter. if !self.bisecting { self.consecutive_outer_failures = 0; } self.damage_window.push(true); if self.damage_window.len() > self.damage_window_max { self.damage_window.remove(0); } // Diagnostic state. self.total_reads_ok += 1; self.last_success_at = Some(std::time::Instant::now()); // If we were in a damage zone and accumulated enough good // reads to exit (damage_window now all-good), the zone is // over. Don't reset zones_entered — that's a sweep total. if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 { self.in_damage_zone = false; self.last_error_family = None; // Reset the damage-jump multiplier so the NEXT zone starts // from the base jump distance. Without this the multiplier // stays at whatever the prior zone inflated it to (up to // MAX_JUMP_MULTIPLIER=64), so the next zone's first jump is // 64x oversized and skips recoverable data. The field doc // promises this reset. self.jump_multiplier = 1; } } /// Final per-pass summary suitable for an INFO log at the end of /// `sweep` / `patch`. Caller renders this to a single structured /// log line. pub fn pass_summary(&self) -> PassSummary { PassSummary { total_reads_ok: self.total_reads_ok, total_errors: self.total_errors, zones_entered: self.zones_entered, jumps_taken: self.jumps_taken, } } } /// End-of-pass stats logged at INFO for post-mortem analysis. Lets /// an operator answer "how damaged is this disc?" from a single log /// line per pass. #[derive(Debug, Clone, Copy)] pub struct PassSummary { pub total_reads_ok: u64, pub total_errors: u64, pub zones_entered: u64, pub jumps_taken: u64, } /// 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. /// Pause between a failed read and the next read attempt — applied /// by Pass 1 sweep via `handle_read_error`. Pass N patch uses its own /// `POST_FAILURE_PAUSE_SECS` (see `disc/patch.rs`). /// /// 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; /// Long cooldown applied when a damage zone is first entered (the /// FIRST read failure after a clean run, before the drive has had a /// chance to cycle in retries that push it toward fast-fail). /// /// Empirical: a 2026-05-11 wedge incident showed 7 medium /// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the /// BU40N's firmware into IllegalRequest fast-fail mode permanently. /// Once there, only physical eject + reload clears it. Giving the /// drive 30s of breathing room after the FIRST error in a zone — /// before we start adding more error counts in the firmware's /// internal window — prevents the transition. /// /// Cost on clean discs: zero (first-error path doesn't trigger). /// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc /// that's 2.5 min extra. Trade for never wedging the drive. pub(crate) const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30; /// 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_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; /// Base of the damage-jump distance formula: `jump_sectors = /// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10 /// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers /// 64 MB instead of 16 MB. Empirically the BU40N's damage clusters /// are 100+ MB wide; 16 MB jumps landed inside the cluster and the /// re-read added to the firmware wedge counter. 64 MB → 128 MB /// (after one doubling) clears almost any single-cluster damage in /// 2 jumps. const JUMP_BASE_SECTORS: u64 = 1024; // Firmware-wedge skip policy for Pass 1 sweep // =========================================== // // When the BU40N (or similar drives) hits a physical-damage cluster, // its firmware can transition into a "wedge" state where it returns // HARDWARE_ERROR or ILLEGAL_REQUEST for every subsequent read — // often for many LBAs after the actual bad sector. Once wedged, // recovery requires either a physical eject + reload or a significant // cool-down period; hammering the same LBA only deepens the state. // // Pass 1's pre-fix behavior was to immediately AbortPass on the // first HARDWARE_ERROR / ILLEGAL_REQUEST, killing the rip at // whatever percentage it had reached. That's the wrong call when: // - the damage zone may be small (jumping past it could resume // normal reads), AND // - even if the drive stays wedged, finishing the sweep gives us // an honest mapfile for Pass N to attack later. // // New policy: treat wedge sense codes the same way the damage-window // treats persistent failure — JumpAhead by a large distance with a // cooldown pause. Allow up to WEDGE_ABORT_THRESHOLD consecutive // wedges (no successful read in between) before declaring the drive // truly stuck and surfacing AbortPass to autorip. /// One-gigabyte jump (1024 MiB) on each wedge. Big enough to clear /// almost any single-cluster damage zone we've seen. const WEDGE_JUMP_SECTORS: u64 = 524_288; /// Cooldown pause after each wedge. A wedged drive needs a /// significant cool-down to leave fast-fail; 30 s strikes a balance /// between giving the drive a chance to recover and not stalling the /// rip if the drive is permanently stuck. const WEDGE_PAUSE_SECS: u64 = 30; /// Bail after this many consecutive wedges with no good read in /// between. At 1 GB jumps this lets us scan ~16 GB worth of fully /// wedged area before giving up — generous enough to clear most /// physical-damage clusters, bounded enough to not loop forever on /// a permanently bricked drive. 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; /// Single source of truth for the Pass-N damage-window threshold. /// Both [`ReadCtx::for_patch`] and `disc::patch::compute_damage_skip` /// reference this constant so the two damage-skip paths cannot drift. /// /// 6% means: with a 16-entry sliding window, the damage-skip fires /// once 1 out of 16 recent reads has failed. Pass 1 uses a 12% /// threshold via `damage_threshold_pct` on `for_sweep`; Pass N is /// twice as eager because patch's whole job is to converge on the /// bad sub-zones inside a NonTrimmed range — a faster trigger /// produces tighter convergence in fewer iterations. pub const PATCH_DAMAGE_THRESHOLD_PCT: usize = 6; /// 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; // Outer-failure counter — only OUTER batch failures count toward // the fast-jump trigger. Bisect inner failures are part of // recovering an already-failed batch and don't represent // independent damage signal. if !ctx.bisecting { ctx.consecutive_outer_failures += 1; } // Diagnostic instrumentation — compute timing context BEFORE // mutating the timestamps so the log reflects the gap to the // PREVIOUS error / success, not zero. let now = std::time::Instant::now(); let ms_since_last_error = ctx .last_error_at .map(|t| now.duration_since(t).as_millis() as u64); let ms_since_last_success = ctx .last_success_at .map(|t| now.duration_since(t).as_millis() as u64); let current_family = err .scsi_sense() .map(|s| SenseFamily::from_sense_key(s.sense_key)) .unwrap_or(SenseFamily::Other); // Zone-entry tracking: this is the first error after a clean run // (or the first error of the sweep). Capture the genuine // clean->damaged transition here, BEFORE mutating in_damage_zone, // so the 30s zone-entry cooldown below keys off the real // transition rather than re-deriving it from a counter that the // fast-jump path resets after every jump. let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting; if is_zone_entry_transition { ctx.in_damage_zone = true; ctx.zones_entered += 1; } ctx.total_errors += 1; ctx.last_error_at = Some(now); // Wedge transition: previous error was MEDIUM, this one is // HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's // firmware flipped into fast-fail mode. Distinct WARN so logs // make it unambiguous when the wedge "started." let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family()) && current_family.is_wedge_family(); ctx.last_error_family = Some(current_family); tracing::warn!( target: "freemkv::disc", phase = "read_error", consecutive_failures = ctx.consecutive_failures, consecutive_outer_failures = ctx.consecutive_outer_failures, ms_since_last_error, ms_since_last_success, total_errors = ctx.total_errors, total_reads_ok = ctx.total_reads_ok, batch = ctx.batch, bisecting = ctx.bisecting, wedge_count = ctx.wedge_count, sense_family = ?current_family, 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" ); if is_wedge_transition { tracing::warn!( target: "freemkv::disc", phase = "wedge_transition", errors_in_zone = ctx.total_errors, ms_since_last_success, new_family = ?current_family, "drive entered wedge / fast-fail family (was returning recoverable medium errors before this)" ); } // 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: the SCSI status byte is non-standard — // neither GOOD (0x00), CHECK CONDITION (0x02), nor TRANSPORT // FAILURE (0xFF). The USB bridge firmware returns these bogus // status bytes (e.g. 0x04, 0x05) with empty sense data when it // enters a semi-stuck state preceding a crash. This is keyed on // the status byte alone, NOT on sense_key/ASC/ASCQ — a real // NOT_READY 04/3E bad-sector error arrives as CHECK CONDITION // (0x02) and is handled by the generic NOT_READY branch below. // The bridge 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 error / illegal request — the firmware-wedge family. // The drive transitioned into a fast-fail state where it // 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): jump // WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS, // mark skipped region NonTrimmed. // - Pass N patch (bisect_on_marginal=true): give up on the // current sector (the granular target), pause for cooldown, // let the outer patch loop move to the next NonTrimmed // range. Implemented as a small JumpAhead so the same code // 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). // // Both paths share the WEDGE_ABORT_THRESHOLD budget — only // AbortPass after N consecutive wedges with no successful // read in between. if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST { // Count every wedge, including bisect-inner ones. A wedge is a // firmware fast-fail state regardless of whether we're inside a // bisect; if we did NOT count bisect-inner wedges, a drive that // wedges mid-bisect would burn a 30s WEDGE_PAUSE cooldown per // inner sector and never reach WEDGE_ABORT_THRESHOLD from inside // the bisect — ~16 min of cooldown sleeping on a batch=32 bisect. ctx.wedge_count += 1; if ctx.wedge_count >= WEDGE_ABORT_THRESHOLD { tracing::warn!( target: "freemkv::disc", phase = "wedge_abort", wedge_count = ctx.wedge_count, threshold = WEDGE_ABORT_THRESHOLD, pass = if ctx.bisect_on_marginal { "N" } else { "1" }, "wedge-skip exhausted — drive appears permanently stuck" ); return ReadAction::AbortPass; } let jump_sectors = if ctx.bisect_on_marginal { WEDGE_PASS_N_SKIP_SECTORS } else { WEDGE_JUMP_SECTORS }; tracing::warn!( target: "freemkv::disc", phase = "wedge_skip", pass = if ctx.bisect_on_marginal { "N" } else { "1" }, wedge_count = ctx.wedge_count, jump_sectors, pause_secs = WEDGE_PAUSE_SECS, "wedge detected — skipping ahead and pausing for drive cooldown" ); ctx.jumps_taken += 1; return ReadAction::JumpAhead { sectors: jump_sectors, pause_secs: WEDGE_PAUSE_SECS, }; } // 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. // // Pass 1 sweep sets `bisect_on_marginal=false` to skip this: // its job is "fast and accurate, get the most data in the // shortest time." Pass N is purpose-built to recover // individual sectors with proper recovery semantics, and Pass // 1 grinding through 32-sector bisects costs ~2.5 min per bad // block AND fills the damage window slower than it should. // Whole-block NonTrimmed → SkipBlock → advance → Pass N // revisits. let is_marginal = matches!( sense_key, scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND ); if is_marginal && ctx.batch > 1 && !ctx.bisecting && ctx.bisect_on_marginal { 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() }; // Inter-error pause — wedge prevention via pacing. // // Zone-entry case (first error after a clean run): apply the // long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge // observed 2026-05-11 happened ~7 errors into a damage zone, // each retry adding to the firmware's internal counter. A 30s // pause at zone entry lets the drive's bridge / firmware // counters reset before we issue the next read. // // Subsequent errors in the same zone: the standard 5s pause. // (We've already jumped past the initial damage; further errors // mean we landed in another bad cluster — same pacing applies.) // // Long-streak escalation: same 5s currently; kept as a separate // branch for future tuning. Pass N (bisect_on_marginal=true) // uses the standard pauses — it's running single-sector retries // on already-known-bad LBAs by design. let is_zone_entry = is_zone_entry_transition && !ctx.bisecting && !ctx.bisect_on_marginal; let pause_secs = if is_zone_entry { ZONE_ENTRY_COOLDOWN_SECS } else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD { CONSECUTIVE_FAIL_LONG_PAUSE_SECS } else { FAIL_PAUSE_SECS }; // 7. Damage-jump: too many failures → 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. // // Jump base bumped 2026-05-10 from 256 to 1024 sectors per // multiplier unit (= 64 MB first jump at batch=32, up from // 16 MB). The smaller base routinely landed jumps back inside // damage clusters of 100+ MB, each landing adding to the // firmware's wedge counter. 64 MB initial + 128 MB second + // 256 MB third clears almost any single-cluster damage // pattern we've seen in 2 jumps. // // Two triggers, evaluated in order: // // a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`. // Fires on Pass 1 (threshold=1) so we don't spend ~40 min // grinding to fill a 16-block damage window before the // first jump on a damage zone we entered cleanly. Doesn't // fire on Pass N (threshold=u64::MAX). // // b. **Window-based** — original behaviour: 12% bad in a // sliding window of 16 outer reads. Pass N's only path, // and Pass 1's fallback if the failures are scattered // enough that we don't hit the consecutive threshold. const MAX_JUMP_MULTIPLIER: u64 = 64; let fast_trigger = !ctx.bisecting && ctx.consecutive_outer_failures >= ctx.fast_jump_threshold; let window_trigger = ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct; if fast_trigger || window_trigger { let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER); let sectors = JUMP_BASE_SECTORS .saturating_mul(ctx.batch as u64) .saturating_mul(mult); ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER); // Reset the outer-failure counter so a long damaged region // doesn't keep firing fast-jump every read after the initial // jump fired. The window-based trigger handles further jumps. ctx.consecutive_outer_failures = 0; ctx.jumps_taken += 1; 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, }), } } fn illegal_request_err() -> Error { Error::DiscRead { sector: 100, status: Some(2), sense: Some(ScsiSense { sense_key: scsi::SENSE_KEY_ILLEGAL_REQUEST, asc: 0x24, ascq: 0x00, }), } } #[test] fn pass_n_marginal_with_batch_gt_1_bisects() { let mut ctx = ReadCtx::for_patch(32); let action = handle_read_error(&medium_err(), &mut ctx); assert_eq!(action, ReadAction::Bisect); } #[test] fn pass_1_marginal_jumps_immediately_not_bisecting() { // 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the // FIRST marginal error (fast_jump_threshold=1) rather than // SkipBlock. Retrying the same LBA quickly is what triggers // the BU40N's firmware fast-fail transition; immediate jump // prevents the cascade. Pass N still bisects (its job is // per-sector recovery on already-known-bad LBAs). let mut ctx = ReadCtx::for_sweep(32); let action = handle_read_error(&medium_err(), &mut ctx); match action { ReadAction::JumpAhead { .. } => {} other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"), } } #[test] fn medium_error_with_batch_1_skips() { let mut ctx = ReadCtx::for_patch(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_patch(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 pass_1_jumps_immediately_on_first_outer_failure() { // 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not // 4. Even ONE error triggers a jump because BU40N's firmware // fast-fail mode is sensitive to retry cadence. The wedge // observed 2026-05-11 happened at 7 errors / 6.5s — by then // we were already wedged. Jumping on error #1 means we // physically can't reach the cascade. let mut ctx = ReadCtx::for_sweep(32); let a = handle_read_error(&medium_err(), &mut ctx); assert!( matches!(a, ReadAction::JumpAhead { .. }), "expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}" ); } #[test] fn pass_n_does_not_fast_jump() { // Pass N's whole reason to exist is to grind on bad ranges. // It should NOT bail after 4 consecutive failures the way // Pass 1 does — it bisects and skips with proper recovery. let mut ctx = ReadCtx::for_patch(32); for _ in 0..4 { let a = handle_read_error(&medium_err(), &mut ctx); assert!( !matches!(a, ReadAction::JumpAhead { .. }), "Pass N must not fast-jump; got {a:?}" ); } } #[test] fn outer_success_resets_consecutive_outer_failures() { // With fast_jump_threshold=1 each Pass 1 error fires a jump // and resets `consecutive_outer_failures` to 0 inside the // handler. So we can't accumulate "3" the old way — instead, // verify the counter goes back to 0 after on_success too. let mut ctx = ReadCtx::for_sweep(32); handle_read_error(&medium_err(), &mut ctx); // After fast-jump, consecutive_outer_failures already 0. assert_eq!(ctx.consecutive_outer_failures, 0); // on_success keeps it at 0 (defensive). ctx.bisecting = false; ctx.on_success(); assert_eq!(ctx.consecutive_outer_failures, 0); } #[test] fn bisect_inner_success_does_not_reset_outer_counter() { let mut ctx = ReadCtx::for_patch(32); for _ in 0..3 { handle_read_error(&medium_err(), &mut ctx); } assert_eq!(ctx.consecutive_outer_failures, 3); // A successful inner-sector read during bisect is not the // same as escaping the bad outer batch. ctx.bisecting = true; ctx.on_success(); assert_eq!( ctx.consecutive_outer_failures, 3, "bisect inner success must not reset outer-failure counter" ); } #[test] fn pass_1_hardware_error_jumps_ahead_not_aborts() { // New wedge-skip policy: Pass 1 (bisect_on_marginal=false) // should JumpAhead with a 1 GB skip + cooldown pause instead // of immediately aborting. Aborting on first wedge was the // pre-fix behavior that killed rips at 48% on damaged discs. let mut ctx = ReadCtx::for_sweep(32); let action = handle_read_error(&hardware_err(), &mut ctx); match action { ReadAction::JumpAhead { sectors, pause_secs, } => { assert_eq!(sectors, WEDGE_JUMP_SECTORS); assert_eq!(pause_secs, WEDGE_PAUSE_SECS); } other => panic!("expected JumpAhead, got {other:?}"), } assert_eq!(ctx.wedge_count, 1); } #[test] fn pass_1_hardware_error_aborts_after_threshold() { // After WEDGE_ABORT_THRESHOLD consecutive wedges with no good // read in between, autorip should see a real AbortPass so it // can surface "drive is stuck, power-cycle required" to the // user — rather than looping forever on a permanently bricked // drive. let mut ctx = ReadCtx::for_sweep(32); for i in 0..WEDGE_ABORT_THRESHOLD - 1 { let action = handle_read_error(&hardware_err(), &mut ctx); assert!( matches!(action, ReadAction::JumpAhead { .. }), "iter {i}: expected JumpAhead, got {action:?}" ); } // The Nth wedge crosses the threshold. let action = handle_read_error(&hardware_err(), &mut ctx); assert_eq!(action, ReadAction::AbortPass); } #[test] fn pass_1_good_read_resets_wedge_count() { // A single successful read between wedges must clear the // skip counter — otherwise a disc with a few scattered bad // zones would eventually run out of skip budget even though // the drive was recovering between zones. let mut ctx = ReadCtx::for_sweep(32); for _ in 0..(WEDGE_ABORT_THRESHOLD - 1) { handle_read_error(&hardware_err(), &mut ctx); } assert_eq!(ctx.wedge_count, WEDGE_ABORT_THRESHOLD - 1); ctx.on_success(); assert_eq!(ctx.wedge_count, 0); // After the success, we should still get JumpAhead (not // AbortPass) on the next wedge. let action = handle_read_error(&hardware_err(), &mut ctx); assert!(matches!(action, ReadAction::JumpAhead { .. })); } #[test] fn pass_n_hardware_error_also_skips_not_aborts() { // 2026-05-11 reframe: error handling is centralized, the // wedge is a code-induced state, and the avoidance principle // (skip + pause + continue) applies to Pass N too. Previously // 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 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); } #[test] fn pass_1_illegal_request_also_routes_to_wedge_skip() { // ILLEGAL_REQUEST is the other half of the wedge family: // drive saying "I won't parse your CDB" after entering the // fast-fail state. Same treatment as HARDWARE_ERROR. let mut ctx = ReadCtx::for_sweep(32); let action = handle_read_error(&illegal_request_err(), &mut ctx); assert!(matches!(action, ReadAction::JumpAhead { .. })); } #[test] fn long_failure_streak_extends_pause_on_pass_n() { // Pass N keeps the cooldown behaviour: after many consecutive // failures, pauses extend to give the drive time to recover. // Pass 1 explicitly does NOT pause — see // `pass_1_does_not_pause_on_skip` below. let mut ctx = ReadCtx::for_patch(1); for _ in 0..15 { handle_read_error(&medium_err(), &mut ctx); } 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 pass_1_zone_entry_uses_long_cooldown() { // 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error // (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause + // a 2 s POST_JUMP_EXTRA on top (since we're also jumping). // The long pause prevents the retry cadence that triggers // firmware fast-fail. Subsequent errors in the same zone fall // back to the standard 5 s FAIL_PAUSE_SECS. let mut ctx = ReadCtx::for_sweep(32); let action = handle_read_error(&medium_err(), &mut ctx); match action { ReadAction::JumpAhead { pause_secs, .. } => { assert_eq!( pause_secs, ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS, "first-error pause should be 30 + 2 = 32 s" ); } other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"), } } #[test] fn pass_1_subsequent_in_zone_errors_skip_long_cooldown() { // Regression: the fast-jump path resets consecutive_outer_failures // to 0 after each jump, so the next in-zone error re-increments it // to 1. Zone-entry must key off the genuine clean->damaged // transition (in_damage_zone), not the counter, otherwise every // error in a damaged region pays the 30 s cooldown. let mut ctx = ReadCtx::for_sweep(32); // First error: genuine zone entry, gets the long cooldown. let first = handle_read_error(&medium_err(), &mut ctx); match first { ReadAction::JumpAhead { pause_secs, .. } => assert_eq!( pause_secs, ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS ), other => panic!("expected JumpAhead on first error, got {other:?}"), } // We are now still in the damage zone; the jump reset the outer // counter. A second error must NOT re-arm the 30 s cooldown. assert!(ctx.in_damage_zone); let second = handle_read_error(&medium_err(), &mut ctx); let pause = match second { ReadAction::JumpAhead { pause_secs, .. } => pause_secs, ReadAction::SkipBlock { pause_secs } => pause_secs, other => panic!("expected pausing action, got {other:?}"), }; assert_ne!( pause, ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS, "subsequent in-zone error must not pay the 30 s zone-entry cooldown" ); assert!( pause <= FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS, "subsequent in-zone pause should be the standard fail pause, got {pause}" ); } #[test] fn pass_n_pauses_uniformly_on_failed_read() { // Pass N (bisect_on_marginal=true) is exempt from the // zone-entry long pause — its whole job is to retry single // sectors on already-known-bad LBAs, and the 30 s pause every // single-sector failure would multiply slow recovery // pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS. let mut ctx = ReadCtx::for_patch(1); let action = handle_read_error(&medium_err(), &mut ctx); match action { ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS), ReadAction::JumpAhead { pause_secs, .. } => { assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS) } ReadAction::Bisect => {} other => panic!("expected pausing 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 jump_multiplier_resets_after_damage_zone_exit() { // A zone that doubles the multiplier must not carry the inflated // value into the next zone — otherwise the next zone's first // jump is up to 64x oversized and skips recoverable data. let mut ctx = ReadCtx::for_sweep(32); // First zone: a few errors push jumps and double the multiplier. for _ in 0..4 { handle_read_error(&medium_err(), &mut ctx); } assert!( ctx.jump_multiplier > 1, "expected the multiplier to inflate inside a damage zone" ); // Exit the zone: damage_window_max consecutive good reads. ctx.bisecting = false; for _ in 0..ctx.damage_window_max { ctx.on_success(); } assert!(!ctx.in_damage_zone, "zone should have exited"); assert_eq!( ctx.jump_multiplier, 1, "jump_multiplier must reset to 1 on zone exit" ); } #[test] fn bridge_degradation_count_resets_on_success() { // After a good read the bridge recovered; the 15s-cooldown retry // budget must be available again instead of staying saturated // for the whole pass. let mut ctx = ReadCtx::for_patch(1); ctx.bridge_degradation_count = BRIDGE_DEGRADATION_MAX_RETRIES; ctx.on_success(); assert_eq!(ctx.bridge_degradation_count, 0); } #[test] fn wedge_abort_reachable_during_bisect() { // A drive that wedges mid-bisect must still reach the abort // threshold rather than burning a WEDGE_PAUSE cooldown per inner // sector forever. let mut ctx = ReadCtx::for_patch(32); ctx.bisecting = true; let mut aborted = false; for _ in 0..WEDGE_ABORT_THRESHOLD { if matches!( handle_read_error(&hardware_err(), &mut ctx), ReadAction::AbortPass ) { aborted = true; break; } } assert!( aborted, "wedge abort threshold must be reachable from inside a bisect" ); } #[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!(*ctx.damage_window.last().unwrap()); } // ---------------------------------------------------------------- // Additional hardening: retry-budget boundaries, transport-abort // precedence, and the bounded-jump invariant. These guard against // off-by-one in the retry caps (which would either hammer a wedging // drive or give up a recovery one attempt early) and against an // unbounded jump multiplier skipping the rest of the disc. // ---------------------------------------------------------------- /// NOT_READY check-condition (status 0x02 so it is NOT classified as /// bridge degradation, which keys off non-standard status bytes). /// sense_key=2 with a generic ASC routes to the NOT_READY retry path. fn not_ready_err() -> Error { Error::DiscRead { sector: 100, status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), sense: Some(ScsiSense { sense_key: scsi::SENSE_KEY_NOT_READY, asc: 0x04, ascq: 0x00, }), } } /// Transport failure: SCSI status 0xFF (bridge crash). CLAUDE.md /// "Bad-sector handling": this aborts the copy. fn transport_failure_err() -> Error { Error::DiscRead { sector: 100, status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE), sense: None, } } /// Bridge degradation: a non-standard status byte (0x04 - neither /// GOOD/CHECK/TRANSPORT) with empty sense, per `Error::is_bridge_degradation`. fn bridge_degradation_err() -> Error { Error::DiscRead { sector: 100, status: Some(0x04), sense: None, } } #[test] fn not_ready_retries_capped_at_three_then_falls_through() { // CLAUDE.md "Bad-sector handling" mode 1: NOT READY -> "Pause 3s, // retry up to 3x, then mark NonTrimmed." NOT_READY_MAX_RETRIES=3. // The 1st-3rd NOT_READY must Retry; the 4th must NOT Retry (it // falls through to skip). Pass N (batch=1) so the marginal-bisect // branch is irrelevant. // Mutation that makes this RED: change `ctx.not_ready_retries < // NOT_READY_MAX_RETRIES` to `<=` (retries 4 times) or to `>` // (never retries). let mut ctx = ReadCtx::for_patch(1); for i in 0..NOT_READY_MAX_RETRIES { let a = handle_read_error(¬_ready_err(), &mut ctx); assert!( matches!(a, ReadAction::Retry { .. }), "NOT_READY attempt {i} should Retry, got {a:?}" ); } // Budget exhausted: the next NOT_READY must not Retry. let a = handle_read_error(¬_ready_err(), &mut ctx); assert!( !matches!(a, ReadAction::Retry { .. }), "NOT_READY past the retry cap must fall through, got {a:?}" ); } #[test] fn transport_failure_aborts_even_mid_bisect() { // CLAUDE.md "Bad-sector handling" mode 2: a transport failure // (bridge crash, status 0xFF) aborts the pass so the outer loop // can re-enumerate the bridge. This must hold even while // bisecting and even on Pass N - the wedge-skip/jump paths must // NOT swallow a real transport crash into a JumpAhead. // Mutation that makes this RED: move the transport-failure check // below the HARDWARE/ILLEGAL wedge arm, so a transport failure // that also carried a wedge-family sense would JumpAhead instead. let mut ctx = ReadCtx::for_patch(32); ctx.bisecting = true; assert_eq!( handle_read_error(&transport_failure_err(), &mut ctx), ReadAction::AbortPass ); // And on a fresh Pass 1 context, still AbortPass. let mut ctx1 = ReadCtx::for_sweep(32); assert_eq!( handle_read_error(&transport_failure_err(), &mut ctx1), ReadAction::AbortPass ); } #[test] fn bridge_degradation_retries_to_budget_then_falls_through() { // The bridge-degradation cooldown retry is bounded by // BRIDGE_DEGRADATION_MAX_RETRIES (=5). The first 5 degradation // errors must Retry with the long bridge cooldown; the 6th must // fall through to skip/jump rather than retrying forever and // stalling the pass. // Mutation that makes this RED: change the budget comparison // `ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES` // to `<=` (retries 6 times). let mut ctx = ReadCtx::for_patch(1); for i in 0..BRIDGE_DEGRADATION_MAX_RETRIES { let a = handle_read_error(&bridge_degradation_err(), &mut ctx); match a { ReadAction::Retry { pause_secs } => { assert_eq!( pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS, "bridge retry {i} should use the bridge cooldown" ); } other => panic!("bridge degradation attempt {i} should Retry, got {other:?}"), } } let a = handle_read_error(&bridge_degradation_err(), &mut ctx); assert!( !matches!(a, ReadAction::Retry { .. }), "bridge degradation past the retry budget must fall through, got {a:?}" ); } /// The documented BU40N bad-sector signature: NOT_READY /// (sense_key=2, ASC=0x04, ASCQ=0x3E) delivered as a CHECK CONDITION /// (status 0x02). This is the case the old comment on the bridge /// branch wrongly claimed `is_bridge_degradation` matched. fn not_ready_04_3e_err() -> Error { Error::DiscRead { sector: 100, status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), sense: Some(ScsiSense { sense_key: scsi::SENSE_KEY_NOT_READY, asc: 0x04, ascq: 0x3E, }), } } #[test] fn not_ready_04_3e_does_not_take_bridge_branch() { // Regression guard for the misleading-comment fix: the bridge // branch keys on the *status byte* (non-standard, i.e. not // GOOD/CHECK/TRANSPORT), NOT on the NOT_READY 04/3E sense. A real // 04/3E bad-sector error arrives as CHECK CONDITION (0x02), so // `is_bridge_degradation()` must be false for it, and it must // route to the generic NOT_READY retry (3 s pause) rather than // the bridge cooldown (15 s pause). let err = not_ready_04_3e_err(); assert!( !err.is_bridge_degradation(), "04/3E arrives as CHECK CONDITION (0x02); it is not bridge degradation" ); let mut ctx = ReadCtx::for_patch(1); match handle_read_error(&err, &mut ctx) { ReadAction::Retry { pause_secs } => { assert_eq!( pause_secs, NOT_READY_PAUSE_SECS, "04/3E must use the generic NOT_READY pause, not the bridge cooldown" ); assert_ne!( pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS, "04/3E must not take the bridge-degradation branch" ); // Confirm it really went through the NOT_READY path. assert_eq!(ctx.not_ready_retries, 1); assert_eq!(ctx.bridge_degradation_count, 0); } other => panic!("04/3E should Retry via the NOT_READY path, got {other:?}"), } } #[test] fn jump_multiplier_caps_and_jump_distance_stays_bounded() { // CLAUDE.md damage-jump: multiplier doubles per jump but is // capped at MAX_JUMP_MULTIPLIER=64 (the "4 GiB cap"); a single // jump must never be allowed to grow without bound and skip the // rest of the disc. Drive a long single-sector failure streak on // a sweep ctx with a tiny window so window-trigger jumps fire // repeatedly, and verify the multiplier saturates at 64 and the // emitted jump distance equals JUMP_BASE_SECTORS * batch * 64. // Mutation that makes this RED: remove the // `.min(MAX_JUMP_MULTIPLIER)` on the multiplier doubling, or use // wrapping/non-saturating mul -> distance overshoots or panics. const MAX_JUMP_MULTIPLIER: u64 = 64; let batch: u16 = 32; let mut ctx = ReadCtx::for_sweep(batch); // Small window + 0% threshold so every failure can window-trigger // a jump and keep doubling the multiplier toward the cap. ctx.damage_window_max = 2; ctx.damage_threshold_pct = 0; let mut last_jump_sectors = 0u64; for _ in 0..40 { // Reset bisecting flag defensively; these are outer failures. ctx.bisecting = false; if let ReadAction::JumpAhead { sectors, .. } = handle_read_error(&medium_err(), &mut ctx) { last_jump_sectors = sectors; } assert!( ctx.jump_multiplier <= MAX_JUMP_MULTIPLIER, "jump_multiplier {} exceeded the cap {}", ctx.jump_multiplier, MAX_JUMP_MULTIPLIER ); } // After saturation, the jump distance is exactly base*batch*cap. let expected = JUMP_BASE_SECTORS * batch as u64 * MAX_JUMP_MULTIPLIER; assert_eq!( last_jump_sectors, expected, "saturated jump distance must equal base*batch*64" ); } }