v0.13.22 — replace bisect-on-fail with hysteresis Block↔Single

The v0.13.21 bisect-on-fail recovery was correct (100% of recoverable
sectors picked up) but slow on dense damage clusters. Live test on
Dune 2 v0.13.21 burned ~30 s per damaged 60-block — paying a ~5 s
kernel ABORT/timeout at every level of a log₂(60) ≈ 6 deep DFS, on
the failing branch each time.

Replaced with a two-state hysteresis machine in Disc::copy:

  Block(batch):
    read(batch) ok    → write, advance, stay Block
    read(batch) fail  → switch to Single, retry SAME range at bpt=1

  Single:
    read(1) ok    → write, consecutive_good++
                    if consecutive_good >= BPT1_EXIT_THRESHOLD:
                      switch to Block, reset counter
    read(1) fail  → mark NonTrimmed, consecutive_good = 0

BPT1_EXIT_THRESHOLD = 10_000 sectors (= 20 MB clean run). Calibrated
from the 2026-04-26 BU40N empirical probe data; tunable.

Per-block math on a damaged 60-block with 1 truly bad sector:

  Bisect      (v0.13.21): ~30 s  (5 s × 6 levels)
  Hysteresis  (v0.13.22): ~10 s  (5 s bpt=batch fail
                                  + 59 × 1 ms good
                                  + 1 × 5 s bad)

Inside a damaged cluster spanning many 60-blocks the win compounds:
hysteresis pays the bpt=batch fail cost ONCE on entry, then stays at
bpt=1 across the cluster; bisection re-paid it every 60 sectors. For
Dune 2's ~1248-sector boundary cluster that's ~21 fewer 5-sec
kernel timeouts ≈ 100 s saved per pass.

Telemetry: new phase=mode_change trace event with from, to, lba, and
consecutive_good. Replaces v0.13.21's phase=bisect. Worklist DFS is
gone — single iterative for s in 0..count on the failure path.

Test rename, same fixture and same 100% recovery expectation:
  test_disc_copy_bisect_recovers_via_single_sector_reads
  → test_disc_copy_hysteresis_recovers_via_single_sector_reads

Also adds DamageSeverity (Clean / Cosmetic / Moderate / Serious) +
classify_damage(bad_sectors, lost_ms), re-exported from libfreemkv,
so applications can render structured severity instead of formatting
their own from raw counters.
This commit is contained in:
MattJackson
2026-04-26 17:27:57 -07:00
parent 424d3cd4f2
commit ebffc6eb88
5 changed files with 301 additions and 83 deletions
+13 -9
View File
@@ -538,16 +538,18 @@ fn test_disc_copy_halts_promptly_on_failing_reader() {
);
}
// ── 8. Bisect-on-fail recovers data the drive can read individually ──────
// ── 8. Hysteresis recovers data the drive can read individually ──────────
//
// Empirically observed on the LG BU40N: in damaged regions the drive fails
// multi-sector READ commands but reads each sector cleanly when asked one
// at a time. Disc::copy's bisect-on-fail must recover those sectors
// without bailing or skip-forwarding past clean territory.
// at a time. Disc::copy's hysteresis state machine (0.13.22, replaces the
// 0.13.21 bisect-on-fail) drops to bpt=1 on the first multi-sector failure
// and stays there until BPT1_EXIT_THRESHOLD consecutive good single-sector
// reads, then returns to bpt=batch.
//
// Fixture: a reader that returns Err for any read with count > 1, and Ok
// for count == 1. With bisect-on-fail, we must observe a 100 % bytes_good
// outcome — every sector recovered via the bisection.
// for count == 1. The full disc must recover via the bpt=1 path with
// 100 % bytes_good outcome.
struct BlockSizeFailingReader {
capacity: u32,
@@ -578,11 +580,13 @@ impl SectorReader for BlockSizeFailingReader {
}
#[test]
fn test_disc_copy_bisect_recovers_via_single_sector_reads() {
fn test_disc_copy_hysteresis_recovers_via_single_sector_reads() {
// 256 sectors = 0.5 MB. Reader fails any multi-sector read but
// succeeds on bpt=1. Bisection must descend log2(batch) levels and
// recover every sector. This is the BU40N bad-zone pattern in
// miniature.
// succeeds on bpt=1. The hysteresis path must drop to Single mode on
// the first multi-sector failure and recover every sector at bpt=1.
// Stays in Single mode until BPT1_EXIT_THRESHOLD reached (10 000
// sectors); since this disc is only 256 sectors we never re-enter
// Block mode, which is fine — every sector still recovers.
let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;