recovery: parameterize the read primitive with ReadParams (speed/FUA/timeout)

Add ReadParams { speed: SpeedPref, fua: bool, timeout: TimeoutPref } and
thread it through read_span so every wedge-safe handler read can request a
spindle speed (SET CD SPEED issued only on change, restored to max when the
handler exits), set the READ(10) FUA bit, and pick the 10s vs 60s timeout.

- SectorSource gains read_sectors_fua (default ignores fua); Drive sets the
  CDB bit, DecryptingSectorSource threads fua to its inner read.
- recovery_read gains a fua param.
- Linear becomes { direction, params }; Bisect/Jump take params. Existing
  tier-0/1 instances keep identical behavior (max speed, no FUA, fast/deep).
- Scoreboard keys on the full-config String name (linear:fwd:max:fast, ...).
- FakeDisc observes speed + FUA + approach so specialist techniques are
  provably exercised in later commits.

cargo test -p libfreemkv green (2193 passed).
This commit is contained in:
Matthew Jackson
2026-07-01 13:50:35 -07:00
parent e94319c099
commit 0c8153304e
5 changed files with 545 additions and 133 deletions
+346 -78
View File
@@ -86,6 +86,102 @@ const WEDGE_ABORT_STREAK: u32 = 16;
/// 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 {
@@ -139,6 +235,13 @@ pub(super) struct HandlerCtx<'a> {
/// 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<'_> {
@@ -196,7 +299,7 @@ fn read_span(
buf: &mut [u8],
pos: u64,
count: u16,
recovery: bool,
params: ReadParams,
) -> ReadHit {
let lba = (pos / SECTOR) as u32;
let bytes = count as usize * SECTOR as usize;
@@ -208,8 +311,24 @@ fn read_span(
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) {
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
@@ -275,7 +394,11 @@ fn read_span(
/// bad read leave it in `bad` and advance (skip-and-move-on); on a transport
/// fault return [`HandlerOutcome::TransportFault`] immediately.
pub(super) trait SectionHandler {
fn name(&self) -> &'static str;
/// 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,
@@ -284,25 +407,43 @@ pub(super) trait SectionHandler {
) -> HandlerOutcome;
}
/// Linear sweep of each bad sub-range. `reverse` walks end→start (the disc
/// sweep overshoots forward, so a NonTrimmed range's good data sits at its tail
/// — reverse hits it first); `!reverse` walks start→end (the front the reverse
/// pass kept dying on). `fast` selects the single-attempt read (`recovery =
/// false`) over the 60 s deep-recovery read. The two bools give backwards /
/// forwards / fast / slow from one handler.
/// 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 reverse: bool,
pub fast: bool,
pub direction: Direction,
pub params: ReadParams,
}
impl SectionHandler for Linear {
fn name(&self) -> &'static str {
match (self.reverse, self.fast) {
(true, true) => "linear:reverse:fast",
(true, false) => "linear:reverse:slow",
(false, true) => "linear:forward:fast",
(false, false) => "linear:forward:slow",
}
fn name(&self) -> String {
format!("linear:{}:{}", self.direction.tag(), self.params.tag())
}
fn recover(
@@ -311,13 +452,13 @@ impl SectionHandler for Linear {
bad: &mut SubRanges,
deadline: Instant,
) -> HandlerOutcome {
let recovery = !self.fast;
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 self.reverse {
if reverse {
snapshot.reverse();
}
@@ -332,13 +473,13 @@ impl SectionHandler for Linear {
return HandlerOutcome::Remaining;
}
let span = batch_bytes.min(rl - done);
let pos = if self.reverse {
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, recovery) {
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
@@ -366,13 +507,17 @@ impl SectionHandler for Linear {
/// 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. Uses fast reads: it LOCATES readable
/// data; deep-recovering the dead sectors is the slow linear handlers' job.
pub(super) struct Bisect;
/// 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) -> &'static str {
"bisect"
fn name(&self) -> String {
format!("bisect:{}", self.params.tag())
}
fn recover(
@@ -401,7 +546,7 @@ impl SectionHandler for Bisect {
}
let end = rp + rl;
let mid = rp + (rl / SECTOR / 2) * SECTOR;
match read_span(ctx, &mut probe, mid, 1, false) {
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.
@@ -416,7 +561,7 @@ impl SectionHandler for Bisect {
}
let span = step.min(end - fwd);
let count = (span / SECTOR) as u16;
match read_span(ctx, &mut buf[..span as usize], fwd, count, false) {
match read_span(ctx, &mut buf[..span as usize], fwd, count, self.params) {
ReadHit::Good => {
bad.remove(fwd, span);
fwd += span;
@@ -448,7 +593,7 @@ impl SectionHandler for Bisect {
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, false) {
match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) {
ReadHit::Good => {
bad.remove(pos, span);
bwd = pos;
@@ -509,11 +654,13 @@ impl SectionHandler for Bisect {
/// 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(super) struct Jump {
pub params: ReadParams,
}
impl SectionHandler for Jump {
fn name(&self) -> &'static str {
"jump"
fn name(&self) -> String {
format!("jump:{}", self.params.tag())
}
fn recover(
@@ -538,7 +685,7 @@ impl SectionHandler for Jump {
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, false) {
match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) {
ReadHit::Good => {
bad.remove(pos, span);
consec_fail = 0;
@@ -583,7 +730,7 @@ impl SectionHandler for Jump {
/// before the ranking narrows to the winners ("try each quick, then prioritise").
#[derive(Default)]
pub(super) struct HandlerScoreboard {
stats: std::collections::HashMap<&'static str, ScoreStat>,
stats: std::collections::HashMap<String, ScoreStat>,
}
#[derive(Default, Clone, Copy)]
@@ -603,8 +750,8 @@ impl HandlerScoreboard {
}
/// Record one attempt: bytes recovered over `elapsed`.
fn record(&mut self, name: &'static str, recovered: u64, elapsed: std::time::Duration) {
let e = self.stats.entry(name).or_default();
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;
@@ -635,7 +782,7 @@ impl HandlerScoreboard {
tracing::info!(
target: "freemkv::disc",
phase = "scorecard",
handler = *name,
handler = name.as_str(),
recovered_mb = s.recovered as f64 / 1_048_576.0,
attempts = s.attempts,
mb_per_s = mbps,
@@ -658,11 +805,12 @@ pub(super) fn run_handlers(
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())));
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)();
@@ -670,13 +818,20 @@ pub(super) fn run_handlers(
// 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(handler.name(), before.saturating_sub(after), elapsed);
scoreboard.record(&name, before.saturating_sub(after), elapsed);
tracing::info!(
target: "freemkv::disc",
phase = "section_recover.handler",
handler = handler.name(),
handler = name.as_str(),
bad_bytes_before = before,
bad_bytes_after = after,
recovered = before.saturating_sub(after),
@@ -721,19 +876,59 @@ mod tests {
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 {
@@ -767,6 +962,32 @@ mod tests {
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() {
@@ -774,6 +995,20 @@ mod tests {
}
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.
@@ -805,6 +1040,13 @@ mod tests {
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 {
@@ -853,13 +1095,14 @@ mod tests {
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 {
reverse: false,
fast: false,
direction: Direction::Forward,
params: ReadParams::deep(),
}
.recover(&mut ctx, &mut bad, deadline);
assert_eq!(
@@ -869,7 +1112,10 @@ mod tests {
);
// Bisect salvages the readable sectors around the dead ones.
ctx.unproductive = 0;
let out = Bisect.recover(&mut ctx, &mut bad, deadline);
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);
@@ -902,12 +1148,13 @@ mod tests {
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 {
reverse: false,
fast: false,
direction: Direction::Forward,
params: ReadParams::deep(),
};
let out = lin.recover(&mut ctx, &mut bad, deadline);
assert_eq!(out, HandlerOutcome::Remaining);
@@ -940,12 +1187,13 @@ mod tests {
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 {
reverse: false,
fast: true,
direction: Direction::Forward,
params: ReadParams::fast(),
};
let out = lin.recover(&mut ctx, &mut bad, deadline);
assert_eq!(out, HandlerOutcome::Remaining);
@@ -976,10 +1224,13 @@ mod tests {
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;
let mut bis = Bisect {
params: ReadParams::fast(),
};
let out = bis.recover(&mut ctx, &mut bad, deadline);
assert_eq!(out, HandlerOutcome::Remaining);
assert!(
@@ -1013,18 +1264,21 @@ mod tests {
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 {
reverse: true,
fast: false,
direction: Direction::Reverse,
params: ReadParams::deep(),
}),
Box::new(Linear {
reverse: false,
fast: false,
direction: Direction::Forward,
params: ReadParams::deep(),
}),
Box::new(Bisect {
params: ReadParams::fast(),
}),
Box::new(Bisect),
];
let deadline_base = (ctx.now)();
let mut scoreboard = HandlerScoreboard::default();
@@ -1055,11 +1309,12 @@ mod tests {
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 {
reverse: false,
fast: true,
direction: Direction::Forward,
params: ReadParams::fast(),
})];
let base = (ctx.now)();
let mut scoreboard = HandlerScoreboard::default();
@@ -1087,13 +1342,14 @@ mod tests {
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 {
reverse: false,
fast: true,
direction: Direction::Forward,
params: ReadParams::fast(),
};
let out = lin.recover(&mut ctx, &mut bad, deadline);
assert_eq!(out, HandlerOutcome::TransportFault);
@@ -1122,21 +1378,26 @@ mod tests {
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),
Box::new(Jump),
Box::new(Linear {
reverse: true,
fast: true,
Box::new(Bisect {
params: ReadParams::fast(),
}),
Box::new(Jump {
params: ReadParams::fast(),
}),
Box::new(Linear {
reverse: false,
fast: true,
direction: Direction::Reverse,
params: ReadParams::fast(),
}),
Box::new(Linear {
direction: Direction::Forward,
params: ReadParams::fast(),
}),
];
let mut scoreboard = HandlerScoreboard::default();
@@ -1181,18 +1442,23 @@ mod tests {
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),
Box::new(Jump),
Box::new(Linear {
reverse: true,
fast: true,
Box::new(Bisect {
params: ReadParams::fast(),
}),
Box::new(Jump {
params: ReadParams::fast(),
}),
Box::new(Linear {
reverse: false,
fast: true,
direction: Direction::Reverse,
params: ReadParams::fast(),
}),
Box::new(Linear {
direction: Direction::Forward,
params: ReadParams::fast(),
}),
];
let mut scoreboard = HandlerScoreboard::default();
@@ -1237,6 +1503,7 @@ mod tests {
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;
@@ -1244,12 +1511,12 @@ mod tests {
// Tier-1 shape: two slow Linear handlers, nothing that reaches 16 alone.
let mut handlers: Vec<Box<dyn SectionHandler>> = vec![
Box::new(Linear {
reverse: true,
fast: false,
direction: Direction::Reverse,
params: ReadParams::deep(),
}),
Box::new(Linear {
reverse: false,
fast: false,
direction: Direction::Forward,
params: ReadParams::deep(),
}),
];
let mut sb = HandlerScoreboard::default();
@@ -1288,12 +1555,13 @@ mod tests {
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 {
reverse: false,
fast: true,
direction: Direction::Forward,
params: ReadParams::fast(),
};
let out = lin.recover(&mut ctx, &mut bad, deadline);
assert_eq!(out, HandlerOutcome::Halted);