//! Handler-chain recovery of a single bad section (Pass-N rework, #55). //! //! The pre-existing patch loop grinds one bad range end-to-end, and when the //! drive wedges it aborts the WHOLE pass — so a dead cluster at the *front* of //! a range starves every later range of any attempt. This module replaces that //! with a chain of time-bounded recovery *handlers*, each a single recovery //! *idea* (read backwards, forwards, fast, slow, bisect...). A coordinator runs //! them in sequence over one section's still-bad sub-ranges: //! //! - each handler gets a hard wall-clock `deadline` and MUST return promptly //! once it passes — no handler ever blocks unbounded (that is the whole //! point); //! - a handler recovers what it can, shrinking the shared [`SubRanges`] via //! [`SubRanges::remove`], and returns [`HandlerOutcome::Remaining`] with the //! rest still bad — the NEXT handler then tries a different idea on what is //! left; //! - whatever is still bad after every handler is the residue the caller //! records as loss (NonTrimmed) before MOVING ON to the next section. //! //! Adding a new recovery idea is one new [`SectionHandler`] impl pushed onto the //! chain — nothing else changes. //! //! This module is deliberately decoupled from the live `patch` machinery //! (`PatchSink`, `PatchItem`, mapfile locks): recovered bytes flow through the //! tiny [`RecoverySink`] trait, and the clock is injected as `&dyn Fn`, so every //! handler and the coordinator are unit-testable against a synthetic //! `SectorSource` with a fake clock — no live drive, no real sleeps. //! //! Wired into `patch_region` (#55): [`run_handlers`] is the live Pass-N recovery //! engine. `SubRanges` stays the shared still-bad set. use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; use super::patch::{SubRanges, recovery_read}; use super::read_error::SenseFamily; use crate::sector::SectorSource; /// One 2048-byte sector. const SECTOR: u64 = 2048; /// Batch size a linear handler reads at once (sectors). A partially-dead batch /// falls back to single-sector reads, so this only trades throughput on clean /// spans against granularity on dead ones. const BATCH_SECTORS: u64 = 32; /// `Jump` handler: after this many consecutive failed batches it jumps to the /// middle of the remaining span (see the handler) to find where readable data /// resumes rather than reading every dead sector. const JUMP_AFTER_FAILS: u32 = 2; /// Early-yield threshold: after this many consecutive reads that recover NOTHING, /// a handler hands the still-bad set to the next handler instead of grinding out /// its whole time budget on a dead zone. The baton comes back — a later handler, /// or the next pass, retries the same sectors from a different angle / after the /// drive state has shifted (recovery is stochastic). This is what turns a /// "60 s of 0 B/s" stall into a fast hand-off. const UNPRODUCTIVE_YIELD: u32 = 4; /// Wedge abort: after this many CONSECUTIVE wedge-family senses (Hardware / /// IllegalRequest — the BU40N firmware's fast-fail state, where it rejects every /// CDB in <100 ms without attempting recovery) the drive is wedged. `read_span` /// escalates the read to `Transport`, which every handler propagates as /// `TransportFault` → the whole pass aborts and the caller spin-cycles the drive /// instead of hammering all remaining sections (which only deepens the wedge). /// Any Good read or non-wedge (medium-error) read resets the streak, so only a /// sustained fast-fail run — never scattered bad sectors on real media — trips /// it. Counted at the PASS level (persisted across sections) so a wedge is caught /// even when every bad sub-range is smaller than the streak. Learned the hard way /// (2026-07-01): the handler chain ground a wedged drive for 28 min at 0 B/s /// because a fast-fail sense was classified as an ordinary bad sector. /// /// Detection latency scales with how much streak one section can build. Tier 0's /// 4 handlers × [`UNPRODUCTIVE_YIELD`] = 16 reads, so a single large wedged /// section trips it within one `run_handlers` call. Tier 1 has only 2 handlers /// (max 8 per section), so a wedge seen only in tier 1 relies on the pass-level /// streak PERSISTING across sections to reach the threshold — regression-tested /// by `wedge_streak_persists_across_sections_for_tier1`. const WEDGE_ABORT_STREAK: u32 = 16; /// A wedge-family failure only counts toward [`WEDGE_ABORT_STREAK`] if it came /// back faster than this — the fast-fail wedge rejects a CDB in <100ms with no /// recovery attempt, whereas a genuine uncorrectable sector on Hardware-error /// media spends real time on ECC recovery before failing. Gating on latency stops /// slow, real damage that happens to report a Hardware sense from false-tripping /// the wedge abort. Generous (500ms) so a slow bus adds margin without admitting /// a true fast-fail. const WEDGE_FASTFAIL_MS: u64 = 500; /// Max read speed sentinel for `SET CD SPEED` (0xFFFF = "as fast as the drive /// will go"). The default for every read; a handler that wants to slow the /// spindle passes [`SpeedPref::Min`] and [`read_span`] restores this on exit. const SPEED_MAX_KBS: u16 = 0xFFFF; /// Min read speed (~DVD 1×; the drive clamps up to its own supported minimum). /// Slower rotation gives the servo more dwell and the ECC engine more /// integration time per sector — the SlowSpin / SpeedSweep lever. The exact /// value only has to be well below max; the drive rounds it to a supported step. const SPEED_MIN_KBS: u16 = 1385; /// Which spindle speed a read requests. `Max` is the streaming default; `Min` /// slows the spindle for marginal-sector recovery (more servo dwell + ECC /// integration). `Mid` is reserved for a future resonance step (SpeedSweep). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SpeedPref { Max, Min, } impl SpeedPref { /// The `SET CD SPEED` value (KB/s) this preference maps to. fn kbs(self) -> u16 { match self { SpeedPref::Max => SPEED_MAX_KBS, SpeedPref::Min => SPEED_MIN_KBS, } } } /// Which SCSI read timeout a read requests. `Fast` is the 10 s single-attempt /// budget (scouting); `Deep` is the 60 s ECC-recovery budget (deep recovery). /// Maps onto `recovery_read`'s `recovery` bool. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TimeoutPref { Fast, Deep, } impl TimeoutPref { /// The `recovery` bool (true = 60 s deep) this timeout maps to. fn recovery(self) -> bool { matches!(self, TimeoutPref::Deep) } } /// The per-read knobs a handler hands to [`read_span`]. A handler is a point in /// the (direction × speed × cache × timeout) space; `ReadParams` carries the /// speed / cache(FUA) / timeout axes (direction is the handler's own walk), so /// the SAME read primitive serves every handler — a new technique is a new /// *parameterisation*, never a bypass of the wedge-safe read path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct ReadParams { pub speed: SpeedPref, pub fua: bool, pub timeout: TimeoutPref, } impl ReadParams { /// Tier-0 scout read: max speed, cache on, 10 s single-attempt. pub(super) fn fast() -> Self { Self { speed: SpeedPref::Max, fua: false, timeout: TimeoutPref::Fast, } } /// Tier-1 deep read: max speed, cache on, 60 s ECC-recovery budget. pub(super) fn deep() -> Self { Self { speed: SpeedPref::Max, fua: false, timeout: TimeoutPref::Deep, } } /// Scorecard tag for the speed / cache / timeout axes, e.g. `min:fua:deep`. /// The handler prepends its own name + direction (`linear:fwd:` + tag). fn tag(&self) -> String { let speed = match self.speed { SpeedPref::Max => "max", SpeedPref::Min => "min", }; let timeout = match self.timeout { TimeoutPref::Fast => "fast", TimeoutPref::Deep => "deep", }; if self.fua { format!("{speed}:fua:{timeout}") } else { format!("{speed}:{timeout}") } } } /// Where a handler left the section after its bounded attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum HandlerOutcome { /// The still-bad set is now empty — the section is fully recovered. The /// coordinator stops the chain. Complete, /// The handler finished or hit its deadline with bad sub-ranges remaining — /// the coordinator moves to the next handler. Remaining, /// The caller's halt token was observed set — abort the chain. Halted, /// A transport-layer fault (bridge wedge / dead bus) — the device never /// answered. The coordinator returns this so the caller can un-wedge /// (spin-cycle) before deciding whether to continue. TransportFault, } /// Receives sectors a handler successfully read back. Kept minimal and /// decoupled from `PatchSink` so handlers are unit-testable in isolation; the /// live wiring maps `recovered` onto the mapfile write + Finished mark. pub(super) trait RecoverySink { /// `buf` holds the plaintext bytes for the byte-range `[pos, pos+buf.len())` /// (all multiples of [`SECTOR`]). fn recovered(&mut self, pos: u64, buf: &[u8]); } /// Everything a handler needs, borrowed for the duration of one `recover` call. /// The `deadline` is passed separately to `recover` (not stored here) so each /// handler invocation is independently bounded. pub(super) struct HandlerCtx<'a> { pub reader: &'a mut dyn SectorSource, pub sink: &'a mut dyn RecoverySink, /// Clock seam — handlers read wall time through this, never `Instant::now()` /// inline, so tests advance a fake clock deterministically. pub now: &'a dyn Fn() -> Instant, pub halt: Option<&'a AtomicBool>, /// Widen mid-unit reads to the aligned AACS unit (see [`recovery_read`]). pub decrypt_is_aacs: bool, /// Progress heartbeat. Handlers call [`HandlerCtx::progress`] frequently (it /// is internally throttled); this pushes a fresh progress snapshot to the /// caller's reporter DURING a handler, not just at range boundaries — so the /// bar and speed move as recovery happens instead of jumping once per /// section. `None` in tests (no reporter). pub tick: Option<&'a mut dyn FnMut()>, /// Consecutive reads that recovered nothing, updated by [`read_span`]. When /// it reaches [`UNPRODUCTIVE_YIELD`] the handler should yield to the next one /// (see [`HandlerCtx::stalled`]). Reset to 0 before each handler runs. pub unproductive: u32, /// Consecutive wedge-family senses (Hardware / IllegalRequest), updated by /// [`read_span`]. At [`WEDGE_ABORT_STREAK`] the drive is wedged and the read /// escalates to `Transport`. Seeded from and read back into the pass-level /// counter so the streak spans sections; a Good or non-wedge read resets it. pub wedge_streak: u32, /// The spindle speed (`SET CD SPEED` KB/s) currently programmed into the /// drive. [`read_span`] issues `SET CD SPEED` only when a read's requested /// speed DIFFERS from this (a `SET CD SPEED` per read would thrash the /// spindle), and [`run_handlers`] restores [`SPEED_MAX_KBS`] after each /// handler. Seeded to max — the caller resets the drive to max before the /// chain runs. pub cur_speed: u16, } impl HandlerCtx<'_> { fn halted(&self) -> bool { self.halt.is_some_and(|h| h.load(Ordering::Relaxed)) } /// The universal "stop this handler now" check every handler loop already /// calls between reads. True when the deadline passed OR the handler has hit /// its early-yield dead streak — folding the yield in here means every /// handler hands the baton off on a dead zone with no per-handler edits. fn past(&self, deadline: Instant) -> bool { self.stalled() || (self.now)() >= deadline } /// True once the handler has read `UNPRODUCTIVE_YIELD` sectors in a row with /// no recovery — its cue to hand the baton to the next handler instead of /// grinding a dead zone for its whole budget. fn stalled(&self) -> bool { self.unproductive >= UNPRODUCTIVE_YIELD } /// Deadline-only stop check (ignores the early-yield stall streak). Used /// inside Bisect's boundary-probing loops, where a short run of failing /// reads is the *expected* way to home in on a dead edge — not a stall. fn timed_out(&self, deadline: Instant) -> bool { (self.now)() >= deadline } /// Emit a progress heartbeat (throttling lives in the tick closure). fn progress(&mut self) { if let Some(t) = self.tick.as_mut() { t(); } } } /// Outcome of one physical read attempt, before the caller decides what to do /// with the still-bad set. enum ReadHit { /// Bytes came back and were handed to the sink. Good, /// A recoverable bad-sector error (media / check-condition). Leave the span /// bad and move on. Bad, /// Transport-layer fault — the bus is gone. Abort now. Transport, } /// Read `count` sectors at byte offset `pos` and, on success, hand them to the /// sink. Does NOT touch the still-bad set — the caller removes recovered spans /// so the read helper stays independent of `SubRanges`. fn read_span( ctx: &mut HandlerCtx, buf: &mut [u8], pos: u64, count: u16, params: ReadParams, ) -> ReadHit { let lba = (pos / SECTOR) as u32; let bytes = count as usize * SECTOR as usize; // Every SubRange enters via `from_section` / `remove`, which keep byte // offsets sector-aligned, so a handler never asks for a sub-sector span. Pin // that invariant: a zero `count` (span < SECTOR) would be a 0-sector read // that silently "recovers" nothing — surface the caller bug in tests. debug_assert!( count >= 1 && pos % SECTOR == 0, "read_span requires a sector-aligned, >=1-sector span (pos={pos}, count={count})" ); // Program the spindle speed ONLY when it changes — a `SET CD SPEED` per read // would thrash the drive. `run_handlers` restores max after the handler. let want_speed = params.speed.kbs(); if want_speed != ctx.cur_speed { ctx.reader.set_speed(want_speed); ctx.cur_speed = want_speed; } let recovery = params.timeout.recovery(); let read_started = (ctx.now)(); let hit = match recovery_read( ctx.reader, ctx.decrypt_is_aacs, lba, count, buf, recovery, params.fua, ) { Ok(_) => { ctx.sink.recovered(pos, &buf[..bytes]); ReadHit::Good } Err(e) if e.is_scsi_transport_failure() => ReadHit::Transport, Err(e) => { // Wedge watch: the drive's fast-fail wedge REJECTS every CDB in <100ms // without attempting recovery, with a Hardware / IllegalRequest sense. // Both signals are required to count toward the streak: // (1) wedge-family sense (Hardware / IllegalRequest), AND // (2) the failure came back FAST (< WEDGE_FASTFAIL_MS). // The latency gate is what keeps a genuine uncorrectable sector on // Hardware-error media from false-tripping the wedge abort: a real // ECC-recovery attempt takes far longer than a fast-fail rejection, so // a SLOW Hardware-error is real damage (resets the streak, retried // next pass), while only the fast rejections — the actual wedge — // accumulate. A medium error or any success below also resets it. let sense_is_wedge = e .scsi_sense() .map(|s| SenseFamily::from_sense_key(s.sense_key).is_wedge_family()) .unwrap_or(false); let elapsed = (ctx.now)().duration_since(read_started); let fast_fail = elapsed.as_millis() < WEDGE_FASTFAIL_MS as u128; if sense_is_wedge && fast_fail { ctx.wedge_streak = ctx.wedge_streak.saturating_add(1); if ctx.wedge_streak >= WEDGE_ABORT_STREAK { ReadHit::Transport } else { ReadHit::Bad } } else { ctx.wedge_streak = 0; ReadHit::Bad } } }; // Track the dead streak for the early-yield hand-off: a recovering read // resets it, a fruitless one advances it toward UNPRODUCTIVE_YIELD. match hit { ReadHit::Good => { ctx.unproductive = 0; ctx.wedge_streak = 0; } // A Bad read is unproductive grinding — advance the yield streak. ReadHit::Bad => ctx.unproductive = ctx.unproductive.saturating_add(1), // A Transport hit aborts the handler immediately (bus fault / wedge // escalation), so it is NOT unproductive grinding — leave the streak // untouched (the counter is never read again after TransportFault, but // keep the semantics honest in case an arm is ever reordered). ReadHit::Transport => {} } // Heartbeat after every read (the tick closure throttles to ~250 ms) so the // UI's bar/speed move DURING a handler, not just when the section finishes. ctx.progress(); hit } /// One recovery idea, given a bounded shot at the section's still-bad set. /// /// Contract: check `ctx.halted()` and `ctx.past(deadline)` between reads and /// return promptly (`Halted` / `Remaining`) — never loop past the deadline. On a /// good read call `ctx.sink.recovered` and [`SubRanges::remove`] the span; on a /// bad read leave it in `bad` and advance (skip-and-move-on); on a transport /// fault return [`HandlerOutcome::TransportFault`] immediately. pub(super) trait SectionHandler { /// Scorecard identity — the FULL config (technique + direction + speed + /// cache + timeout), e.g. `linear:fwd:min:fua:deep`. The scoreboard keys on /// this, so two instances of the same handler at different [`ReadParams`] /// score independently and can flip past each other. fn name(&self) -> String; fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome; } /// Which end a [`Linear`] sweep walks from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Direction { /// start→end (the front the reverse pass kept dying on). Forward, /// end→start (the disc sweep overshoots forward, so a NonTrimmed range's /// good data sits at its tail — reverse hits it first). Reverse, } impl Direction { fn is_reverse(self) -> bool { matches!(self, Direction::Reverse) } fn tag(self) -> &'static str { match self { Direction::Forward => "fwd", Direction::Reverse => "rev", } } } /// Linear batch sweep of each bad sub-range, in `direction`, at `params`. The /// direction × the [`ReadParams`] axes (speed / FUA / timeout) give every /// backwards/forwards × fast/slow × max/min × cache/FUA combination from one /// handler — the tier-0 fast scouts, the tier-1 deep sweeps, and the tier-2 /// SlowSpin / FuaRetry / SlowFua specialists are all just `Linear` at different /// `params`. pub(super) struct Linear { pub direction: Direction, pub params: ReadParams, } impl SectionHandler for Linear { fn name(&self) -> String { format!("linear:{}:{}", self.direction.tag(), self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { let reverse = self.direction.is_reverse(); let batch_bytes = BATCH_SECTORS * SECTOR; let mut buf = vec![0u8; batch_bytes as usize]; // Snapshot the sub-ranges: we mutate `bad` via remove() as we recover, // and iterating the snapshot keeps that from disturbing the walk. let mut snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); if reverse { snapshot.reverse(); } for (rp, rl) in snapshot { // Position within the range, in bytes, walked from whichever end. let mut done = 0u64; while done < rl { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let span = batch_bytes.min(rl - done); let pos = if reverse { rp + (rl - done - span) } else { rp + done }; let count = (span / SECTOR) as u16; match read_span(ctx, &mut buf, pos, count, self.params) { ReadHit::Good => bad.remove(pos, span), // Keep reads at the full batch — no per-sector grind (proven // worse on the BU40N, and it's what stalled a handler on a // dead front). Leave the failed batch bad and advance; the // readable tail past it is reached by the next batch, and // Bisect salvages readable islands inside a dead batch. ReadHit::Bad => {} ReadHit::Transport => return HandlerOutcome::TransportFault, } done += span; } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// Bisect + expand. Probe the middle sector of a bad sub-range; when it reads, /// EXPAND outward from it — forward and backward in full batches — until a read /// fails, recovering the whole readable island around the good centre in large /// reads. The two failing ends become smaller bad sub-ranges, pushed back to be /// bisected again. A dead middle just splits into halves. This shreds one huge /// bad range into precisely-located small dead clusters (a handful of sectors) /// instead of leaving the whole thing bad. `params` is normally fast reads: it /// LOCATES readable data; deep-recovering the dead sectors is the slow linear /// handlers' job. Tier 2 also runs a Bisect at FUA/deep params to shred islands /// under cache-bypass. pub(super) struct Bisect { pub params: ReadParams, } impl SectionHandler for Bisect { fn name(&self) -> String { format!("bisect:{}", self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { let batch = BATCH_SECTORS * SECTOR; let mut buf = vec![0u8; batch as usize]; let mut probe = [0u8; SECTOR as usize]; // Work stack of still-bad chunks. A good probe recovers the readable // island around it and pushes the two (smaller) failing ends; a dead // probe pushes the two halves. Either way the stack shrinks toward small // bad clusters, so it drains in bounded steps. let mut stack: Vec<(u64, u64)> = bad.ranges().to_vec(); while let Some((rp, rl)) = stack.pop() { if rl == 0 { continue; } if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let end = rp + rl; let mid = rp + (rl / SECTOR / 2) * SECTOR; match read_span(ctx, &mut probe, mid, 1, self.params) { ReadHit::Good => { bad.remove(mid, SECTOR); // Expand FORWARD from mid+1 in batches until a read fails. let mut fwd = mid + SECTOR; let mut step = batch; while fwd < end { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.timed_out(deadline) { return HandlerOutcome::Remaining; } let span = step.min(end - fwd); let count = (span / SECTOR) as u16; match read_span(ctx, &mut buf[..span as usize], fwd, count, self.params) { ReadHit::Good => { bad.remove(fwd, span); fwd += span; step = batch; } // Halve at the dead boundary instead of giving up, so // the readable sectors right up to the dead one are // recovered in ~log2(batch) reads (no per-sector grind). ReadHit::Bad => { if span > SECTOR { step = ((span / SECTOR) / 2).max(1) * SECTOR; } else { break; } } ReadHit::Transport => return HandlerOutcome::TransportFault, } } // Expand BACKWARD from mid toward rp until a read fails. let mut bwd = mid; let mut step = batch; while bwd > rp { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.timed_out(deadline) { return HandlerOutcome::Remaining; } let span = step.min(bwd - rp); let pos = bwd - span; let count = (span / SECTOR) as u16; match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { ReadHit::Good => { bad.remove(pos, span); bwd = pos; step = batch; } ReadHit::Bad => { if span > SECTOR { step = ((span / SECTOR) / 2).max(1) * SECTOR; } else { break; } } ReadHit::Transport => return HandlerOutcome::TransportFault, } } // Locating this readable island was productive work; the // failed reads that pinned its dead edges are boundary probes, // not a stall. Clear the streak so the re-bisect (and the next // handler) start fresh. ctx.unproductive = 0; // The two failing ends stay bad — bisect them again to pin // the exact dead sectors. if bwd > rp { stack.push((rp, bwd - rp)); } if fwd < end { stack.push((fwd, end - fwd)); } } ReadHit::Bad => { // Dead middle: split and keep hunting for a good centre. if mid > rp { stack.push((rp, mid - rp)); } let right = mid + SECTOR; if right < end { stack.push((right, end - right)); } } ReadHit::Transport => return HandlerOutcome::TransportFault, } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// Blow through a LARGE dead run fast. Reads forward in batches; after /// [`JUMP_AFTER_FAILS`] consecutive failed batches it SKIPS AHEAD an escalating /// distance (1 MiB → 2 → 4 … capped at [`JUMP_CAP_BYTES`]), leaving the skipped /// span bad, to find where readable data RESUMES — mirroring the Pass-1 /// damage-jump. A later handler / `Bisect` pins the exact good/bad boundary the /// jump stepped over. Uses fast reads (this is a scout, not a deep-recovery /// pass). Without it a linear walk pays one up-to-10 s read per dead batch /// across the whole run, so a deadline-bounded pass never reaches readable data /// buried behind a big dead front (exactly the 192 MB range on Dune). pub(super) struct Jump { pub params: ReadParams, } impl SectionHandler for Jump { fn name(&self) -> String { format!("jump:{}", self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { let batch = BATCH_SECTORS * SECTOR; let mut buf = vec![0u8; batch as usize]; let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); for (rp, rl) in snapshot { let mut off = 0u64; let mut consec_fail = 0u32; while off < rl { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let span = batch.min(rl - off); let pos = rp + off; let count = (span / SECTOR) as u16; match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { ReadHit::Good => { bad.remove(pos, span); consec_fail = 0; off += span; } ReadHit::Bad => { consec_fail += 1; if consec_fail >= JUMP_AFTER_FAILS { // Sustained dead run — jump to the MIDDLE of the // remaining span (never overshoot the range). Halving // adapts to any size: a big dead run is crossed in // ~log2 jumps, and a small range lands mid-range // instead of being skipped past entirely (the 8 MiB // fixed jump used to leap clean over a <8 MiB range and // miss readable data in its middle). The skipped span // stays bad for Bisect to reclaim. let remaining = rl - off; let step = ((remaining / 2) / SECTOR).max(1) * SECTOR; off += step; consec_fail = 0; } else { off += span; } } ReadHit::Transport => return HandlerOutcome::TransportFault, } } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// SpeedSweep — per residual sector, try Max→Min spindle speeds until one reads. /// *Failure mode:* speed resonance — the best speed is NOT always the slowest; /// some marginal sectors hit a read-channel sweet spot at a higher speed, so a /// per-sector search beats committing to min. Distinct from SlowSpin (a `Linear` /// pinned to min): this searches. `params` carries the FUA / timeout axes; the /// speed axis is what it sweeps. Single-sector, so it runs on the true residual. pub(super) struct SpeedSweep { pub params: ReadParams, } impl SectionHandler for SpeedSweep { fn name(&self) -> String { format!("speedsweep:{}", self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { // Fastest first — resonance means the sweet spot isn't always the // slowest, and the fast read costs least when it happens to work. const SWEEP: [SpeedPref; 2] = [SpeedPref::Max, SpeedPref::Min]; let mut probe = [0u8; SECTOR as usize]; let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); for (rp, rl) in snapshot { let mut off = 0u64; while off < rl { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let pos = rp + off; for speed in SWEEP { let params = ReadParams { speed, fua: self.params.fua, timeout: self.params.timeout, }; match read_span(ctx, &mut probe, pos, 1, params) { ReadHit::Good => { bad.remove(pos, SECTOR); break; } // This speed didn't read it; try the next one. ReadHit::Bad => continue, ReadHit::Transport => return HandlerOutcome::TransportFault, } } off += SECTOR; } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// CachePrime — before reading a residual island, read the good run immediately /// PRECEDING it to lock the drive's PLL/servo, then read the marginal sectors /// while the channel is warm. *Failure mode:* a boundary sector the drive can't /// lock onto from a cold seek (ddrescue's "back up, run forward"). The priming /// read is a normal wedge-safe [`read_span`]; if the preceding sector is itself /// bad the prime just fails and the island is read cold (no worse than Linear). pub(super) struct CachePrime { pub params: ReadParams, } impl SectionHandler for CachePrime { fn name(&self) -> String { format!("cacheprime:{}", self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { let batch_bytes = BATCH_SECTORS * SECTOR; let mut buf = vec![0u8; batch_bytes as usize]; let mut prime = [0u8; SECTOR as usize]; let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); for (rp, rl) in snapshot { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } // Prime: read the good sector immediately before the island to lock // the servo/PLL, so the boundary sector is read warm, not cold-seeked. if rp >= SECTOR { // A bad/absent preceding sector just means no prime — read cold. if let ReadHit::Transport = read_span(ctx, &mut prime, rp - SECTOR, 1, self.params) { return HandlerOutcome::TransportFault; } } // Now walk the island forward while warm; contiguous reads keep the // channel primed across it (each sector's predecessor was just read). let mut done = 0u64; while done < rl { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let span = batch_bytes.min(rl - done); let pos = rp + done; let count = (span / SECTOR) as u16; match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { ReadHit::Good => bad.remove(pos, span), ReadHit::Bad => {} ReadHit::Transport => return HandlerOutcome::TransportFault, } done += span; } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// Oscillate — read each residual sector by ALTERNATING approach: forward-into /// (prime from the sector below, then read) and reverse-into (prime from the /// sector above, then read). *Failure mode:* direction-dependent tracking — a /// sector's servo lock differs by approach direction, so it may read one way but /// not the other. Combines the two Linear directions into a per-sector /// alternation on the true residual. `params` carries the speed / FUA / timeout /// axes; the alternation is the direction axis. pub(super) struct Oscillate { pub params: ReadParams, } impl SectionHandler for Oscillate { fn name(&self) -> String { format!("oscillate:{}", self.params.tag()) } fn recover( &mut self, ctx: &mut HandlerCtx, bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { let mut probe = [0u8; SECTOR as usize]; let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); for (rp, rl) in snapshot { let mut off = 0u64; while off < rl { if ctx.halted() { return HandlerOutcome::Halted; } if ctx.past(deadline) { return HandlerOutcome::Remaining; } let pos = rp + off; // Forward-into: prime from the sector below, then read the target // (the head approaches from a lower LBA). if pos >= SECTOR { if let ReadHit::Transport = read_span(ctx, &mut probe, pos - SECTOR, 1, self.params) { return HandlerOutcome::TransportFault; } } let mut recovered = match read_span(ctx, &mut probe, pos, 1, self.params) { ReadHit::Good => { bad.remove(pos, SECTOR); true } ReadHit::Transport => return HandlerOutcome::TransportFault, ReadHit::Bad => false, }; // Reverse-into: prime from the sector above, then read the target // (the head approaches from a higher LBA). if !recovered { if let ReadHit::Transport = read_span(ctx, &mut probe, pos + SECTOR, 1, self.params) { return HandlerOutcome::TransportFault; } recovered = match read_span(ctx, &mut probe, pos, 1, self.params) { ReadHit::Good => { bad.remove(pos, SECTOR); true } ReadHit::Transport => return HandlerOutcome::TransportFault, ReadHit::Bad => false, }; } let _ = recovered; off += SECTOR; } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } } /// EWMA smoothing factor for the decayed recovery rate. Each new attempt is /// weighted `α`, the running average `1-α`, so a handler's score tracks its /// RECENT performance and forgets its distant past at a rate set by `α`. Higher /// = more reactive (leadership flips sooner); lower = steadier. 0.5 halves the /// weight of the previous score on every attempt — reactive enough that a proven /// early winner whose territory is exhausted decays out of the lead within a few /// barren attempts, while a late-starting specialist climbs as it earns. const SCORE_EWMA_ALPHA: f64 = 0.5; /// Per-rip handler scorecard. Grades each handler by a DECAYED recovery rate (an /// EWMA of bytes-recovered-per-second, [`SCORE_EWMA_ALPHA`]) so the coordinator /// runs whoever is winning *now* FIRST on later sections. The residual shrinks /// and hardens mid-pass, so the best technique CHANGES: the fast scouts clean /// the range-fronts, then the leftovers are exactly the marginal sectors where /// the specialists win — and the ranking must FLIP. A cumulative rate froze the /// early winner in the lead forever; the EWMA re-prices continuously — a handler /// that stops earning decays down, one that starts earning climbs. Ephemeral — /// reset each rip, no persistence. A handler not yet tried ranks top /// (`u64::MAX`) so every handler is calibrated once before the ranking narrows. #[derive(Default)] pub(super) struct HandlerScoreboard { stats: std::collections::HashMap, } #[derive(Default, Clone, Copy)] struct ScoreStat { /// Decayed recovery rate (bytes/second), the ranking signal. `None` until /// the first attempt that spent measurable time (a zero-elapsed call proves /// no rate). Seeded to the first timed sample, then EWMA'd. ewma_rate: Option, // Cumulative totals — for the operator log line only, NOT for ranking. recovered: u64, nanos: u128, attempts: u64, } impl HandlerScoreboard { /// Fold one timed sample (bytes/second) into the decayed rate. fn decay(prev: Option, sample: f64) -> f64 { match prev { None => sample, Some(p) => SCORE_EWMA_ALPHA * sample + (1.0 - SCORE_EWMA_ALPHA) * p, } } /// Record one attempt: `recovered` bytes over `elapsed`. A timed attempt /// (elapsed > 0) decays a fresh bytes/second sample into `ewma_rate` — a /// barren attempt (recovered = 0) contributes a 0 sample that decays the /// score DOWN, which is exactly what lets an exhausted early winner lose its /// lead. A zero-elapsed call (handler yielded before any timed read) /// contributes no rate sample. fn record(&mut self, name: &str, recovered: u64, elapsed: std::time::Duration) { let e = self.stats.entry(name.to_string()).or_default(); e.recovered = e.recovered.saturating_add(recovered); e.nanos = e.nanos.saturating_add(elapsed.as_nanos()); e.attempts += 1; let secs = elapsed.as_secs_f64(); if secs > 0.0 { let sample = recovered as f64 / secs; e.ewma_rate = Some(Self::decay(e.ewma_rate, sample)); } } /// Ranking key (higher runs earlier). Untried → top, so it gets calibrated. fn rank(&self, name: &str) -> u64 { match self.stats.get(name) { // Never attempted → top, so every handler is calibrated once. None => u64::MAX, // Attempted but no timed sample yet — e.g. it returned `Halted` on // its first check or did zero reads. It proved nothing, so rank it at // the BOTTOM (0), not the top: otherwise a called-but-idle handler // perpetually crowds out proven performers. Some(s) => match s.ewma_rate { None => 0, Some(r) => r.max(0.0).min(u64::MAX as f64) as u64, }, } } /// Emit the scorecard to the log so the operator can see, per rip, which /// handler is pulling the weight and which is a dud on this drive/disc. pub(super) fn log(&self) { let mut rows: Vec<_> = self.stats.iter().collect(); // Rank by the decayed rate (the live signal), highest first. rows.sort_by_key(|(name, _)| std::cmp::Reverse(self.rank(name))); for (name, s) in rows { let mbps = s.recovered as f64 / (s.nanos as f64 / 1e9).max(1e-9) / 1_048_576.0; tracing::info!( target: "freemkv::disc", phase = "scorecard", handler = name.as_str(), recovered_mb = s.recovered as f64 / 1_048_576.0, attempts = s.attempts, decayed_bytes_per_s = s.ewma_rate.unwrap_or(0.0), mb_per_s = mbps, "handler scorecard (this rip)" ); } } } /// Run the handler chain over one section's still-bad set, ordered best-first by /// the rip scorecard. Never-hang guarantee: each handler is deadline-bounded and /// the loop always drains to `Complete`/`Remaining`. `Halted` / `TransportFault` /// short-circuit so the caller can abort or un-wedge. Each attempt is scored so /// later sections run the winners first. pub(super) fn run_handlers( ctx: &mut HandlerCtx, handlers: &mut [Box], bad: &mut SubRanges, scoreboard: &mut HandlerScoreboard, section_deadline_for: impl Fn(&SubRanges) -> Instant, ) -> HandlerOutcome { // Best-first by recovery rate so far; untried handlers rank top (calibrate). handlers.sort_by_key(|h| std::cmp::Reverse(scoreboard.rank(&h.name()))); for handler in handlers.iter_mut() { if bad.is_empty() { return HandlerOutcome::Complete; } let name = handler.name(); let before = bad.total_len(); let deadline = section_deadline_for(bad); let started = (ctx.now)(); // Fresh dead-streak budget per handler: each gets its own chance before // the early-yield trips. ctx.unproductive = 0; let outcome = handler.recover(ctx, bad, deadline); // A handler may have dropped the spindle (SlowSpin / SpeedSweep) or set // FUA; restore max speed before the next handler so it starts from the // streaming default (FUA is a per-read param, so nothing to unwind there). if ctx.cur_speed != SPEED_MAX_KBS { ctx.reader.set_speed(SPEED_MAX_KBS); ctx.cur_speed = SPEED_MAX_KBS; } let elapsed = (ctx.now)().duration_since(started); let after = bad.total_len(); scoreboard.record(&name, before.saturating_sub(after), elapsed); tracing::info!( target: "freemkv::disc", phase = "section_recover.handler", handler = name.as_str(), bad_bytes_before = before, bad_bytes_after = after, recovered = before.saturating_sub(after), outcome = ?outcome, "handler finished; remaining bad bytes carry to the next handler" ); match outcome { HandlerOutcome::Complete => return HandlerOutcome::Complete, HandlerOutcome::Remaining => continue, HandlerOutcome::Halted => return HandlerOutcome::Halted, HandlerOutcome::TransportFault => return HandlerOutcome::TransportFault, } } if bad.is_empty() { HandlerOutcome::Complete } else { HandlerOutcome::Remaining } } #[cfg(test)] mod tests { use super::*; use crate::error::{Error, Result}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::time::Duration; /// Synthetic disc: a set of dead LBAs, an optional transport-fault LBA, and /// an injectable per-read time cost that advances a shared fake clock. No /// real sleeps — the clock is an `AtomicU64` of nanoseconds so the reader /// (which owns `&mut self`) and the `now` closure share one timeline while /// staying `Send`. struct FakeDisc { dead: HashSet, /// LBAs that return a wedge-family sense (IllegalRequest) — the drive /// fast-fail state, distinct from an ordinary dead sector (which carries /// no sense). Used to exercise wedge detection. wedge: HashSet, transport_at: Option, clock_nanos: Arc, per_read: Duration, reads: Arc, // ── Physical failure-mode models (all default-empty) ───────────────── // Each conditional sector reads ONLY when the drive state the handler // manipulates (speed / FUA / approach direction) matches — so a test // that recovers it PROVES the technique was actually exercised, not that // a plain read happened to work. /// Current `SET CD SPEED` value (updated by `set_speed`); max at build. speed: u16, /// Reads ONLY at min speed (fails at max) → SlowSpin / SpeedSweep. slow_only: HashSet, /// Reads ONLY on the Nth *physical* (FUA) attempt; a cached (non-FUA) /// re-read never gets it → FuaRetry. Maps LBA → attempts required. fua_need: HashMap, /// Physical (FUA) attempts observed so far, per LBA. fua_seen: HashMap, /// Reads ONLY when approached from ABOVE (the previous physical access /// was a higher LBA) → Oscillate's reverse-into pass. dir_reverse_only: HashSet, /// Reads ONLY when the immediately-preceding sector was the previous /// physical access (PLL/servo primed) → CachePrime. prime_only: HashSet, /// LBA of the last sector physically accessed (success or fail) — the /// approach-direction / priming signal the specialists drive. last_lba: Option, } impl SectorSource for FakeDisc { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool, ) -> Result { // Bulk (non-FUA) path. self.read_sectors_fua(lba, count, buf, recovery, false) } fn read_sectors_fua( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, fua: bool, ) -> Result { self.reads.fetch_add(1, Ordering::Relaxed); self.clock_nanos .fetch_add(self.per_read.as_nanos() as u64, Ordering::Relaxed); // The head moved across this span; record where it ended so the NEXT // read can see the approach direction / priming (both success and // failure move the head). let prev = self.last_lba; self.last_lba = Some(lba + count as u32 - 1); if let Some(t) = self.transport_at { if (lba..lba + count as u32).contains(&t) { return Err(Error::ScsiError { opcode: crate::scsi::SCSI_READ_10, status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, sense: None, }); } } for l in lba..lba + count as u32 { if self.wedge.contains(&l) { // Fast-fail wedge sense: ILLEGAL REQUEST / INVALID FIELD IN // CDB (0x05/0x24), the real BU40N wedge signature. Non- // transport status so it isn't caught as a bus fault, but // carries sense so the wedge classifier sees it. return Err(Error::ScsiError { opcode: crate::scsi::SCSI_READ_10, status: 0x02, sense: Some(crate::scsi::ScsiSense { sense_key: crate::scsi::SENSE_KEY_ILLEGAL_REQUEST, asc: 0x24, ascq: 0x00, }), }); } if self.dead.contains(&l) { // Non-transport bad-sector error (CHECK CONDITION, 0x02). return Err(Error::DiscRead { sector: l as u64, status: Some(0x02), sense: None, }); } // Marginal sector: reads only at min spindle speed. if self.slow_only.contains(&l) && self.speed != SPEED_MIN_KBS { return Err(bad_sector(l)); } // Stochastic sector: needs N physical (FUA) reads; a cached read // can never land it (cache masks the good re-read). if let Some(need) = self.fua_need.get(&l).copied() { if !fua { return Err(bad_sector(l)); } let seen = self.fua_seen.entry(l).or_insert(0); *seen += 1; if *seen < need { return Err(bad_sector(l)); } } // Direction-dependent tracking: reads only when approached from // above (previous physical access was a higher LBA). if self.dir_reverse_only.contains(&l) && prev.is_none_or(|p| p <= l) { return Err(bad_sector(l)); } // Boundary sector: reads only when the preceding sector was the // previous physical access (servo primed). if self.prime_only.contains(&l) && prev != l.checked_sub(1) { return Err(bad_sector(l)); } } let bytes = count as usize * SECTOR as usize; for (i, b) in buf[..bytes].iter_mut().enumerate() { *b = (lba as usize + i / SECTOR as usize) as u8; } Ok(bytes) } fn set_speed(&mut self, kbs: u16) { self.speed = kbs; } } /// The ordinary recoverable bad-sector error (CHECK CONDITION, no sense) the /// conditional failure modes return when their precondition isn't met. fn bad_sector(l: u32) -> Error { Error::DiscRead { sector: l as u64, status: Some(0x02), sense: None, } } /// Records every recovered span so a test can assert which sectors came back. #[derive(Default)] struct RecordSink { got: HashMap, // pos -> bytes } impl RecoverySink for RecordSink { fn recovered(&mut self, pos: u64, buf: &[u8]) { self.got.insert(pos, buf.len()); } } /// A fake clock plus a disc sharing its timeline. struct Harness { clock_nanos: Arc, reads: Arc, base: Instant, } impl Harness { fn build(dead: &[u32], transport_at: Option, per_read: Duration) -> (Self, FakeDisc) { let clock_nanos = Arc::new(AtomicU64::new(0)); let reads = Arc::new(AtomicU64::new(0)); let disc = FakeDisc { dead: dead.iter().copied().collect(), wedge: HashSet::new(), transport_at, clock_nanos: clock_nanos.clone(), per_read, reads: reads.clone(), speed: SPEED_MAX_KBS, slow_only: HashSet::new(), fua_need: HashMap::new(), fua_seen: HashMap::new(), dir_reverse_only: HashSet::new(), prime_only: HashSet::new(), last_lba: None, }; ( Harness { clock_nanos, reads, base: Instant::now(), }, disc, ) } fn now_fn(&self) -> impl Fn() -> Instant { let c = self.clock_nanos.clone(); let base = self.base; move || base + Duration::from_nanos(c.load(Ordering::Relaxed)) } fn read_count(&self) -> u64 { self.reads.load(Ordering::Relaxed) } } fn lba(pos: u64) -> u32 { (pos / SECTOR) as u32 } #[test] fn chain_recovers_readable_in_a_dead_batch_leaving_only_dead() { // Section [0, 10 sectors). Dead: sectors 3 and 7. Linear reads it as one // batch, which fails (it contains dead sectors), so Linear leaves the // whole batch bad — NO per-sector grind (that's the point of dropping // narrow_batch). Bisect then probes/expands and salvages the 8 readable // sectors, leaving ONLY 3 and 7. Proves the Linear→Bisect division of // labour: Linear sweeps at batch granularity, Bisect finds the islands. let dead = [3u32, 7u32]; let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 10 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); // Linear leaves the failed 10-sector batch whole. Linear { direction: Direction::Forward, params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!( bad.total_len(), 10 * SECTOR, "linear leaves the dead batch whole" ); // Bisect salvages the readable sectors around the dead ones. ctx.unproductive = 0; let out = Bisect { params: ReadParams::fast(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); // Exactly the two dead sectors remain. assert_eq!(bad.total_len(), 2 * SECTOR); for &(p, l) in bad.ranges() { assert_eq!(l, SECTOR); assert!( lba(p) == 3 || lba(p) == 7, "unexpected bad sector {}", lba(p) ); } } #[test] fn linear_forward_front_dead_still_reaches_readable_tail() { // THE bug: front dead, tail readable. Section [0, 40 sectors). First 32 // (one whole batch) are dead; the tail 8 are readable. Forward linear // must recover the tail — it does not hang at the front. let dead: Vec = (0..32).collect(); let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 40 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { direction: Direction::Forward, params: ReadParams::deep(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); // The 32 dead front sectors remain; the 8-sector readable tail is // recovered as one clean batch (one sink span covering 8 sectors). assert_eq!(bad.total_len(), 32 * SECTOR); assert_eq!(sink.got.len(), 1, "tail is one clean 8-sector batch"); assert_eq!( sink.got.get(&(32 * SECTOR)).copied(), Some(8 * SECTOR as usize), "tail batch not recovered" ); } #[test] fn linear_honors_deadline_and_returns_promptly() { // 1000 clean sectors, but each read costs 1 s and the budget is 3 s. The // handler must stop after ~3 reads, NOT drain all 1000 — proving bounded // wall-clock even on a huge range. let (h, disc) = Harness::build(&[], None, Duration::from_secs(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(3); let mut lin = Linear { direction: Direction::Forward, params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); // Batch=32 clean sectors per read: a handful of reads at most, not 1000. assert!( h.read_count() <= 5, "ran {} reads, expected <=5", h.read_count() ); assert!(bad.total_len() > 0, "should not have drained the range"); } #[test] fn bisect_finds_good_middle_in_mostly_dead_range() { // 9 sectors, only the middle (sector 4) readable. Bisect probes the // middle first, recovers it, and the recursive halves' middles are dead. let dead: Vec = (0..9).filter(|&l| l != 4).collect(); let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 9 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut bis = Bisect { params: ReadParams::fast(), }; let out = bis.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert!( sink.got.contains_key(&(4 * SECTOR)), "good middle not found" ); assert_eq!( bad.total_len(), 8 * SECTOR, "only the middle should recover" ); } #[test] fn coordinator_reverse_then_forward_makes_progress_direction_matters() { // Two dead sectors at opposite ends won't both be cleared by one // direction alone in this contrived fixture, but the CHAIN clears every // readable sector regardless of order. Prove the coordinator runs // handler after handler and drains the readable set. let dead = [0u32, 15u32]; // ends of a 16-sector section let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 16 * SECTOR); let mut handlers: Vec> = vec![ Box::new(Linear { direction: Direction::Reverse, params: ReadParams::deep(), }), Box::new(Linear { direction: Direction::Forward, params: ReadParams::deep(), }), Box::new(Bisect { params: ReadParams::fast(), }), ]; let deadline_base = (ctx.now)(); let mut scoreboard = HandlerScoreboard::default(); let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { deadline_base + Duration::from_secs(30) }); assert_eq!(out, HandlerOutcome::Remaining); // 14 readable sectors recovered, only the two dead ends remain. assert_eq!(bad.total_len(), 2 * SECTOR); for &(p, _) in bad.ranges() { assert!(lba(p) == 0 || lba(p) == 15); } } #[test] fn coordinator_completes_when_no_dead_sectors() { // A clean section drains to Complete on the first handler. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 64 * SECTOR); let mut handlers: Vec> = vec![Box::new(Linear { direction: Direction::Forward, params: ReadParams::fast(), })]; let base = (ctx.now)(); let mut scoreboard = HandlerScoreboard::default(); let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { base + Duration::from_secs(30) }); assert_eq!(out, HandlerOutcome::Complete); assert!(bad.is_empty()); } #[test] fn transport_fault_short_circuits() { // A transport fault mid-range returns TransportFault immediately so the // caller can un-wedge the drive. let (h, disc) = Harness::build(&[], Some(5), Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; // Single-sector batches so the transport LBA is hit directly. let mut bad = SubRanges::from_section(0, 8 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { direction: Direction::Forward, params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::TransportFault); } #[test] fn wedged_drive_aborts_fast_instead_of_grinding() { // Regression for the 2026-07-01 incident: a fast-fail wedge (drive // returns ILLEGAL REQUEST on every CDB) was classified as an ordinary // bad sector, so the chain ground a dead drive for 28 min at 0 B/s. // Now a sustained run of wedge-family senses escalates to TransportFault // so the pass aborts and the caller spin-cycles. A big section (1000 // sectors) that is ENTIRELY wedged must bail after ~WEDGE_ABORT_STREAK // reads, not after reading the whole thing. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.wedge = (0..1000u32).collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); // The full tier-0 chain: the wedge streak persists across handlers (only // `unproductive` resets per handler), so it reaches the abort threshold // even though each handler yields early on the dead streak. let mut handlers: Vec> = vec![ Box::new(Bisect { params: ReadParams::fast(), }), Box::new(Jump { params: ReadParams::fast(), }), Box::new(Linear { direction: Direction::Reverse, params: ReadParams::fast(), }), Box::new(Linear { direction: Direction::Forward, params: ReadParams::fast(), }), ]; let mut scoreboard = HandlerScoreboard::default(); let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { (h.now_fn())() + Duration::from_secs(60) }); assert_eq!( out, HandlerOutcome::TransportFault, "a wholly-wedged section must escalate to TransportFault" ); // The whole point: it bailed after a short streak, not after grinding all // 1000 sectors. Generous bound (handlers read in batches) but far below // the section size. assert!( h.read_count() < 100, "wedge must abort fast; did {} reads on a 1000-sector wedged section", h.read_count() ); } #[test] fn slow_hardware_error_media_does_not_false_trip_wedge_abort() { // A genuine uncorrectable sector on Hardware-error media reports a // wedge-FAMILY sense (IllegalRequest here) but comes back SLOW — the drive // spent real time on ECC recovery before failing. That must NOT count // toward the wedge abort (which targets the drive's <100ms fast-fail // rejection). Each read here costs 600ms (> WEDGE_FASTFAIL_MS), so even a // wholly-"wedge-sense" section never escalates to TransportFault — it just // leaves the residue bad for the next pass, exactly like ordinary damage. let (h, disc) = Harness::build(&[], None, Duration::from_millis(600)); let mut disc = disc; disc.wedge = (0..1000u32).collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); let mut handlers: Vec> = vec![ Box::new(Bisect { params: ReadParams::fast(), }), Box::new(Jump { params: ReadParams::fast(), }), Box::new(Linear { direction: Direction::Reverse, params: ReadParams::fast(), }), Box::new(Linear { direction: Direction::Forward, params: ReadParams::fast(), }), ]; let mut scoreboard = HandlerScoreboard::default(); // Long per-handler deadline so the deadline (not the wedge) is never the // reason a handler stops — we're isolating the wedge-escalation decision. let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { (h.now_fn())() + Duration::from_secs(3600) }); assert_ne!( out, HandlerOutcome::TransportFault, "slow (ECC-recovery) Hardware-error reads must NOT trip the fast-fail wedge abort" ); assert_eq!( ctx.wedge_streak, 0, "slow wedge-family reads must not accumulate the streak" ); } #[test] fn wedge_streak_persists_across_sections_for_tier1() { // Tier 1 is only TWO handlers, so one wedged section builds at most // 2 × UNPRODUCTIVE_YIELD = 8 streak — below WEDGE_ABORT_STREAK (16). The // wedge is caught only because the pass-level wedge_streak PERSISTS across // sections. Simulate what PatchCtx does: carry wedge_streak in/out of each // per-section run_handlers call, and assert the abort lands on a LATER // section, not the first. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.wedge = (0..4000u32).collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut carried = 0u32; // the pass-level wedge_streak let mut caught_on: Option = None; for section in 0..6usize { let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: carried, cur_speed: SPEED_MAX_KBS, }; // Distinct 100-sector section per iteration, all within the wedge set. let pos = (section as u64) * 100 * SECTOR; let mut bad = SubRanges::from_section(pos, 100 * SECTOR); // Tier-1 shape: two slow Linear handlers, nothing that reaches 16 alone. let mut handlers: Vec> = vec![ Box::new(Linear { direction: Direction::Reverse, params: ReadParams::deep(), }), Box::new(Linear { direction: Direction::Forward, params: ReadParams::deep(), }), ]; let mut sb = HandlerScoreboard::default(); let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut sb, |_| { (h.now_fn())() + Duration::from_secs(60) }); carried = ctx.wedge_streak; if out == HandlerOutcome::TransportFault { caught_on = Some(section); break; } } let caught = caught_on.expect("a two-handler tier must still catch the wedge"); assert!( caught >= 1, "one 2-handler section can't reach the streak alone; the wedge must be \ caught via cross-section accumulation, not on section 0 (caught on {caught})" ); } #[test] fn halt_token_returns_promptly() { // Halt set before the call: the handler returns Halted on its first // check, having done no reads. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; let mut sink = RecordSink::default(); let now = h.now_fn(); let halt = AtomicBool::new(true); let mut ctx = HandlerCtx { reader: &mut disc, sink: &mut sink, now: &now, halt: Some(&halt), decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 100 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { direction: Direction::Forward, params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Halted); assert_eq!(h.read_count(), 0, "halt must precede any read"); } #[test] fn scorecard_decays_so_a_late_starter_overtakes_an_early_winner() { // The whole point of the DECAYED rate: the residual hardens mid-pass, so // leadership must hand off. A cumulative rate would freeze "early" in the // lead forever; the EWMA re-prices continuously. let mut sb = HandlerScoreboard::default(); let dt = Duration::from_secs(1); // Round 1 — "early" cleans the easy bulk; "late" finds nothing yet. sb.record("early", 1_000_000, dt); sb.record("late", 0, dt); assert!( sb.rank("early") > sb.rank("late"), "early must lead once it's the only one recovering" ); // The bulk is gone. Now "early"'s technique no longer fits the hardened // residual (barren attempts) while "late"'s specialist starts winning. for _ in 0..4 { sb.record("early", 0, dt); sb.record("late", 1_000_000, dt); } assert!( sb.rank("late") > sb.rank("early"), "a handler that stops earning must LOSE its lead to a late starter \ (late={}, early={})", sb.rank("late"), sb.rank("early") ); // Calibration invariants preserved: an untried handler still ranks top // (one-shot calibration), and a handler attempted with no timed read // (zero elapsed) ranks bottom rather than crowding out proven performers. assert_eq!(sb.rank("never_tried"), u64::MAX, "untried → top"); sb.record("idle", 0, Duration::ZERO); assert_eq!(sb.rank("idle"), 0, "attempted-but-zero-time → bottom"); } /// Build a ctx over `disc` with the fake clock — the common per-test setup. macro_rules! ctx { ($h:expr, $disc:expr, $sink:expr, $now:expr) => { HandlerCtx { reader: &mut $disc, sink: &mut $sink, now: &$now, halt: None, decrypt_is_aacs: false, tick: None, unproductive: 0, wedge_streak: 0, cur_speed: SPEED_MAX_KBS, } }; } fn min_deep() -> ReadParams { ReadParams { speed: SpeedPref::Min, fua: false, timeout: TimeoutPref::Deep, } } #[test] fn slow_spin_recovers_a_min_speed_only_sector_that_max_linear_misses() { // Sector 5 reads ONLY at min spindle speed (weak signal / servo drift): // a max-speed deep Linear leaves it bad; SlowSpin (Linear pinned to min) // recovers it. Single-sector residual so Linear reads it directly. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.slow_only = [5u32].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(5 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); // Max-speed deep Linear cannot read a min-only sector. let out = Linear { direction: Direction::Forward, params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!( bad.total_len(), SECTOR, "max-speed linear must leave it bad" ); // SlowSpin = Linear at min speed — recovers it. let out = Linear { direction: Direction::Forward, params: min_deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Complete); assert!(bad.is_empty(), "SlowSpin must recover the min-only sector"); assert_eq!(sink.got.get(&(5 * SECTOR)).copied(), Some(SECTOR as usize)); } #[test] fn speed_sweep_recovers_a_min_speed_only_sector() { // SpeedSweep sweeps Max→Min per sector, so it reaches the min-only // sector 7 that a max-only read never gets — proving the sweep actually // drops the spindle when the fast read fails. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.slow_only = [7u32].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(7 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); let out = SpeedSweep { params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Complete); assert!(bad.is_empty(), "SpeedSweep must reach min and recover it"); assert_eq!(sink.got.get(&(7 * SECTOR)).copied(), Some(SECTOR as usize)); // It tried the fast (max) read first, then the min read — 2 reads. assert_eq!(h.read_count(), 2, "swept max then min"); } fn max_fua_deep() -> ReadParams { ReadParams { speed: SpeedPref::Max, fua: true, timeout: TimeoutPref::Deep, } } #[test] fn fua_retry_recovers_a_stochastic_sector_a_cached_read_keeps_missing() { // Sector 9 lands only on its 2nd PHYSICAL (FUA) read; a cached (non-FUA) // re-read never gets it (the cache masks the good re-read). FuaRetry = // the Linear fwd + rev + Bisect group at FUA params: across its reads the // sector gets enough physical attempts to land, where cached reads can't. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.fua_need = [(9u32, 2u32)].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(9 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); // Cached (non-FUA) reads keep missing — twice, and the sector stays bad // (a cached miss never even counts as a physical attempt). for _ in 0..2 { let out = Linear { direction: Direction::Forward, params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!(bad.total_len(), SECTOR, "cached read must keep missing"); } // FuaRetry group: Linear fwd (FUA attempt 1) leaves it, Linear rev (FUA // attempt 2) lands it. let mut handlers: Vec> = vec![ Box::new(Linear { direction: Direction::Forward, params: max_fua_deep(), }), Box::new(Linear { direction: Direction::Reverse, params: max_fua_deep(), }), Box::new(Bisect { params: max_fua_deep(), }), ]; let mut sb = HandlerScoreboard::default(); let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut sb, |_| deadline); assert_eq!(out, HandlerOutcome::Complete); assert!(bad.is_empty(), "FuaRetry must land the stochastic sector"); assert_eq!(sink.got.get(&(9 * SECTOR)).copied(), Some(SECTOR as usize)); } #[test] fn slow_fua_recovers_the_hardest_sector_needing_both_min_and_fua() { // Sector 11 is the hardest case: it reads ONLY at min speed AND ONLY on a // physical (FUA) read. Neither lever alone works — SlowFua (Linear at // {min, fua, deep}) is the combination that recovers it. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.slow_only = [11u32].into_iter().collect(); disc.fua_need = [(11u32, 1u32)].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(11 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); // FUA but max speed → wrong speed, fails. let out = Linear { direction: Direction::Forward, params: max_fua_deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!( bad.total_len(), SECTOR, "max+fua must miss the min-only sector" ); // Min speed but cached (no FUA) → no physical attempt, fails. let out = Linear { direction: Direction::Forward, params: min_deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!( bad.total_len(), SECTOR, "min+cached must miss the FUA-only sector" ); // Both levers: min speed AND FUA → recovers. let out = Linear { direction: Direction::Forward, params: ReadParams { speed: SpeedPref::Min, fua: true, timeout: TimeoutPref::Deep, }, } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Complete); assert!( bad.is_empty(), "SlowFua (min+fua) must recover the hardest sector" ); assert_eq!(sink.got.get(&(11 * SECTOR)).copied(), Some(SECTOR as usize)); } #[test] fn oscillate_recovers_a_direction_dependent_sector_forward_linear_misses() { // Sector 13 reads ONLY when approached from ABOVE (reverse-into). A plain // forward Linear (approaches from below) misses it; Oscillate's // reverse-into pass recovers it. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.dir_reverse_only = [13u32].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(13 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); // Forward Linear approaches from below → misses the reverse-only sector. let out = Linear { direction: Direction::Forward, params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!(bad.total_len(), SECTOR, "forward linear must miss it"); // Oscillate tries forward-into then reverse-into → the reverse-into pass // approaches from above and lands it. let out = Oscillate { params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Complete); assert!( bad.is_empty(), "Oscillate must recover the direction-dependent sector" ); assert_eq!(sink.got.get(&(13 * SECTOR)).copied(), Some(SECTOR as usize)); } #[test] fn cache_prime_recovers_a_boundary_sector_that_needs_a_warm_channel() { // Sector 15 reads ONLY when the immediately-preceding sector was just // read (servo primed) — a boundary the drive can't lock onto from a cold // seek. A cold Linear read of the island misses it; CachePrime reads the // preceding good sector first, then lands it warm. let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); let mut disc = disc; disc.prime_only = [15u32].into_iter().collect(); let mut sink = RecordSink::default(); let now = h.now_fn(); let mut ctx = ctx!(h, disc, sink, now); let mut bad = SubRanges::from_section(15 * SECTOR, SECTOR); let deadline = (ctx.now)() + Duration::from_secs(30); // Cold Linear read (never touches the preceding sector) → misses it. let out = Linear { direction: Direction::Forward, params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert_eq!( bad.total_len(), SECTOR, "cold linear must miss the boundary sector" ); // CachePrime reads the preceding run first → warm channel → lands it. let out = CachePrime { params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Complete); assert!( bad.is_empty(), "CachePrime must recover the primed boundary sector" ); assert_eq!(sink.got.get(&(15 * SECTOR)).copied(), Some(SECTOR as usize)); } }