Files
libfreemkv/src/disc/section_recover.rs
T
Matthew Jackson c27009d443 recovery: wire tier 2 marginal specialists into the Pass-N chain
Add a third breadth-first tier (PATCH_TIERS 2->3) that runs the marginal
specialists on the hardened residual tiers 0-1 leave: SlowSpin (Linear
fwd+rev @ min), FuaRetry (Linear fwd+rev+Bisect @ FUA), SlowFua (Linear @
min+FUA), CachePrime, Oscillate (@ max and @ min), SpeedSweep. Every read is
a wedge-safe read_span, so they inherit wedge-abort / unproductive-yield /
deadline for free. All are new configs, so the EWMA scorecard calibrates each
once then ranks by decayed rate — a specialist that doesn't fit self-
deprioritises. Tiers 0-1 (fast scouts, slow-deep) are unchanged; this is
purely additive. Also switch the scorecard log sort to sort_by_key.

cargo test -p libfreemkv green (2200 passed).
2026-07-01 13:50:35 -07:00

2135 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, ScoreStat>,
}
#[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<f64>,
// 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<f64>, 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<dyn SectionHandler>],
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<u32>,
/// 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<u32>,
transport_at: Option<u32>,
clock_nanos: Arc<AtomicU64>,
per_read: Duration,
reads: Arc<AtomicU64>,
// ── 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<u32>,
/// 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<u32, u32>,
/// Physical (FUA) attempts observed so far, per LBA.
fua_seen: HashMap<u32, u32>,
/// Reads ONLY when approached from ABOVE (the previous physical access
/// was a higher LBA) → Oscillate's reverse-into pass.
dir_reverse_only: HashSet<u32>,
/// Reads ONLY when the immediately-preceding sector was the previous
/// physical access (PLL/servo primed) → CachePrime.
prime_only: HashSet<u32>,
/// LBA of the last sector physically accessed (success or fail) — the
/// approach-direction / priming signal the specialists drive.
last_lba: Option<u32>,
}
impl SectorSource for FakeDisc {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
// 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<usize> {
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<u64, usize>, // 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<AtomicU64>,
reads: Arc<AtomicU64>,
base: Instant,
}
impl Harness {
fn build(dead: &[u32], transport_at: Option<u32>, 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<u32> = (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<u32> = (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<Box<dyn SectionHandler>> = 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<Box<dyn SectionHandler>> = 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<Box<dyn SectionHandler>> = 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<Box<dyn SectionHandler>> = 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<usize> = 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<Box<dyn SectionHandler>> = 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<Box<dyn SectionHandler>> = 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));
}
}