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).
2135 lines
88 KiB
Rust
2135 lines
88 KiB
Rust
//! 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));
|
||
}
|
||
}
|