//! 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, err: Option, } 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 }, /// 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>, /// 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, } /// 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>` 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>)> { 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 for PatchSink { type Output = PatchSummary; fn apply(&mut self, item: PatchItem) -> std::result::Result { 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 { // 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, 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, 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( reader: &mut R, decrypt_is_aacs: bool, lba: u32, count: u16, buf: &mut [u8], recovery: bool, fua: bool, ) -> Result { 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, shared: &'a Mutex, 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> { 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> { 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::().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 = 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 { 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> = 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, ) -> 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 { 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>` 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::::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 = 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 { 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)]); } }