Files
libfreemkv/src/disc/patch.rs
T
Matthew Jackson 88e58bfc95 Decrypt is keymap-only: sweep/patch/extract, no AACS trial-decrypt
Every AACS decrypt now goes through the resolved key map (decrypt_sectors_
mapped): the map keys each content unit up front and a missing key fails at
resolve time. The old trial-decrypt path — try each held key per unit, keep
the first-tried plaintext on a miss — is gone; decrypt_sectors_impl's AACS
arm now fails loud (reaching it means a reader was built without its map,
which would silently apply a wrong key). CSS (self-descramble) and the clear
no-op path are unchanged.

Disc::sweep and Disc::patch resolve a whole-disc key map up front for a
decrypting pass (the fetch secures any missing CPS-unit key, fail-loud) and
decrypt via the map — clear nav/filesystem sectors are in no range and pass
through, so the separate content-range gate and the reactive per-unit
key-fetch recovery are no longer needed. extract_tree keys every unit with
the base Unit Key through the map (its encrypted-flag gate skips clear
files). Multipass sweeps stay --raw.

Removes the obsolete non-mapped-AACS trial/gate/recovery tests (the mapped
path and resolve fail-loud are tested directly).
2026-07-23 13:24:36 -07:00

1667 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Producer / consumer split for `Disc::patch`.
//!
//! Background: pre-0.18 patch ran strictly serial — single-sector
//! recovery read → seek + write recovered bytes → mapfile.record →
//! next iteration. The drive sat idle while the previous block's
//! recovered bytes were committed. On a damaged disc with many bad
//! sectors that adds up: per-sector write + mapfile.record costs a
//! handful of milliseconds each, which the drive could be using to
//! issue the next per-sector retry.
//!
//! This module decouples them. A consumer thread owns the
//! [`crate::io::WritebackFile`] (the ISO file) and the
//! [`super::mapfile::Mapfile`]. The producer thread (`Disc::patch`)
//! keeps the [`crate::sector::SectorSource`], the wedge / damage-window
//! state, the per-range watchdog, decrypt — so what enters the channel
//! is already-clean cleartext bytes (or an "Unreadable" terminal mark).
//!
//! Producer and consumer run concurrently; the channel uses
//! [`crate::io::pipeline::WRITE_THROUGH_DEPTH`] (=1) so back-pressure
//! kicks in immediately. We want the drive's per-sector retry budget
//! to stay in lockstep with the writer — sweep's `DEFAULT_PIPELINE_DEPTH`
//! (4) would let several sectors of recovered bytes queue up between
//! the producer's retry decisions and the writer, and patch's recovery
//! loop reads stats (`bytes_good`, range progress) inline to drive its
//! skip / wedge decisions. WRITE_THROUGH_DEPTH gives "read N+1 while
//! writing N", no further pipelining — exactly the model the producer
//! logic was written against.
//!
//! Correctness invariants preserved:
//! - Mapfile is single-writer (consumer-only). No locking on it.
//! - All recovery state (damage window, consecutive_failures, skip
//! escalation, range watchdog) stays on the producer thread.
//! - `set_speed` calls happen on the producer thread (same thread that
//! owns the `SectorSource`). No new SCSI concurrency.
//! - Per-iteration ordering of file-write → mapfile-record is kept
//! intact in the consumer (write before record), so the on-disk
//! invariant "mapfile only marks Finished what the file has received"
//! survives a crash mid-pass.
//! - The BU40N+Initio bridge wedge concern is unchanged: only one
//! SCSI command in flight at a time, error-path timing identical,
//! no new retry logic. The threading primitive only overlaps the
//! *write* with the *next read*; the per-sector single-shot read
//! budget that the bridge wedge concern was originally about is
//! untouched.
//!
//! Per-range watchdog (`range_sectors × SECONDS_PER_SECTOR`, capped at `RANGE_BUDGET_CAP_SECS`)
//! checks `bytes_good` for forward progress. With work in flight on
//! the consumer, the producer would otherwise see stale values; the
//! sink publishes a [`SharedPatchState`] snapshot after every record
//! so the producer's stall guards observe consumer side-effects with
//! at most one item of lag (which is fine — the watchdog uses minute-
//! scale budgets, not single-record latency).
use std::io::{Seek, SeekFrom, Write};
use std::sync::{Arc, Mutex};
use crate::error::{Error, Result};
use crate::io::pipeline::{Flow, Sink};
use super::mapfile::{self, MapStats, Mapfile, SectorStatus};
use super::section_recover::{
Bisect, CachePrime, Direction, HandlerCtx, HandlerOutcome, HandlerScoreboard, Jump, Linear,
Oscillate, ReadParams, RecoverySink, SectionHandler, SpeedPref, SpeedSweep, TimeoutPref,
run_handlers,
};
/// Wall-clock budget one recovery handler gets on a section before the chain
/// moves to the next idea (#55). Tight and bounded — this is what guarantees a
/// pass never hangs: a handler that can't shrink the still-bad set within this
/// window returns, the next handler tries a different idea, and whatever is
/// still bad becomes NonTrimmed residue so recovery advances to the next range.
/// Replaces the old 1800 s/range + 3600 s/pass grind budgets on the live path.
const PER_HANDLER_BUDGET_SECS: u64 = 60;
/// Minimum interval between progress heartbeats pushed from inside a handler, so
/// the UI's bar/speed move continuously during a long section without flooding
/// the reporter (see the tick closure in `recover_section`).
const PROGRESS_TICK_MS: u64 = 250;
/// Bridges the decoupled [`RecoverySink`] a handler writes to onto the live
/// patch consumer pipe: each recovered span becomes a [`PatchItem::Recovered`]
/// the consumer thread seeks + writes + records `Finished`. `recovered` can't
/// return an error (the trait is infallible so handlers stay simple), so a
/// pipe-closed / halt error is captured in `err` and surfaced by the caller
/// after `run_handlers` returns.
struct PatchRecoverySink<'a> {
pipe: &'a Pipeline<PatchItem, PatchSummary>,
err: Option<Error>,
}
impl RecoverySink for PatchRecoverySink<'_> {
fn recovered(&mut self, pos: u64, buf: &[u8]) {
if self.err.is_some() {
return;
}
if let Err(e) = send_or_abort(
self.pipe,
PatchItem::Recovered {
pos,
buf: buf.to_vec(),
},
) {
self.err = Some(e);
}
}
}
/// Item the producer hands to the patch consumer. One per per-sector
/// recovery decision.
pub(super) enum PatchItem {
/// Sector / small batch successfully recovered (and decrypted on the
/// producer side if `opts.decrypt` was set). Consumer seeks to
/// `pos`, writes `buf`, records the range as `Finished`.
Recovered { pos: u64, buf: Vec<u8> },
/// Producer exhausted retries on `[pos, pos+len)`. Consumer records
/// the range as `Unreadable`. No file write — the existing zero-fill
/// from sweep is preserved in place.
///
/// Currently unused by `Disc::patch` itself (2026-05-11 design call:
/// patch never marks `Unreadable` mid-multipass; bytes stay
/// `NonTrimmed` so future passes get another shot at them). Kept
/// in the enum for the orchestrator-side end-of-recovery promotion
/// (autorip, after the final retry pass completes, promotes
/// still-NonTrimmed bytes to Unreadable). The orchestrator (autorip)
/// performs this promotion directly via `Mapfile::record()` after all
/// retry passes complete, not by emitting to `PatchSink`. This variant
/// remains unused by the library itself.
#[allow(dead_code)]
Unreadable { pos: u64, len: u64 },
/// Producer marks `[pos, pos+len)` as `NonTrimmed`. Used for BOTH
/// the per-range skip-limit case (remaining bytes never tried) AND
/// individual sector failures (tried-but-failed within a pass).
/// Both stay "hopeful" — a later pass retries them.
///
/// CRITICAL: "NonTrimmed in pass N" does NOT mean "Unreadable
/// forever." Drive reads are stochastic: the same sector that
/// fails 10 times in Pass 2 may succeed on attempt 1 in Pass 3
/// after temperature / bus state / prior-read patterns shift.
/// Pre-2026-05-11 patch marked individual failures Unreadable,
/// which gave up on sectors that subsequent passes could have
/// recovered (historical: ~36% of patch-marked Unreadable
/// sectors turned out to be readable in re-rip experiments).
/// Promotion to true Unreadable is the orchestrator's job,
/// applied once after all retry passes complete.
NonTrimmed { pos: u64, len: u64 },
}
/// Mapfile snapshot the sink republishes after every record so the
/// producer can drive its stall / progress logic without holding the
/// mapfile lock for long. `bad_ranges` is the DAMAGE set
/// (`NonTrimmed + Unreadable + NonScraped`) — NOT NonTried, which is the unread
/// remainder, not damage. Including NonTried inflated the live located drilldown
/// (at-risk movie time + range count) with unread sectors; excluding it matches
/// the one-shot progress path.
pub(super) struct SharedPatchState {
pub stats: MapStats,
pub bad_ranges: Vec<(u64, u64)>,
}
impl SharedPatchState {
/// Cap on the republished `bad_ranges` Vec. Consumers (progress display,
/// scheduler) only sample the head of the list; the full set is bounded by
/// the mapfile entry cap so a pathologically fragmented disc can't make
/// every per-record republish allocate unboundedly.
const MAX_BAD_RANGES: usize = 8192;
fn from_map(map: &Mapfile) -> Self {
let mut bad_ranges = map.ranges_with(&[
SectorStatus::NonTrimmed,
SectorStatus::Unreadable,
SectorStatus::NonScraped,
]);
bad_ranges.truncate(Self::MAX_BAD_RANGES);
Self {
stats: map.stats(),
bad_ranges,
}
}
}
/// Final summary returned by [`Sink::close`] when the consumer drains
/// cleanly. Mirrors what the pre-split patch loop computed at the end
/// of the function — final mapfile stats plus whether `sync_all`
/// failed on a regular file (the only kind of fsync error patch ever
/// surfaced; `/dev/null` and pipes always fail `sync_all`, that's not
/// a real error).
pub(super) struct PatchSummary {
pub stats: MapStats,
}
/// Consumer-side of the patch pipeline. Owns the ISO writeback file
/// and the mapfile; publishes a shared snapshot after every record so
/// the producer can read `bytes_good` for stall detection and
/// progress reporting.
pub(super) struct PatchSink {
file: crate::io::WritebackFile,
map: Mapfile,
/// Whether the output is a regular file (so a `sync_all` failure
/// is real). `/dev/null` etc. always fail `sync_all`; ignore those.
is_regular: bool,
/// Snapshot the producer reads. Updated after every successful
/// `record()` call. `Mutex` rather than separate atomics because
/// the producer wants stats + bad_ranges as a coherent pair.
shared: Arc<Mutex<SharedPatchState>>,
/// Last time the shared snapshot was republished. `from_map` allocates
/// O(bad_ranges) every call, so the per-record path throttles to a time
/// cadence (`REPUBLISH_CADENCE`); the final close always forces a publish.
last_republish: Option<std::time::Instant>,
}
/// Minimum interval between per-record snapshot republishes.
const REPUBLISH_CADENCE: std::time::Duration = std::time::Duration::from_millis(250);
impl PatchSink {
/// Open `path` as a [`crate::io::WritebackFile`] and pair it with
/// `map` for the consumer. The producer holds onto the returned
/// `Arc<Mutex<SharedPatchState>>` so it can poll mapfile state
/// while the consumer is mutating it.
pub(super) fn new(
path: &std::path::Path,
map: Mapfile,
is_regular: bool,
) -> Result<(Self, Arc<Mutex<SharedPatchState>>)> {
let file =
crate::io::WritebackFile::open(path).map_err(|e| Error::IoError { source: e })?;
let shared = Arc::new(Mutex::new(SharedPatchState::from_map(&map)));
let shared_clone = shared.clone();
Ok((
Self {
file,
map,
is_regular,
shared,
last_republish: None,
},
shared_clone,
))
}
/// Republish the shared snapshot. When `force` is false the update is
/// throttled to `REPUBLISH_CADENCE`; `force` (used at close) always
/// publishes the final state.
fn republish(&mut self, force: bool) {
let now = std::time::Instant::now();
if !force {
if let Some(prev) = self.last_republish {
if now.duration_since(prev) < REPUBLISH_CADENCE {
return;
}
}
}
self.last_republish = Some(now);
self.publish_now();
}
fn publish_now(&self) {
// Best-effort lock — only the producer reads, only the consumer
// writes; contention is single-acquire so the lock is never
// poisoned in practice. If it ever did get poisoned we'd want
// the underlying error surfaced rather than silently swallowed,
// so we propagate the poison panic rather than silently
// continuing with stale shared state.
let mut guard = self
.shared
.lock()
.expect("PatchSink shared state mutex poisoned");
*guard = SharedPatchState::from_map(&self.map);
}
}
impl Sink<PatchItem> for PatchSink {
type Output = PatchSummary;
fn apply(&mut self, item: PatchItem) -> std::result::Result<Flow, Error> {
match item {
PatchItem::Recovered { pos, buf } => {
let len = buf.len() as u64;
self.file
.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
self.file
.write_all(&buf)
.map_err(|e| Error::IoError { source: e })?;
self.map
.record(pos, len, SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
}
PatchItem::Unreadable { pos, len } => {
self.map
.record(pos, len, SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
}
PatchItem::NonTrimmed { pos, len } => {
self.map
.record(pos, len, SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
}
}
self.republish(false);
Ok(Flow::Continue)
}
fn close(mut self) -> std::result::Result<Self::Output, Error> {
// Drain in-flight writeback then issue a full fsync. A failure
// here matters only on regular files — pipes / `/dev/null` etc.
// always fail `sync_all`.
if let Err(e) = self.file.sync_all() {
if self.is_regular {
tracing::warn!(
target: "freemkv::disc",
phase = "patch.sync.failed",
error = %e,
os_error = e.raw_os_error(),
error_kind = ?e.kind(),
"patch: sync_all failed"
);
return Err(Error::IoError { source: e });
}
tracing::debug!(
target: "freemkv::disc",
phase = "patch.sync.skipped",
error = %e,
"patch: sync_all failed for non-regular file; ignoring"
);
}
self.map.flush().map_err(|e| Error::IoError { source: e })?;
// Final republish so anyone reading the shared snapshot after
// `Pipeline::finish` sees the post-flush state. (The producer
// already has its own copy of the final `MapStats` in the
// returned `PatchSummary`, but the snapshot is part of the
// public-ish contract of the consumer: it stays current
// through close.)
self.republish(true);
Ok(PatchSummary {
stats: self.map.stats(),
})
}
}
// ─────────────────────────────────────────────────────────────────
// Disc::patch + bytes_bad_in_title — extracted from disc/mod.rs in
// 0.20.1. Behavior unchanged; the move splits the 3,900-line mod.rs
// into a cleaner-to-read file.
// ─────────────────────────────────────────────────────────────────
use super::{Disc, DiscTitle, PatchOptions, PatchOutcome, bytes_bad_in_title};
use crate::io::pipeline::Pipeline;
use crate::sector::SectorSource;
/// Breadth-first recovery tiers. Tier 0 fast-sweeps every bad range; tier 1
/// deep-recovers the residual; tier 2 runs the marginal specialists on whatever
/// tiers 0-1 leave (the true hardened residual). See `PatchCtx::run` and
/// `build_tier_handlers`.
const PATCH_TIERS: usize = 3;
/// Send a `PatchItem` and translate a `SendError` (consumer thread died
/// / panicked) into a library error so the caller propagates cleanly.
pub(super) fn send_or_abort(
pipe: &Pipeline<PatchItem, PatchSummary>,
item: PatchItem,
) -> Result<()> {
pipe.send(item).map_err(|_| Error::PipelineConsumerGone)
}
/// Phase A pre-snapshot. Loads the mapfile, captures the fields the
/// patch loop needs after the live `Mapfile` moves into the consumer
/// thread (`bytes_good` baseline, total stats, entry snapshot for
/// the diagnostic dump, the initial bad-range work list, total work
/// in bytes, and the `is_regular` test that gates the post-pass
/// `sync_all` error policy). Returned `Mapfile` is the same object
/// that was loaded — caller passes ownership into `PatchSink::new`.
#[allow(clippy::type_complexity)]
pub(super) fn compute_initial_state(
path: &std::path::Path,
opts: &PatchOptions,
mapfile_path: &std::path::Path,
) -> Result<(
Mapfile,
MapStats,
Vec<mapfile::MapEntry>,
u64,
Vec<(u64, u64)>,
u64,
bool,
)> {
let map = mapfile::Mapfile::load(mapfile_path).map_err(|e| Error::IoError { source: e })?;
let total_bytes = map.total_size();
let initial_stats = map.stats();
let initial_entries: Vec<_> = map.entries().to_vec();
// Every retry pass acts on NonTrimmed, NonScraped, and Unreadable
// ranges. Including Unreadable means a sector that failed in pass N
// gets a fresh shot in pass N+1 — drive state evolves, the same
// read can succeed later. Each pass owns its own jumps/skips; if
// pass 5 jumps over the same zone as pass 2, fine. NonTried ranges
// are intentionally excluded — they are covered by a preceding
// sweep pass, not by patch.
let mut bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable,
]);
if opts.reverse {
bad_ranges.reverse();
}
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
// Fail SAFE when metadata is indeterminate: assume a regular file so a
// real `sync_all` failure is surfaced, not swallowed. `/dev/null` and pipes
// report success-with-non-file here (so they still correctly map to
// `false`); only a genuine metadata error (e.g. transient NFS ESTALE) hits
// the default, and for a data-integrity guard "surface the error" is the
// right side to err on.
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(true);
Ok((
map,
initial_stats,
initial_entries,
total_bytes,
bad_ranges,
work_total,
is_regular,
))
}
/// One recovery read of `[lba, lba+count)` into `buf[..count*2048]`.
///
/// On an AACS disc a mid-unit window (start or length not unit-aligned)
/// is widened to the enclosing aligned 3-sector unit, decrypted, and the
/// originally-requested window copied back out: the decrypting reader
/// rejects an unaligned read (`DecryptFailed`) and the sector would be
/// abandoned without the drive ever being asked. Units anchor at offset
/// 0, so the widened start is always unit-aligned. All recovery
/// accounting upstream (pos, block_bytes, dispatched lba/count) is
/// unchanged — only the physical read widens, so the cursor cannot
/// desync. `recovery` selects the SCSI timeout (true = 60 s deep recovery,
/// false = the fast path); `fua` forces the drive to bypass its readahead cache
/// and re-fetch
/// the medium (a Pass-N marginal-sector lever — see
/// [`crate::sector::SectorSource::read_sectors_fua`]).
pub(super) fn recovery_read<R: SectorSource + ?Sized>(
reader: &mut R,
decrypt_is_aacs: bool,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let bytes = count as usize * 2048;
if decrypt_is_aacs && (lba % 3 != 0 || count % 3 != 0) {
const U: u32 = 3;
let aligned_lba = lba - (lba % U);
let head = (lba - aligned_lba) as usize; // lead-in sectors
let span = head + count as usize;
let aligned_count = span + ((U as usize - span % U as usize) % U as usize);
let mut scratch = vec![0u8; aligned_count * 2048];
reader.read_sectors_fua(
aligned_lba,
aligned_count as u16,
&mut scratch,
recovery,
fua,
)?;
buf[..bytes].copy_from_slice(&scratch[head * 2048..head * 2048 + bytes]);
Ok(bytes)
} else {
reader.read_sectors_fua(lba, count, &mut buf[..bytes], recovery, fua)
}
}
/// The still-bad `[pos, len)` sub-ranges of one bad section, in byte offsets
/// (all multiples of 2048), kept sorted and non-overlapping. The per-section
/// recovery rework (#50) threads one of these through the recovery phase
/// helpers: each phase RECOVERS some bytes and calls [`SubRanges::remove`] to
/// shrink the set; whatever remains after all phases is the dead residue that
/// gets recorded NonTrimmed. Pure data structure — no I/O — so each phase
/// helper is unit-testable by asserting the residual `SubRanges`.
///
/// The residue tracker used by the phased `recover_section` orchestrator.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct SubRanges {
/// (pos, len) pairs, sorted by pos, non-overlapping, all non-zero len.
ranges: Vec<(u64, u64)>,
}
#[cfg_attr(not(test), allow(dead_code))]
impl SubRanges {
/// One whole bad section.
pub(super) fn from_section(pos: u64, len: u64) -> Self {
let ranges = if len == 0 {
Vec::new()
} else {
vec![(pos, len)]
};
Self { ranges }
}
pub(super) fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
/// Total still-bad bytes across all sub-ranges.
pub(super) fn total_len(&self) -> u64 {
self.ranges.iter().map(|&(_, l)| l).sum()
}
pub(super) fn ranges(&self) -> &[(u64, u64)] {
&self.ranges
}
/// Remove the recovered byte-range `[pos, pos+len)` from the bad set,
/// splitting any sub-range it bisects and trimming any it overlaps. A
/// range fully covered is dropped; a removal landing in a gap is a no-op.
/// This is how a phase helper records "these bytes are no longer bad".
pub(super) fn remove(&mut self, pos: u64, len: u64) {
if len == 0 {
return;
}
let rend = pos + len;
let mut out: Vec<(u64, u64)> = Vec::with_capacity(self.ranges.len() + 1);
for &(rp, rl) in &self.ranges {
let re = rp + rl;
// Disjoint: keep whole.
if rend <= rp || pos >= re {
out.push((rp, rl));
continue;
}
// Left remainder [rp, pos).
if pos > rp {
out.push((rp, pos - rp));
}
// Right remainder [rend, re).
if rend < re {
out.push((rend, re - rend));
}
// Otherwise the overlap consumed this whole sub-range.
}
self.ranges = out;
}
}
/// Pre-loop diagnostic dump: emits `patch_mapfile_snapshot` plus the
/// first/last 10 entries (info + per-entry debug). Pure logging — no
/// state mutation. Pulled out of `Disc::patch` so the coordination
/// body stays compact; the operator's grep patterns for
/// `[disc] patch_mapfile_snapshot`, `patch_mapfile_entries_start`,
/// `patch_mapfile_entry_start`, `patch_mapfile_entries_end`,
/// `patch_mapfile_entry_end` are unchanged.
pub(super) fn log_patch_start_snapshot(
initial_entries: &[mapfile::MapEntry],
initial_stats: &mapfile::MapStats,
bytes_good_before: u64,
) {
tracing::info!(
target: "freemkv::disc",
phase = "patch.mapfile.snapshot",
total_entries = initial_entries.len(),
bytes_good_before,
bytes_retryable = initial_stats.bytes_retryable,
bytes_unreadable = initial_stats.bytes_unreadable,
bytes_nontried = initial_stats.bytes_nontried,
"Mapfile state snapshot at patch start"
);
if !initial_entries.is_empty() {
tracing::info!(
target: "freemkv::disc",
phase = "patch.mapfile.entries.start",
num_to_log = (initial_entries.len().min(10)) as u32,
"First 10 entries"
);
for entry in initial_entries.iter().take(10) {
tracing::debug!(
target: "freemkv::disc",
phase = "patch.mapfile.entry.start",
pos_hex = format!("0x{:09x}", entry.pos),
size_mb = entry.size as f64 / 1_048_576.0,
status_char = entry.status.to_char() as u8 as i32,
"Mapfile entry"
);
}
}
if initial_entries.len() > 10 {
tracing::info!(
target: "freemkv::disc",
phase = "patch.mapfile.entries.end",
num_to_log = (initial_entries.len().min(10)) as u32,
"Last 10 entries"
);
for entry in initial_entries.iter().skip(initial_entries.len() - 10) {
tracing::debug!(
target: "freemkv::disc",
phase = "patch.mapfile.entry.end",
pos_hex = format!("0x{:09x}", entry.pos),
size_mb = entry.size as f64 / 1_048_576.0,
status_char = format!("{}", entry.status.to_char()),
"Mapfile entry"
);
}
}
}
/// Bundle final mapfile stats + accumulated loop counters into the
/// public `PatchOutcome` the caller consumes. The post-loop tracing
/// (`patch_iso_size_end`, `patch_done`) is also emitted here so the
/// coordination body has one less inline stanza.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_outcome(
state: &PatchLoopState,
summary: &PatchSummary,
path: &std::path::Path,
total_bytes: u64,
num_ranges: usize,
wedged_threshold: u64,
) -> PatchOutcome {
let stats = summary.stats;
if let Ok(metadata) = std::fs::metadata(path) {
tracing::info!(
target: "freemkv::disc",
phase = "patch.iso_size.end",
iso_bytes = metadata.len(),
bytes_recovered = stats.bytes_good.saturating_sub(state.bytes_good_before),
"ISO file size at patch end"
);
}
tracing::info!(
target: "freemkv::disc",
phase = "patch.done",
wedged_exit = state.wedged_exit,
halted = state.halted,
bytes_recovered = stats.bytes_good.saturating_sub(state.bytes_good_before),
final_bytes_good = stats.bytes_good,
final_bytes_unreadable = stats.bytes_unreadable,
final_bytes_pending = stats.bytes_pending,
total_ranges_processed = num_ranges,
"Disc::patch returning"
);
PatchOutcome {
bytes_total: total_bytes,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
bytes_recovered_this_pass: stats.bytes_good.saturating_sub(state.bytes_good_before),
halted: state.halted,
wedged_exit: state.wedged_exit,
wedged_threshold,
}
}
/// Per-pass loop state, accumulated across every range and every read
/// inside `Disc::patch`. Lives on the producer thread; helpers take
/// `&mut PatchLoopState` so they can mutate counters and per-range
/// scratch without an explosion of parameters at the call site.
pub(super) struct PatchLoopState {
// Counters
pub halted: bool,
pub wedged_exit: bool,
// Clock seam: the handler chain reads wall time through this rather than
// calling `Instant::now()` inline, so the per-handler deadline is driven by
// an injectable clock and deterministic tests can wind it forward.
pub now: fn() -> std::time::Instant,
// Snapshot at construction — these stay constant for the whole pass.
pub bytes_good_before: u64,
#[allow(dead_code)]
pub total_bytes: u64,
pub initial_batch: u16,
pub work_total: u64,
}
impl PatchLoopState {
pub(super) fn new(
bytes_good_before: u64,
total_bytes: u64,
initial_batch: u16,
work_total: u64,
) -> Self {
// Production clock: the real monotonic wall clock.
Self::new_with_clock(
bytes_good_before,
total_bytes,
initial_batch,
work_total,
std::time::Instant::now,
)
}
/// Like `new`, but with an injectable monotonic clock so a test can wind a
/// fake clock forward to drive the per-handler deadline deterministically.
/// `new` passes `Instant::now`, so the production path is unchanged.
pub(super) fn new_with_clock(
bytes_good_before: u64,
total_bytes: u64,
initial_batch: u16,
work_total: u64,
now: fn() -> std::time::Instant,
) -> Self {
Self {
halted: false,
wedged_exit: false,
now,
bytes_good_before,
total_bytes,
initial_batch,
work_total,
}
}
}
/// Why [`PatchCtx::patch_region`] returned. The orchestrator
/// ([`PatchCtx::run`]) advances to the next bad range on `Completed` (the
/// handler chain always drains a section to recovered-or-residue, so there is
/// no per-range abort), and ends the whole pass only on `Halted` or
/// `TransportFault` — for which the matching `state.halted` / `state.wedged_exit`
/// flag was already set, so `build_outcome` reports it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RegionOutcome {
/// Section drained: recovered what was readable, left the rest NonTrimmed.
Completed,
/// Halt requested — the halt token or the progress reporter.
/// `state.halted` is set.
Halted,
/// USB-bridge transport fault: a dead bus, not a bad sector.
/// `state.wedged_exit` is set.
TransportFault,
}
/// Per-pass coordination state for one `Disc::patch` run: the decrypting
/// reader, the consumer pipe + its shared mapfile snapshot, the options,
/// and the accumulating [`PatchLoopState`]. Bundling these lets the
/// orchestrator ([`PatchCtx::run`]) and the focused per-range recovery
/// loop ([`PatchCtx::patch_region`]) be methods rather than free
/// functions threading a dozen arguments. `state` carries ACROSS ranges
/// (counters, stall timers, NOT_READY/last-skip cursors); the per-range
/// scratch inside it is reset at the top of each `patch_region`.
struct PatchCtx<'a, 'o> {
disc: &'a Disc,
reader: &'a mut dyn SectorSource,
pipe: &'a Pipeline<PatchItem, PatchSummary>,
shared: &'a Mutex<SharedPatchState>,
opts: &'a PatchOptions<'o>,
total_bytes: u64,
decrypt_is_aacs: bool,
state: PatchLoopState,
/// Per-rip handler scorecard: grades handlers by recovery rate so the
/// coordinator runs the winners first and lets duds fall back. Reset per
/// pass (ephemeral, no persistence).
scoreboard: HandlerScoreboard,
/// Consecutive wedge-family senses across the WHOLE pass. Seeded into each
/// per-section `HandlerCtx` and read back after, so a drive fast-fail wedge is
/// detected even when every bad sub-range is smaller than the abort streak.
wedge_streak: u32,
}
/// Build the handler chain for one breadth-first tier. Each config is named by
/// its FULL parameterisation (`build_tier_handlers` picks the roster; the
/// scorecard re-orders WITHIN a tier per rip). The engine hardcodes no
/// conclusion: every technique is always present at its tier, and a technique
/// that doesn't fit this disc self-deprioritises (scores low, yields after 4
/// unproductive reads) rather than being removed.
///
/// - **Tier 0 — fast scouts** (`fast`: max speed, 10 s, cache on): grab the
/// readable bulk across every range.
/// - **Tier 1 — slow-deep** (`deep`: max speed, 60 s ECC budget): deep-recover
/// the easy residual.
/// - **Tier 2 — marginal specialists**: the physical-failure-mode matrix
/// (SlowSpin / FuaRetry / SlowFua / CachePrime / Oscillate / SpeedSweep), run
/// ONLY on what tiers 0-1 leave.
fn build_tier_handlers(tier: usize) -> Vec<Box<dyn SectionHandler>> {
match tier {
// Tier 0 — fast scouts. Bisect leads by default (probing a range's
// MIDDLE finds a readable island in one read); Jump blows through large
// dead runs; the fast linear sweeps mop up. The scorecard re-orders.
0 => 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(),
}),
],
// Tier 1 — slow deep recovery on the small residue tier 0 leaves.
1 => vec![
Box::new(Linear {
direction: Direction::Reverse,
params: ReadParams::deep(),
}),
Box::new(Linear {
direction: Direction::Forward,
params: ReadParams::deep(),
}),
],
// Tier 2 — marginal specialists, run ONLY on the hardened residual that
// tiers 0-1 leave. Each targets ONE physical failure mode. They are all
// NEW configs, so the scorecard calibrates each once then ranks by its
// decayed rate — a specialist that doesn't fit THIS disc self-
// deprioritises (scores low, yields after 4 unproductive reads) and one
// that starts landing sectors climbs. Every read is a wedge-safe
// `read_span`, so they inherit the wedge-abort / unproductive-yield /
// deadline bounds for free. Additive: tiers 0-1 are untouched.
_ => {
// Slower spindle (more servo dwell + ECC integration per sector).
let min_deep = ReadParams {
speed: SpeedPref::Min,
fua: false,
timeout: TimeoutPref::Deep,
};
// Cache-bypass physical re-read (stochastic marginal sectors).
let fua_deep = ReadParams {
speed: SpeedPref::Max,
fua: true,
timeout: TimeoutPref::Deep,
};
// Both levers for the hardest sectors (min spindle AND cache-bypass).
let slow_fua = ReadParams {
speed: SpeedPref::Min,
fua: true,
timeout: TimeoutPref::Deep,
};
vec![
// SlowSpin: Linear fwd + rev at min speed.
Box::new(Linear {
direction: Direction::Reverse,
params: min_deep,
}),
Box::new(Linear {
direction: Direction::Forward,
params: min_deep,
}),
// FuaRetry: Linear fwd + rev + Bisect under FUA (multiple physical
// attempts per marginal sector).
Box::new(Linear {
direction: Direction::Forward,
params: fua_deep,
}),
Box::new(Linear {
direction: Direction::Reverse,
params: fua_deep,
}),
Box::new(Bisect { params: fua_deep }),
// SlowFua: the hardest sector — min speed AND FUA.
Box::new(Linear {
direction: Direction::Forward,
params: slow_fua,
}),
// CachePrime: warm the channel on the preceding good run first.
Box::new(CachePrime {
params: ReadParams::deep(),
}),
// Oscillate: alternate approach direction, at max and at min.
Box::new(Oscillate {
params: ReadParams::deep(),
}),
Box::new(Oscillate { params: min_deep }),
// SpeedSweep: per-sector Max→Min speed search.
Box::new(SpeedSweep {
params: ReadParams::deep(),
}),
]
}
}
}
/// The FLAT handler pool — every technique×parameterization from all tiers in
/// ONE chain, no tier gate. `run_handlers` sorts it best-first by the rip
/// scorecard on every range, so this is a data-driven bandit: the first ranges
/// try them all (explore), then the decayed-yield ranking floats whatever is
/// actually landing sectors to the front (exploit), re-measured per range. A
/// handler that doesn't fit stays last but is never dropped (floor → it can
/// still revive if the residual's character shifts). No fixed ordering, no
/// "start tier" — the data picks the order. Enabled by `FREEMKV_PATCH_FLAT`;
/// unset keeps the proven tier ladder.
fn build_flat_pool() -> Vec<Box<dyn SectionHandler>> {
let mut pool = Vec::new();
for tier in 0..PATCH_TIERS {
pool.extend(build_tier_handlers(tier));
}
pool
}
/// True when the flat-pool bandit scheduler is requested (`FREEMKV_PATCH_FLAT`
/// set to anything but empty / `0`). Default (unset) keeps the tier ladder.
fn patch_flat_mode() -> bool {
std::env::var("FREEMKV_PATCH_FLAT")
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
/// Short per-handler EXPLORE budget for the flat bandit (seconds). Keeps any one
/// handler from hogging a range so all 16 get a fast turn and the scorecard
/// learns quickly. `FREEMKV_PATCH_FLAT_BUDGET` overrides; default 12 s, floored
/// at 1.
fn flat_handler_budget_secs() -> u64 {
std::env::var("FREEMKV_PATCH_FLAT_BUDGET")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(12)
.max(1)
}
impl PatchCtx<'_, '_> {
/// Orchestrator (one pass): walk the ordered bad ranges. Apply the
/// inter-range cooldown only after a range that grinded, then recover
/// the range; stop the whole pass the moment a range reports
/// halt / wedge / transport-fault.
fn run(&mut self, bad_ranges: &[(u64, u64)]) -> Result<()> {
let num_ranges = bad_ranges.len();
// Attack the LARGEST ranges first. The big NonTrimmed regions are usually
// sweep-jump over-marks that read straight back, so ordering them ahead of
// the many tiny dead fragments lets tier 0 recover the bulk of the disc in
// its first minutes instead of grinding fragments first (ties: low LBA
// first for a predictable, mostly-sequential walk).
let mut ordered: Vec<(u64, u64)> = bad_ranges.to_vec();
ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
// Per-range still-bad sets, persisted ACROSS the breadth-first tiers so
// tier N+1 works on exactly what tier N left behind.
let mut sections: Vec<SubRanges> = ordered
.iter()
.map(|&(p, l)| SubRanges::from_section(p, l))
.collect();
// Two schedulers select the handler chain per range:
//
// FLAT bandit (`FREEMKV_PATCH_FLAT`): ONE range walk, the full flat pool
// per range. `run_handlers` orders it best-first by the live scorecard,
// so the data — not a fixed tier order — decides what runs first. Right
// for a hardened residual (late resume): the specialists get a shot on
// every range immediately instead of waiting out a full bucket→mug sweep.
//
// TIER ladder (default): tier 0 fast-sweeps EVERY range first — grabbing
// the easily-readable bulk across the whole disc (sweep-jump over-marks a
// big region NonTrimmed without testing each sector, so most reads back in
// seconds) — BEFORE any slow per-sector grind; tiers 1-2 escalate onto the
// residue. Right for a FRESH rip (flood still present). This is also the
// fix for the OLD depth-first starvation bug (full chain per range burned
// ~5 min on a front cluster and starved the big recoverable ranges) — but
// the new handlers self-limit (yield after 4 dead reads), so the flat
// scheduler no longer hits that.
if patch_flat_mode() {
for (range_idx, &(range_pos, range_size)) in ordered.iter().enumerate() {
if sections[range_idx].is_empty() {
continue;
}
// Single flat pass: this IS the final (only) tier for the range,
// so surviving residue is recorded NonTrimmed for the next pass.
let outcome = self.recover_section(
0,
range_idx,
num_ranges,
range_pos,
range_size,
&mut sections[range_idx],
/* final_tier */ true,
/* flat */ true,
)?;
match outcome {
RegionOutcome::Completed => {}
RegionOutcome::Halted | RegionOutcome::TransportFault => return Ok(()),
}
}
return Ok(());
}
for tier in 0..PATCH_TIERS {
let final_tier = tier + 1 == PATCH_TIERS;
for (range_idx, &(range_pos, range_size)) in ordered.iter().enumerate() {
if sections[range_idx].is_empty() {
continue; // already fully recovered by an earlier tier
}
let outcome = self.recover_section(
tier,
range_idx,
num_ranges,
range_pos,
range_size,
&mut sections[range_idx],
final_tier,
/* flat */ false,
)?;
match outcome {
RegionOutcome::Completed => {}
RegionOutcome::Halted | RegionOutcome::TransportFault => return Ok(()),
}
}
}
Ok(())
}
/// Run ONE breadth-first tier of the handler chain over one range's still-bad
/// set `bad`. Tier 0 = the fast breadth handlers (grab the readable bulk,
/// fast-fail the rest); tier 1 = deep recovery (slow reads) + bisect on the
/// residual. `final_tier` records the surviving residue as NonTrimmed and
/// accounts the range toward progress exactly once. Cross-range scheduling
/// lives in [`PatchCtx::run`]; this owns one (tier, range) unit of work.
#[allow(clippy::too_many_arguments)]
fn recover_section(
&mut self,
tier: usize,
range_idx: usize,
num_ranges: usize,
range_pos: u64,
range_size: u64,
bad: &mut SubRanges,
final_tier: bool,
flat: bool,
) -> Result<RegionOutcome> {
tracing::info!(
target: "freemkv::disc",
phase = "patch.region.enter",
tier,
flat,
range_index = range_idx,
num_total_ranges = num_ranges,
range_lba = range_pos / 2048,
range_size_mb = range_size as f64 / 1_048_576.0,
bad_bytes = bad.total_len(),
"entering patch range"
);
// Enter at max read speed. A handler picks its own speed / FUA / timeout
// via its `ReadParams`; `read_span` restores max after each handler, so
// every tier starts from the streaming default.
self.reader.set_speed(0xFFFF);
// Handler roster. FLAT mode: the whole pool (all techniques) in one
// chain — `run_handlers` orders it best-first by the rip scorecard, so
// the data picks what runs first. TIER mode: just this tier's roster
// (tier 0 fast scouts, 1 slow-deep, 2 marginal specialists), likewise
// scorecard-ordered within the tier. Either way the scorecard re-learns
// per disc.
let mut handlers: Vec<Box<dyn SectionHandler>> = if flat {
build_flat_pool()
} else {
build_tier_handlers(tier)
};
// Clock seam: handlers read wall time through this so tests can wind a
// fake clock (the same seam the pass uses for its own timing).
let now_ptr = self.state.now;
let now_fn = move || now_ptr();
let mut sink = PatchRecoverySink {
pipe: self.pipe,
err: None,
};
let bad_before = bad.total_len();
let (outcome, wedge_after) = {
// Progress heartbeat: a throttled closure that pushes a fresh
// snapshot to the reporter as recovery happens (called from every
// read via `HandlerCtx::progress`), so the bar and speed move DURING
// a handler instead of only when a section finishes. Scoped to this
// block so its borrow of `self.state` ends before the post-tier
// accounting below.
let disc = self.disc;
let opts = self.opts;
let shared = self.shared;
let total_bytes = self.total_bytes;
let state = &self.state;
let last_tick = std::cell::Cell::new(now_ptr());
let mut tick = move || {
let t = now_ptr();
if t.duration_since(last_tick.get())
>= std::time::Duration::from_millis(PROGRESS_TICK_MS)
{
last_tick.set(t);
let _ = disc.report_patch_progress(state, opts, total_bytes, shared);
}
};
let mut ctx = HandlerCtx {
reader: &mut *self.reader,
sink: &mut sink,
now: &now_fn,
halt: self.opts.halt.as_deref(),
decrypt_is_aacs: self.decrypt_is_aacs,
tick: Some(&mut tick),
unproductive: 0,
// Carry the pass-level wedge streak in so a fast-fail wedge is
// caught across many small sections, not reset each one.
wedge_streak: self.wedge_streak,
// Drive was just reset to max above; read_span tracks changes.
cur_speed: 0xFFFF,
};
// Per-handler time budget. FLAT mode is EXPLORE-first: give each
// handler only a short slice so all 16 get a turn on the range
// quickly ("test all quick"), and the scorecard learns which land
// bytes — a winner then earns more cumulative time across ranges and
// passes. TIER mode keeps the full 60 s deep-recovery window. Both
// env-tunable via `FREEMKV_PATCH_FLAT_BUDGET`.
let budget_secs = if flat {
flat_handler_budget_secs()
} else {
PER_HANDLER_BUDGET_SECS
};
let o = run_handlers(&mut ctx, &mut handlers, bad, &mut self.scoreboard, |_bad| {
now_ptr() + std::time::Duration::from_secs(budget_secs)
});
(o, ctx.wedge_streak)
};
self.wedge_streak = wedge_after;
tracing::info!(
target: "freemkv::disc",
phase = "patch.region.exit",
tier,
range_index = range_idx,
range_lba = range_pos / 2048,
outcome = ?outcome,
bad_bytes_before = bad_before,
bad_bytes_after = bad.total_len(),
recovered = bad_before.saturating_sub(bad.total_len()),
"region tier finished"
);
// A pipe-closed / halt error captured while emitting recovered spans is
// fatal to the pass.
if let Some(e) = sink.err.take() {
return Err(e);
}
// On the FINAL tier, whatever is still bad is this pass's residue: record
// NonTrimmed and account the range toward progress (once). A later pass —
// or a future handler — gets another shot; the orchestrator promotes
// still-NonTrimmed to Unreadable only after the final pass completes.
if final_tier {
for &(pos, len) in bad.ranges() {
send_or_abort(self.pipe, PatchItem::NonTrimmed { pos, len })?;
}
}
if self
.disc
.report_patch_progress(&self.state, self.opts, self.total_bytes, self.shared)
{
self.state.halted = true;
return Ok(RegionOutcome::Halted);
}
match outcome {
// Whether the chain cleared the section or left residue, we always
// advance to the next range — never hang, never abort mid-pass.
HandlerOutcome::Complete | HandlerOutcome::Remaining => Ok(RegionOutcome::Completed),
HandlerOutcome::Halted => {
self.state.halted = true;
Ok(RegionOutcome::Halted)
}
// Bridge/transport crash: end the pass so the orchestrator can
// spin-cycle the drive and resume from the mapfile next pass.
HandlerOutcome::TransportFault => {
self.state.wedged_exit = true;
Ok(RegionOutcome::TransportFault)
}
}
}
}
impl Disc {
/// Build + dispatch a `PassProgress` to the caller's reporter,
/// using the current pipeline-shared mapfile snapshot. Needs
/// `&self` for `self.titles`. Returns `true` if the reporter
/// asked us to halt (i.e. the outer loop should set
/// `state.halted` and break).
pub(super) fn report_patch_progress(
&self,
state: &PatchLoopState,
opts: &PatchOptions,
total_bytes: u64,
shared: &Mutex<SharedPatchState>,
) -> bool {
let Some(reporter) = opts.progress else {
return false;
};
let (s, bad_ranges_now) = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
(g.stats, g.bad_ranges.clone())
};
let kind = if state.initial_batch == 1 {
crate::progress::PassKind::Scrape {
reverse: opts.reverse,
}
} else {
crate::progress::PassKind::Trim {
reverse: opts.reverse,
}
};
let main_title_bad = self
.titles
.first()
.map(|t| bytes_bad_in_title(t, &bad_ranges_now))
.unwrap_or(0);
let main_title = self.titles.first();
// Progress = bytes RECOVERED so far (initial bad still-bad), not a
// per-range counter. With breadth-first tiers the readable bulk comes
// back during tier 0 before any range is "finished", so a range-counter
// sits at 0% while hundreds of MB are actually recovered. Deriving it
// from the live still-bad count makes the bar (and the speed the client
// computes from its delta) reflect real recovery the instant it happens.
//
// Compose the still-bad set to MATCH `work_total` (= the initial
// NonTrimmed + NonScraped + Unreadable, no NonTried). `bytes_pending`
// alone is the wrong denominator: it INCLUDES NonTried (so on a partially
// swept disc it exceeds `work_total` and saturating_sub pins the bar at 0)
// and EXCLUDES Unreadable (so the final-tier Unreadable→NonTrimmed relabel
// would drive `recovered` backward). Subtract NonTried and add Unreadable
// back so the two sets line up and progress stays monotonic.
let still_bad_work = s
.bytes_pending
.saturating_sub(s.bytes_nontried)
.saturating_add(s.bytes_unreadable);
let recovered = state.work_total.saturating_sub(still_bad_work);
let pp = crate::progress::PassProgress {
kind,
work_done: recovered,
work_total: state.work_total,
bytes_good_total: s.bytes_good,
bytes_unreadable_total: s.bytes_unreadable,
bytes_pending_total: s.bytes_pending,
bytes_retryable_total: s.bytes_retryable,
bytes_total_disc: total_bytes,
disc_duration_secs: main_title.map(|t| t.duration_secs),
bytes_bad_in_main_title: main_title_bad,
main_title_duration_secs: main_title.map(|t| t.duration_secs),
main_title_size_bytes: main_title.map(|t| t.size_bytes),
// The rendered drilldown — located ranges + at-risk movie time —
// computed here from the in-memory bad-range set + title so the
// client renders it verbatim and never reads the mapfile.
located: main_title
.map(|t| crate::disc::locate_ranges(&bad_ranges_now, t))
.unwrap_or_default(),
};
!reporter.report(&pp)
}
/// Bytes of bad/unreadable data in a title's extents, from a mapfile.
///
/// Consumers (CLI, autorip) call this after a rip pass to determine
/// how much damage affects a particular title — useful for showing
/// "42s lost (12s in main movie)" in the UI.
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
let map = match mapfile::Mapfile::load(mapfile_path) {
Ok(m) => m,
// A MISSING mapfile is legitimate (no damage was ever tracked — e.g. a
// clean single-pass rip): 0 bad bytes is correct. Any OTHER load error
// (corrupt / unreadable mapfile) means we CANNOT know the damage — and
// a returned 0 reads to the caller as "clean." Logging alone is not
// fail-safe: the RETURN VALUE drives the loss/abort accounting, not the
// log. So fail safe by reporting the ENTIRE title as bad (its full
// in-extent byte count) — a corrupt damage record must surface as
// maximal loss, never as a clean rip.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
path = %mapfile_path.display(),
error = %e,
"bytes_bad_in_title: mapfile load failed; reporting whole title bad (fail-safe: cannot confirm clean)"
);
return bytes_bad_in_title(title, &[(0, u64::MAX)]);
}
};
let bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::Unreadable,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::NonTried,
]);
bytes_bad_in_title(title, &bad_ranges)
}
/// Pass 2..N of a multipass rip: re-read the bad ranges
/// recorded in the sidecar mapfile and try to recover them.
/// With `reverse: true` (the default for the recovery walker),
/// the bad-range walk runs end-to-start so escalating skips
/// converge on the actual bad sub-zones inside any
/// `NonTrimmed` block. Returns a [`PatchOutcome`] with
/// recovered byte counts and wedge-detection signals.
///
/// Paired with [`Disc::sweep`] as the library's other flat
/// rip-phase verb. Caller drives the retry loop and the
/// sweep-vs-patch dispatch.
pub fn patch(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &PatchOptions,
) -> Result<PatchOutcome> {
use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH};
use crate::sector::DecryptingSectorSource;
// Pre-flight decrypt gate (also enforced in `copy`; re-checked here so a
// direct `patch` caller can't bypass it). A decrypting patch pass of an
// encrypted disc with no usable key would write ciphertext into the ISO's
// recovered ranges; refuse before reading any sector. No-op for `--raw`
// (`opts.decrypt == false`) and unencrypted discs.
self.ensure_decryptable(!opts.decrypt)?;
let patch_t0 = std::time::Instant::now();
let mapfile_path = self.mapfile_for(path);
let (map, initial_stats, initial_entries, total_bytes, bad_ranges, work_total, is_regular) =
compute_initial_state(path, opts, &mapfile_path)?;
tracing::info!(
target: "freemkv::scan",
phase = "patch",
num_ranges = bad_ranges.len(),
reverse = opts.reverse,
"begin"
);
let bytes_good_before = initial_stats.bytes_good;
let bytes_good_start = bytes_good_before;
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
// patch (the multipass / `--raw --multipass` path) copies ciphertext
// verbatim (keys = `None` → pass-through). Bad sectors are found by
// PHYSICAL read success, not by decrypt structure: a re-read that returns
// good bytes recovers the range; a read that errors leaves it NonTrimmed
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
let mut keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
// AACS decrypting patch: resolve the whole-disc key map up front and decrypt
// via the map (identical to `Disc::sweep`). CSS keeps the content-gated
// self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.)
let key_map = if opts.decrypt && decrypt_is_aacs {
Some(std::sync::Arc::new(self.resolve_content_key_map(
reader,
&mut keys,
opts.key_fetch.as_ref(),
)?))
} else {
None
};
let content_ranges = self.encrypted_content_ranges();
let can_gate = !content_ranges.is_empty();
let mut reader = {
let mut dec = DecryptingSectorSource::new(reader, keys);
if let Some(map) = key_map {
dec = dec.with_key_map(map);
} else if opts.decrypt && can_gate {
dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges));
}
dec
};
let reader = &mut reader;
// Spawn the consumer. The `WritebackFile` (same bounded-cache
// wrapper sweep uses, so patch's recovery writes — sparse but
// can be many across a damaged region — get the burst-flush
// protection on slow / NFS-backed staging) and the `Mapfile`
// both move into the sink. We hold an `Arc<Mutex<…>>` snapshot
// the sink republishes after every record so producer-side
// stall guards / progress callbacks can read consumer side-
// effects.
let (sink, shared) = PatchSink::new(path, map, is_regular)?;
// Why: WRITE_THROUGH_DEPTH (=1) — patch reads ONE sector per
// recovery decision and the producer's stall / damage-window
// logic checks consumer-published stats inline. Sweep's
// DEFAULT_PIPELINE_DEPTH (=4) would let several sectors of
// recovered bytes queue up between producer decisions and
// writes, which conflicts with the per-sector lockstep this
// loop was written against.
let pipe = Pipeline::<PatchItem, _>::spawn(WRITE_THROUGH_DEPTH, sink)?;
// Log ISO file size at patch start for write monitoring
if let Ok(metadata) = std::fs::metadata(path) {
tracing::info!(
target: "freemkv::disc",
phase = "patch.iso_size.start",
iso_bytes = metadata.len(),
"ISO file size at patch start"
);
}
// Read sizing and fast-vs-deep recovery are owned by the handler chain
// (`section_recover.rs`): it reads at a fixed `BATCH_SECTORS`, bisects to
// isolate readable islands, and selects fast vs 60 s deep reads per
// handler/tier. The old adaptive `current_batch` / halve-on-failure /
// double-back loop that this comment used to describe no longer exists.
// `block_sectors` and `full_recovery` therefore no longer drive behavior
// — they survive only as the PassKind label and the diagnostics logged
// below (informational-only; a caller can't change read sizing or the
// recovery timeout through them). Clamp to ≥1 so the label math never
// underflows on a `Some(0)`.
let initial_batch = opts.block_sectors.unwrap_or(1).max(1);
let recovery = opts.full_recovery;
log_patch_start_snapshot(&initial_entries, &initial_stats, bytes_good_before);
tracing::info!(
target: "freemkv::disc",
phase = "patch.ranges",
num_ranges = bad_ranges.len(),
work_total,
reverse_mode = opts.reverse,
"Bad ranges for patch"
);
tracing::info!(
target: "freemkv::disc",
phase = "patch.start",
block_sectors = initial_batch,
recovery,
reverse = opts.reverse,
wedged_threshold = opts.wedged_threshold,
num_ranges = bad_ranges.len(),
work_total,
bytes_good_start,
"Disc::patch entered"
);
// Drive the recovery: build the per-pass context, then walk the
// ordered bad ranges. `run` owns inter-range cooldown + the
// pass-ending conditions; `patch_region` owns one range's loop.
let mut ctx = PatchCtx {
disc: self,
reader,
pipe: &pipe,
shared: &shared,
opts,
total_bytes,
decrypt_is_aacs,
state: PatchLoopState::new(bytes_good_before, total_bytes, initial_batch, work_total),
scoreboard: HandlerScoreboard::default(),
wedge_streak: 0,
};
ctx.run(&bad_ranges)?;
ctx.scoreboard.log();
let PatchCtx { state, .. } = ctx;
// Drain the consumer thread: drop tx, wait for `close` to run
// sync_all + mapfile.flush, then take the final stats from the
// sink's summary. `close` failing on a regular-file sync_all is
// surfaced here as `Error::IoError`, matching pre-split
// behaviour.
let summary = pipe.finish()?;
let outcome = build_outcome(
&state,
&summary,
path,
total_bytes,
bad_ranges.len(),
opts.wedged_threshold,
);
tracing::info!(
target: "freemkv::scan",
phase = "patch",
recovered = outcome.bytes_recovered_this_pass,
halted = outcome.halted,
wedged_exit = outcome.wedged_exit,
elapsed_ms = patch_t0.elapsed().as_millis() as u64,
"end"
);
Ok(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The flat bandit pool must contain every handler from every tier, with a
/// UNIQUE name per config — the scoreboard keys on the name, so any two
/// handlers sharing a name would blur each other's decayed-yield ranking.
#[test]
fn flat_pool_is_all_tiers_with_unique_names() {
let flat = build_flat_pool();
let tiered: usize = (0..PATCH_TIERS).map(|t| build_tier_handlers(t).len()).sum();
assert_eq!(
flat.len(),
tiered,
"flat pool must equal the sum of all tier rosters"
);
let mut names: Vec<String> = flat.iter().map(|h| h.name()).collect();
let total = names.len();
names.sort();
names.dedup();
assert_eq!(
names.len(),
total,
"every flat-pool handler must have a unique scoreboard name"
);
}
/// The flat-mode toggle: unset / empty / "0" → tier ladder; anything else →
/// flat bandit. (Env is process-global; this asserts the parse logic via the
/// same rules `patch_flat_mode` applies.)
#[test]
fn flat_mode_toggle_parsing() {
let on = |v: &str| !v.is_empty() && v != "0";
assert!(!on(""));
assert!(!on("0"));
assert!(on("1"));
assert!(on("true"));
}
/// Transport failure (status=0xFF, USB-bridge crash) must be recognised and
/// abort the pass, rather than being treated as an ordinary bad sector and
/// hammering the crashed device for up to the per-range watchdog budget. The
/// transport-failure classification predicate is not unit-testable in
/// isolation, so this guards the predicate the production early-return keys
/// off, and the contrast that an ordinary read error is NOT misclassified as
/// a transport failure.
#[test]
fn transport_failure_is_recognised_for_patch_abort() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
// The exact shape Drive::read surfaces on a bridge crash.
let tf = Error::DiscRead {
sector: 1_392_314,
status: Some(SCSI_STATUS_TRANSPORT_FAILURE),
sense: None,
};
assert!(
tf.is_scsi_transport_failure(),
"a DiscRead with status=0xFF must classify as a transport failure so \
patch aborts the pass"
);
// The raw ScsiError form (e.g. straight from the transport) too.
let tf_raw = Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(tf_raw.is_scsi_transport_failure());
// An ordinary recoverable bad sector (CHECK CONDITION with sense) must
// NOT trip the transport-failure abort — it should still be retried /
// marked NonTrimmed, not abort the whole pass.
let bad_sector = Error::DiscRead {
sector: 1_392_314,
status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x03,
asc: 0x11,
ascq: 0x00,
}),
};
assert!(
!bad_sector.is_scsi_transport_failure(),
"an ordinary bad-sector CHECK CONDITION must not be misclassified as \
a transport failure"
);
}
#[test]
fn recovery_read_widens_unaligned_aacs_window() {
// A mid-unit AACS read must widen to the enclosing 3-sector unit
// (so the decrypting source accepts it) and copy back exactly the
// requested sector. Each sector is filled with its own LBA's low
// byte so we can prove which window came back.
struct RecordReader {
saw_lba: u32,
saw_count: u16,
}
impl SectorSource for RecordReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.saw_lba = lba;
self.saw_count = count;
for s in 0..count as usize {
buf[s * 2048..(s + 1) * 2048].fill((lba as usize + s) as u8);
}
Ok(count as usize * 2048)
}
}
let mut rr = RecordReader {
saw_lba: 0,
saw_count: 0,
};
let mut buf = vec![0u8; 2048];
// Request lba=4 (4 % 3 == 1, mid-unit), count=1.
let n = recovery_read(&mut rr, true, 4, 1, &mut buf, true, false).unwrap();
assert_eq!(n, 2048);
assert_eq!(rr.saw_lba, 3, "widened down to the unit-aligned start");
assert_eq!(rr.saw_count, 3, "widened to a whole 3-sector unit");
assert_eq!(
buf[0], 4u8,
"copied back the requested sector (lba 4), not the unit head (lba 3)"
);
}
// ----------------------------------------------------------------
// SubRanges — the still-bad work-list the per-section recovery
// phases (#50) shrink. Pure data structure; exhaustively tested so
// each future phase helper can assert on its residual ranges.
// ----------------------------------------------------------------
#[test]
fn subranges_from_section_and_basics() {
let s = SubRanges::from_section(2048, 10 * 2048);
assert!(!s.is_empty());
assert_eq!(s.total_len(), 10 * 2048);
assert_eq!(s.ranges(), &[(2048, 10 * 2048)]);
assert!(SubRanges::from_section(2048, 0).is_empty());
assert!(SubRanges::default().is_empty());
}
#[test]
fn subranges_remove_middle_splits() {
// [0,20k) minus [8k,12k) -> [0,8k) + [12k,20k)
let mut s = SubRanges::from_section(0, 20 * 1024);
s.remove(8 * 1024, 4 * 1024);
assert_eq!(s.ranges(), &[(0, 8 * 1024), (12 * 1024, 8 * 1024)]);
assert_eq!(s.total_len(), 16 * 1024);
}
#[test]
fn subranges_remove_prefix_suffix_and_whole() {
// prefix
let mut s = SubRanges::from_section(1000, 1000);
s.remove(900, 200); // [1000,1100) trimmed off the front
assert_eq!(s.ranges(), &[(1100, 900)]);
// suffix
let mut s = SubRanges::from_section(1000, 1000);
s.remove(1800, 500); // [1800,2000) trimmed off the back
assert_eq!(s.ranges(), &[(1000, 800)]);
// whole (exact + over-cover both clear it)
let mut s = SubRanges::from_section(1000, 1000);
s.remove(1000, 1000);
assert!(s.is_empty());
let mut s = SubRanges::from_section(1000, 1000);
s.remove(0, 100_000);
assert!(s.is_empty());
}
#[test]
fn subranges_remove_gap_and_zero_are_noops() {
let mut s = SubRanges::from_section(1000, 1000);
s.remove(5000, 1000); // disjoint, after
s.remove(0, 500); // disjoint, before
s.remove(1200, 0); // zero-len
assert_eq!(s.ranges(), &[(1000, 1000)]);
}
#[test]
fn subranges_remove_spanning_two_ranges() {
// two sub-ranges, removal straddling the gap trims the inner edges
let mut s = SubRanges::from_section(0, 4096);
s.remove(1024, 1024); // -> [0,1024) + [2048,4096)
assert_eq!(s.ranges(), &[(0, 1024), (2048, 2048)]);
s.remove(512, 2048); // covers tail of first + head of second
assert_eq!(s.ranges(), &[(0, 512), (2560, 1536)]);
}
}