disc/patch: leave failed reads NonTrimmed mid-multipass, not Unreadable

User design call after watching Pass 2 mark ~20 KB as "Cosmetic"
(permanently Unreadable) after just 10 retries within a single pass:
"i think it's good or maybe until all passes are done. then it's
gone."

That contradicts what the multi-pass design promises a user. The
project goal in CLAUDE.md is "recover 100% of readable data from any
optical disc, automatically." Marking sectors Unreadable after a
SINGLE pass's per-range retry budget gives up on sectors that
subsequent passes might recover — drive reads are stochastic, the
sector that fails 10 times in Pass 2 may succeed on attempt 1 in
Pass 3 after temperature / bus state / prior-read patterns shift.
The patch.rs doc comment already noted ~36% of patch-marked
Unreadable sectors turned out to be readable in re-rip experiments.

Three sites in `Disc::patch` were emitting `PatchItem::Unreadable`
mid-pass:
  - backtrack hit damage (line ~2659)
  - all-retries-exhausted on a single LBA (line ~2846)
  - redundant second mark after the wedge-suspicion log (line ~2970)

All three now emit `PatchItem::NonTrimmed` instead. Failed bytes
stay "maybe" (NonTrimmed) so the next pass gets another shot. The
per-range skip-limit (10) and per-pass wedge-threshold (50) still
bound time-per-pass; they just no longer turn the bytes terminal.

The `PatchItem::Unreadable` variant stays in the enum (with
#[allow(dead_code)]) because the orchestrator-side end-of-recovery
promotion will use it: autorip, after the final retry pass
completes, scans the mapfile and promotes still-NonTrimmed →
Unreadable. That promotion lands in a follow-up commit on the
autorip side — separable from this libfreemkv change.

Loss accounting unchanged: `bytes_pending + bytes_unreadable` is
the "lost or pending" total that `abort_on_lost_secs` consults
(disc/mod.rs:1327). Moving bytes from one bucket to the other
mid-pass doesn't affect whether the rip would abort; it only
affects display (UI shows "Maybe" vs "Cosmetic") and whether
subsequent passes retry the bytes (the actual fix).

Test update: `test_pass_progress_separates_unreadable_from_pending`
was renamed to `test_pass2_leaves_failed_reads_as_pending_not_unreadable`
and rewritten to assert the new invariant — Pass 2 leaves all
failed bytes as bytes_pending (no mid-pass Unreadable promotion).
Original assertions were checking the pre-design-call behavior.

Precommit (cargo +1.86 fmt + clippy + test) green.
This commit is contained in:
2026-05-10 18:47:05 -07:00
parent c4c901f073
commit 5ddbf43ab2
3 changed files with 79 additions and 32 deletions
+29 -11
View File
@@ -2654,9 +2654,17 @@ impl Disc {
}
Err(_err) => {
blocks_read_failed += 1;
// Leave NonTrimmed (not Unreadable) so a later
// pass gets another shot. Per the project goal
// — "recover 100% of readable data" — and the
// multi-pass design's promise: bytes stay
// Good-or-Maybe across passes; promotion to
// Unreadable is the orchestrator's job at
// end-of-recovery (final retry pass complete).
// Reference: 2026-05-11 design call.
send_or_abort(
&pipe,
PatchItem::Unreadable {
PatchItem::NonTrimmed {
pos: bt_pos,
len: span,
},
@@ -2840,10 +2848,21 @@ impl Disc {
}
}
// All retries exhausted - mark as Unreadable
// All retries exhausted IN THIS PASS — leave NonTrimmed
// so a subsequent pass gets another shot. Bytes stay
// Good-or-Maybe across passes; only the orchestrator
// (autorip) promotes still-NonTrimmed → Unreadable
// after the FINAL retry pass completes. Reference:
// 2026-05-11 design call ("good or maybe until all
// passes are done, then it's gone"). Pre-fix the
// patch loop marked Unreadable here, which gave up
// on sectors that a later pass might have recovered
// (drive reads are stochastic — same sector that
// fails 10x in Pass 2 might succeed on attempt 1 in
// Pass 3 after the drive state has shifted).
send_or_abort(
&pipe,
PatchItem::Unreadable {
PatchItem::NonTrimmed {
pos,
len: block_bytes,
},
@@ -2960,25 +2979,24 @@ impl Disc {
}
}
// Redundant second Unreadable mark — preserved
// bit-for-bit from the pre-split loop (`record`
// is idempotent for same-status replacement of
// the same range). Routes through the consumer
// like every other state change.
// Pair with the earlier NonTrimmed dispatch — same
// bytes, same state. Pre-2026-05-11 this was a
// second Unreadable mark; now it's NonTrimmed for
// the same reason: cross-pass retry survival.
send_or_abort(
&pipe,
PatchItem::Unreadable {
PatchItem::NonTrimmed {
pos,
len: block_bytes,
},
)?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_mapfile_record_unreadable",
phase = "patch_mapfile_record_nontrimmed",
pos,
block_bytes,
consecutive_failures,
"Mapfile record dispatched as Unreadable"
"Mapfile record dispatched as NonTrimmed (retry next pass)"
);
let pause_secs = if err.is_bridge_degradation() {