diff --git a/Cargo.toml b/Cargo.toml index e2f05ed..600b2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "1.5.2" +version = "1.6.0" edition = "2024" rust-version = "1.86" license = "MIT" diff --git a/src/decrypt.rs b/src/decrypt.rs index 07319e6..2cef802 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -381,7 +381,7 @@ impl AacsKeyMap { /// decorator can dispatch uniformly. A map index outside the held pool is a /// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every /// selectable index is present, so a gap here is a resolver bug, not silent loss. -pub fn decrypt_sectors_mapped( +pub(crate) fn decrypt_sectors_mapped( buf: &mut [u8], keys: &DecryptKeys, base_lba: u32, diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 060527d..3dd0d9c 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1,6 +1,7 @@ //! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`). //! -//! Sibling of [`Disc::copy`](super::Disc::copy) (disc → ISO sector dump), +//! Sibling of the disc→ISO sector dump (the sweep/patch recovery passes, which +//! now live in the `freemkv-engine` crate), //! specialized to write **per file** rather than a whole image, applying //! decryption on the way out, and **without** any multipass / recovery //! orchestration. 1-shot, decrypt-only. diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs deleted file mode 100644 index dd0d69f..0000000 --- a/src/disc/mapfile.rs +++ /dev/null @@ -1,1670 +0,0 @@ -//! ddrescue-compatible mapfile for tracking rip progress. -//! -//! Records which byte ranges of a disc image are good, unreadable, -//! or not-yet-attempted. Written as plain text so it's greppable, -//! human-editable, and interoperates with ddrescue's own tools. -//! -//! Format: -//! ```text -//! # Rescue Logfile. Created by libfreemkv vX.Y.Z -//! # Current pos / status / pass / pass_time (ddrescue state machine — we only populate pos) -//! 0x000000000 ? 1 0 -//! # pos size status -//! 0x000000000 0x12345678 + -//! 0x012345678 0x00001000 - -//! 0x012346678 0x01234500 ? -//! ``` -//! -//! Status chars: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished. -//! -//! The mapfile is flushed to disk at most once per `FLUSH_INTERVAL` -//! during `record()` calls, plus on explicit `flush()` and on `Drop`. -//! This bounds atomic-rename RPC rate on networked staging (e.g. NFS) -//! where per-record persists otherwise serialize the rip pipeline. - -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -/// Minimum interval between mapfile persists. `record()` updates in-memory -/// state every call but only writes to disk when this interval has elapsed -/// since the last persist (or when `flush()` is called explicitly, or on -/// `Drop`). Bounds RPC rate on NFS staging where atomic-rename per record -/// otherwise dominates throughput. On crash the worst-case progress loss -/// is one interval's worth of records. -const FLUSH_INTERVAL: Duration = Duration::from_millis(1000); - -/// Status of a byte range in the mapfile. ddrescue-compatible. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SectorStatus { - /// `?` — not yet attempted. Initial state for a fresh mapfile. - NonTried, - /// `*` — fast-pass read failed; edges need trimming. - NonTrimmed, - /// `/` — trimmed; interior needs sector scrape. - NonScraped, - /// `-` — drive couldn't read it this session. - Unreadable, - /// `+` — good. - Finished, -} - -impl SectorStatus { - /// The single ddrescue status character for this status - /// (`?`/`*`/`/`/`-`/`+`). - pub fn to_char(self) -> char { - match self { - Self::NonTried => '?', - Self::NonTrimmed => '*', - Self::NonScraped => '/', - Self::Unreadable => '-', - Self::Finished => '+', - } - } - /// Parse a ddrescue status character into a `SectorStatus`. Returns - /// `None` for any character that is not one of `?*/-+`. - pub fn from_char(c: char) -> Option { - Some(match c { - '?' => Self::NonTried, - '*' => Self::NonTrimmed, - '/' => Self::NonScraped, - '-' => Self::Unreadable, - '+' => Self::Finished, - _ => return None, - }) - } -} - -/// One contiguous range of bytes with a status. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct MapEntry { - pub pos: u64, - pub size: u64, - pub status: SectorStatus, -} - -/// Summary statistics over all entries. -/// -/// `bytes_pending` aggregates `NonTried + NonTrimmed + NonScraped` for -/// back-compat. `bytes_nontried` and `bytes_retryable` (= NonTrimmed + -/// NonScraped) split that aggregate so UIs can distinguish *unread* -/// territory (still ahead of Pass 1's read head) from *needs-retry* -/// territory (Pass 1 already encountered, queued for Pass 2-N). -#[derive(Debug, Clone, Copy, Default)] -pub struct MapStats { - pub bytes_total: u64, - pub bytes_good: u64, - pub bytes_unreadable: u64, - pub bytes_pending: u64, - /// Sectors Pass 1 hasn't reached yet (`NonTried`). Subset of - /// `bytes_pending`. - pub bytes_nontried: u64, - /// Sectors flagged for Pass 2-N retry — `NonTrimmed` (multi-sector - /// read failed; needs split) + `NonScraped` (small-block read - /// partially recovered; remainder still pending). Subset of - /// `bytes_pending`. This is the right signal for a "MAYBE / will - /// retry" UI bucket; `bytes_pending` over-counts because it folds - /// in `bytes_nontried`. - pub bytes_retryable: u64, - /// Number of distinct `Unreadable` ranges (for UI display). - /// Computed by `compute_stats` (counts coalesced `-` entries). - pub num_bad_ranges: u32, - /// Largest gap among unreadable ranges in milliseconds. Computed as - /// largest range size / bytes_per_sec * 1000. Set by caller (autorip) - /// since bytes_per_sec is application-specific. - pub main_lost_ms: f64, -} - -/// Time-batched mapfile. `record()` keeps in-memory state up-to-date on -/// every call; persists to disk at most once per `FLUSH_INTERVAL`. -/// Explicit `flush()` and `Drop` guarantee state is on disk after a sweep -/// or patch finishes. On hard crash the worst-case loss is one flush -/// interval of records — the file's payload bytes are unaffected. -pub struct Mapfile { - path: PathBuf, - entries: Vec, - total_size: u64, - version: String, - /// Incrementally maintained stats — updated on every `record()` call - /// so `stats()` is O(1) instead of O(n). - stats: MapStats, - /// True when in-memory state has changed but `write_to_disk` has not - /// yet captured it. - dirty: bool, - /// Wall-clock timestamp of the last successful `write_to_disk` (or - /// the moment the mapfile was constructed, whichever is later). - last_flushed: Instant, - /// AACS Volume ID (16 bytes) for the disc, persisted as a - /// `# freemkv-vid:` comment header so it survives to deferred-mux / - /// resume without altering the ISO payload or breaking ddrescue - /// data-line parsing. `None` for unencrypted / non-AACS discs. - /// - /// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved - /// persists the keys (`unit_keys`) and NOT the VID — the keys are the final - /// answer, so deferred-mux/resume decrypts directly with no key service. A - /// disc that did NOT resolve persists only the VID, the retry-able "still - /// need a key" marker (a future mux can re-ask the key service with it). - vid: Option<[u8; 16]>, - /// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:` - /// comment headers when the disc was successfully keyed. Mutually exclusive - /// with `vid` (see above). Empty when unresolved. - unit_keys: Vec<(u32, [u8; 16])>, -} - -impl Mapfile { - /// Create a new mapfile with one `NonTried` region covering the whole disc. - /// Writes to disk immediately so a resume can pick up even if the caller - /// never records anything. - pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result { - let mut mf = Self { - path: path.to_path_buf(), - entries: vec![MapEntry { - pos: 0, - size: total_size, - status: SectorStatus::NonTried, - }], - total_size, - version: version.to_string(), - stats: MapStats { - bytes_total: total_size, - bytes_pending: total_size, - bytes_nontried: total_size, - ..Default::default() - }, - dirty: false, - last_flushed: Instant::now(), - vid: None, - unit_keys: Vec::new(), - }; - // Eager initial persist so a resume can pick this up even if - // `record()` is never called. - mf.write_to_disk()?; - mf.last_flushed = Instant::now(); - Ok(mf) - } - - /// Load an existing mapfile from disk. - pub fn load(path: &Path) -> io::Result { - let text = std::fs::read_to_string(path)?; - let mut entries = Vec::new(); - let mut saw_current_line = false; - let mut version = String::from("unknown"); - let mut vid: Option<[u8; 16]> = None; - let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new(); - for line in text.lines() { - let t = line.trim(); - if t.is_empty() { - continue; - } - if let Some(rest) = t.strip_prefix('#') { - let rest = rest.trim(); - if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") { - version = v.to_string(); - } - if let Some(hex) = rest.strip_prefix("freemkv-vid:") { - // Best-effort: a malformed or short VID comment is - // ignored rather than failing the whole load. - vid = parse_vid_hex(hex.trim()); - } - if let Some(uk) = rest.strip_prefix("freemkv-uk:") { - // `:<32hex>`. Best-effort: a malformed line is skipped. - if let Some(entry) = parse_uk_line(uk.trim()) { - unit_keys.push(entry); - } - } - continue; - } - // First non-comment line is the "current" state line - // (`pos status [pass] [pass_time]`). We ignore its contents but - // skip over it. - if !saw_current_line { - saw_current_line = true; - // Discriminate by ddrescue's actual line shape, not by a - // `0x`-prefix heuristic (which dropped a valid first data line - // whose size field happened to lack `0x`). A *current* line's - // 2nd field is a single status char (`?*/-+`); a *data* line's - // 2nd field is the hex size, with the status char in the 3rd. - // So: single-char-and-valid-status 2nd field ⇒ current line - // (skip); anything else ⇒ fall through to entry parse. - let fields: Vec<&str> = t.split_whitespace().collect(); - let is_current_line = fields - .get(1) - .and_then(|f| { - let mut chars = f.chars(); - match (chars.next(), chars.next()) { - // Exactly one char that is a valid status char. - (Some(c), None) => SectorStatus::from_char(c), - _ => None, - } - }) - .is_some(); - if is_current_line { - continue; - } - // Otherwise it's a data line — fall through to entry parse. - } - // Entry: `pos size statuschar` - let fields: Vec<&str> = t.split_whitespace().collect(); - if fields.len() < 3 { - continue; - } - let pos = parse_hex(fields[0])?; - let size = parse_hex(fields[1])?; - // Reject an entry whose pos+size overflows u64 up front. The - // downstream overlap/coalesce/next_with code adds pos+size - // freely; a crafted/corrupt line like - // `0xfffffffffffffff0 0x20 +` would otherwise panic (debug) - // or wrap to a tiny range (release), corrupting stats and - // resume logic. - if pos.checked_add(size).is_none() { - let e: io::Error = crate::error::Error::MapfileInvalid { kind: "range" }.into(); - return Err(e); - } - // A zero-size entry is degenerate: it contributes nothing to the - // partition yet trips overlap/coalesce arithmetic (two entries can - // share the same pos). Reject it rather than carry it through. - if size == 0 { - let e: io::Error = crate::error::Error::MapfileInvalid { kind: "zero_size" }.into(); - return Err(e); - } - let status = fields[2] - .chars() - .next() - .and_then(SectorStatus::from_char) - .ok_or_else(|| { - // No English text — the variant carries a stable - // language-neutral kind identifier (`status_char`). - let e: io::Error = crate::error::Error::MapfileInvalid { - kind: "status_char", - } - .into(); - e - })?; - entries.push(MapEntry { pos, size, status }); - } - entries.sort_by_key(|e| e.pos); - // Reject overlapping ranges, then COALESCE-FILL any internal gaps - // with synthetic NonTried entries. A well-formed ddrescue mapfile - // is a *gap-free* disjoint partition of [0, total_size). - // - // Overlaps (from a corrupt/hand-edited file) would make - // compute_stats double-count, so bytes_good / bytes_unreadable / - // bytes_pending could exceed bytes_total and inflate resume / - // abort-on-loss decisions and >100% progress — hard-reject those. - // - // GAPS are a subtler hazard: total_size is derived from the last - // entry's end, so a holed mapfile passes the caller's - // `covers_disc = (total_size == disc_size)` check and copy() would - // report complete=true even though the hole was never read. Rather - // than hard-reject (which would strand existing partial mapfiles), - // we fill every gap — leading, internal, and any between entries — - // with a NonTried entry so the gap is visible to the resume - // sweep's NonTried region list and actually gets read. (A trailing - // gap up to the disc size is filled by the caller's full-sweep - // path when total_size < disc_size; here we only have the mapfile's - // own extent to reason about.) - let mut filled: Vec = Vec::with_capacity(entries.len() + 1); - let mut cursor: u64 = 0; - for e in entries { - if e.pos < cursor { - let err: io::Error = crate::error::Error::MapfileInvalid { kind: "overlap" }.into(); - return Err(err); - } - if e.pos > cursor { - // Leading or internal gap — fill it as NonTried. - filled.push(MapEntry { - pos: cursor, - size: e.pos - cursor, - status: SectorStatus::NonTried, - }); - } - cursor = e.pos.saturating_add(e.size); - filled.push(e); - } - let entries = filled; - let total_size = entries - .last() - .map(|e| e.pos.saturating_add(e.size)) - .unwrap_or(0); - // Enforce the keys-XOR-vid invariant that set_unit_keys() - // guarantees: a corrupt/hand-edited file carrying both comment - // types would otherwise load with vid=Some AND non-empty - // unit_keys, violating the invariant downstream code relies on. - // Unit keys win, matching the setter (it clears vid when keys - // are present). - if !unit_keys.is_empty() { - vid = None; - } - let stats = Self::compute_stats(&entries, total_size); - Ok(Self { - path: path.to_path_buf(), - entries, - total_size, - version, - stats, - dirty: false, - last_flushed: Instant::now(), - vid, - unit_keys, - }) - } - - /// Load if the file exists, otherwise create a fresh mapfile. - pub fn open_or_create(path: &Path, total_size: u64, version: &str) -> io::Result { - match Self::load(path) { - Ok(mf) => { - // load() derives total_size from the last entry's - // pos+size; if that disagrees with the caller's - // expected disc size (different disc, edited/partial - // file, trimmed trailing region) the downstream - // resume/progress math keys off the wrong basis. Surface - // it so an operator can spot a mismatched mapfile rather - // than failing the resume outright. - if mf.total_size != total_size { - tracing::warn!( - target: "freemkv::disc", - phase = "mapfile_total_size_mismatch", - loaded_total = mf.total_size, - supplied_total = total_size, - path = %path.display(), - "loaded mapfile coverage differs from supplied disc size" - ); - } - Ok(mf) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => { - Self::create(path, total_size, version) - } - Err(e) => Err(e), - } - } - - /// Mark a byte range as having the given status. Splits any overlapping - /// existing entries, merges with adjacent same-status entries, and flushes - /// to disk. - pub fn record(&mut self, pos: u64, size: u64, status: SectorStatus) -> io::Result<()> { - if size == 0 { - return Ok(()); - } - // Mirror load()'s overflow contract: reject a range that would - // wrap u64 rather than storing a saturated entry narrower than - // its size, which load() would then reject on the next resume - // (making the mapfile unreadable). - let Some(end) = pos.checked_add(size) else { - let e: io::Error = crate::error::Error::MapfileInvalid { kind: "range" }.into(); - return Err(e); - }; - let mut new_entries = Vec::with_capacity(self.entries.len() + 2); - - for e in self.entries.drain(..) { - let e_end = e.pos.saturating_add(e.size); - if e_end <= pos || e.pos >= end { - // entirely before or after — keep - new_entries.push(e); - continue; - } - // Overlap — keep portions outside [pos, end) - if e.pos < pos { - new_entries.push(MapEntry { - pos: e.pos, - size: pos - e.pos, - status: e.status, - }); - } - if e_end > end { - new_entries.push(MapEntry { - pos: end, - size: e_end - end, - status: e.status, - }); - } - } - new_entries.push(MapEntry { pos, size, status }); - new_entries.sort_by_key(|e| e.pos); - - // Coalesce adjacent same-status entries. - let mut merged: Vec = Vec::with_capacity(new_entries.len()); - for e in new_entries { - if let Some(last) = merged.last_mut() { - if last.pos.saturating_add(last.size) == e.pos && last.status == e.status { - last.size = last.size.saturating_add(e.size); - continue; - } - } - merged.push(e); - } - - // Recompute stats from merged entries. record() is already O(n) due to - // drain-and-rebuild, so this is a constant-factor overhead. The critical - // win is that stats() is now O(1) — called millions of times in the hot - // path during sweep/patch, it just returns the cached value. - self.stats = Self::compute_stats(&merged, self.total_size); - self.entries = merged; - self.dirty = true; - if self.last_flushed.elapsed() >= FLUSH_INTERVAL { - self.write_to_disk()?; - self.dirty = false; - self.last_flushed = Instant::now(); - } - Ok(()) - } - - /// Persist any pending in-memory changes to disk. No-op if clean. - /// Callers (sweep/patch finalisation) invoke this after their last - /// `record()` to guarantee state is durable before returning. - pub fn flush(&mut self) -> io::Result<()> { - if self.dirty { - self.write_to_disk()?; - self.dirty = false; - self.last_flushed = Instant::now(); - } - Ok(()) - } - - /// Record the disc's 16-byte AACS Volume ID so it persists in the - /// mapfile's comment header. Marks the mapfile dirty; the next - /// `flush()` / `Drop` writes the `# freemkv-vid:` line. Does not - /// touch the ISO payload or the ddrescue data lines. - pub fn set_vid(&mut self, vid: [u8; 16]) { - self.vid = Some(vid); - self.dirty = true; - } - - /// The disc's AACS Volume ID, if one was set or parsed from a - /// `# freemkv-vid:` comment on load. `None` for unencrypted / - /// non-AACS discs. - pub fn vid(&self) -> Option<[u8; 16]> { - self.vid - } - - /// Record the disc's decrypted AACS unit keys so they persist in the - /// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux / - /// resume decrypts directly from these with no key-service round-trip. - /// Setting keys clears any VID — the mapfile holds keys XOR VID, never both - /// (keys are the final answer; VID is only the "still unresolved" marker). - pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) { - self.unit_keys = keys.to_vec(); - if !self.unit_keys.is_empty() { - self.vid = None; - } - self.dirty = true; - } - - /// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from - /// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`). - pub fn unit_keys(&self) -> &[(u32, [u8; 16])] { - &self.unit_keys - } - - /// All map entries, sorted ascending by `pos` and (after load) - /// guaranteed disjoint and non-overflowing. - pub(crate) fn entries(&self) -> &[MapEntry] { - &self.entries - } - - /// Total image size in bytes, i.e. the end byte of the last entry. - pub fn total_size(&self) -> u64 { - self.total_size - } - - /// First range with a given status starting at or after `from`. - pub fn next_with(&self, from: u64, status: SectorStatus) -> Option<(u64, u64)> { - for e in &self.entries { - if e.status != status { - continue; - } - let e_end = e.pos.saturating_add(e.size); - if e_end <= from { - continue; - } - let start = e.pos.max(from); - return Some((start, e_end - start)); - } - None - } - - /// All ranges matching one of the given statuses, in position order. - pub fn ranges_with(&self, statuses: &[SectorStatus]) -> Vec<(u64, u64)> { - self.entries - .iter() - .filter(|e| statuses.contains(&e.status)) - .map(|e| (e.pos, e.size)) - .collect() - } - - /// Snapshot of the incrementally-maintained summary statistics. - /// O(1) — returns the cached `MapStats`. - pub fn stats(&self) -> MapStats { - self.stats - } - - fn compute_stats(entries: &[MapEntry], total_size: u64) -> MapStats { - let mut s = MapStats { - bytes_total: total_size, - ..Default::default() - }; - for e in entries { - match e.status { - SectorStatus::Finished => s.bytes_good += e.size, - SectorStatus::Unreadable => { - s.bytes_unreadable += e.size; - s.num_bad_ranges += 1; - } - SectorStatus::NonTried => { - s.bytes_pending += e.size; - s.bytes_nontried += e.size; - } - SectorStatus::NonTrimmed | SectorStatus::NonScraped => { - s.bytes_pending += e.size; - s.bytes_retryable += e.size; - } - } - } - s - } - - fn write_to_disk(&self) -> io::Result<()> { - // Write to a tempfile then rename for atomicity. Appending ".tmp" - // rather than `with_extension` so we don't clobber the original - // extension (which may already be ".mapfile"). - let tmp = { - let mut s = self.path.clone().into_os_string(); - s.push(".tmp"); - PathBuf::from(s) - }; - { - let file = std::fs::File::create(&tmp)?; - let mut w = std::io::BufWriter::new(file); - writeln!(w, "# Rescue Logfile. Created by {}", self.version)?; - // VID comment lives in the header block. ddrescue treats any - // `#`-prefixed line as a comment, so this round-trips through - // our `load()` without affecting the `pos size status` data - // parser. 16 bytes → 32 lowercase hex chars. - // KEYS XOR VID: a keyed disc persists its unit keys (the final - // answer — deferred-mux decrypts directly); an unresolved disc - // persists only the VID (the retry marker, so a future mux can - // re-ask the key service). Never both. - use std::fmt::Write as _; - if !self.unit_keys.is_empty() { - for (cps, key) in &self.unit_keys { - let mut hex = String::with_capacity(32); - for b in key { - let _ = write!(hex, "{b:02x}"); - } - writeln!(w, "# freemkv-uk: {cps}:{hex}")?; - } - } else if let Some(vid) = self.vid { - let mut hex = String::with_capacity(32); - for b in vid { - let _ = write!(hex, "{b:02x}"); - } - writeln!(w, "# freemkv-vid: {hex}")?; - } - writeln!(w, "# Current pos / status / pass / pass_time")?; - writeln!(w, "0x000000000 ? 1 0")?; - writeln!(w, "# pos size status")?; - for e in &self.entries { - writeln!( - w, - "0x{:09x} 0x{:09x} {}", - e.pos, - e.size, - e.status.to_char() - )?; - } - w.flush()?; - // fsync the tmp file before the rename so the bytes are durable on - // disk (notably on NFS, where a rename can otherwise reach the - // server before the data does and leave a truncated mapfile after - // a crash). Recover the File from the BufWriter to call sync_all. - let file = w.into_inner().map_err(|e| e.into_error())?; - file.sync_all()?; - } - std::fs::rename(&tmp, &self.path)?; - // fsync the parent directory so the rename itself is durable. Syncing - // the tmp file's bytes (above) is not enough: after the rename the new - // dirent for the final mapfile name lives only in the directory's - // page cache, so a crash / power loss in the rename-commit window can - // lose it and leave resume reading a stale or absent mapfile even - // though the data was synced. On NFS — the case the tmp-fsync guards — - // this window is the wide one. Best-effort: a dir that can't be - // opened/synced (some filesystems, Windows) is not a write failure. - if let Some(parent) = self.path.parent() { - crate::io::fsync::dir(parent); - } - Ok(()) - } -} - -impl Drop for Mapfile { - /// Best-effort flush on drop so a sweep / patch that returns early - /// (or unwinds) doesn't lose its in-memory state. Errors here are - /// swallowed because Drop has no way to surface them; explicit - /// `flush()` on the success path gives callers proper error handling. - fn drop(&mut self) { - let _ = self.flush(); - } -} - -/// Parse a 32-char lowercase/uppercase hex string into a 16-byte VID. -/// Returns `None` on any malformation (wrong length, non-hex) — the -/// caller treats a bad VID comment as simply absent rather than an -/// error, so a corrupt header never fails a mapfile load. -fn parse_vid_hex(s: &str) -> Option<[u8; 16]> { - // The one workspace hex parser (accepts an optional `0x`/`0X` prefix, - // byte-based so a multi-byte `# freemkv-vid:` comment rejects, never panics). - crate::hex::parse_hex_fixed::<16>(s) -} - -/// Parse a `# freemkv-uk:` value `:<32hex>` into `(cps_unit, key)`. Returns -/// `None` on any malformation so a corrupt line is ignored, never fatal. -fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> { - let (cps, hex) = s.split_once(':')?; - let cps: u32 = cps.trim().parse().ok()?; - let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser - Some((cps, key)) -} - -fn parse_hex(s: &str) -> io::Result { - let s = s.strip_prefix("0x").unwrap_or(s); - u64::from_str_radix(s, 16).map_err(|_| { - // Underlying ParseIntError dropped — its Display is OS-locale text. - // The typed variant carries `kind = "hex"` which is stable. - let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into(); - e - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn tmpfile(tag: &str) -> PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let name = format!( - "libfreemkv-mapfile-test-{}-{}-{}.mapfile", - std::process::id(), - tag, - n - ); - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/test-scratch"); - let _ = std::fs::create_dir_all(&dir); - dir.join(name) - } - - #[test] - fn create_has_one_nontried_region() { - let p = tmpfile("create_has_one_nontried_region"); - let _ = std::fs::remove_file(&p); - let mf = Mapfile::create(&p, 1000, "test").unwrap(); - assert_eq!(mf.entries().len(), 1); - assert_eq!(mf.entries()[0].pos, 0); - assert_eq!(mf.entries()[0].size, 1000); - assert_eq!(mf.entries()[0].status, SectorStatus::NonTried); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn record_splits_overlap() { - let p = tmpfile("record_splits_overlap"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(200, 100, SectorStatus::Finished).unwrap(); - let es = mf.entries(); - assert_eq!(es.len(), 3); - assert_eq!( - (es[0].pos, es[0].size, es[0].status), - (0, 200, SectorStatus::NonTried) - ); - assert_eq!( - (es[1].pos, es[1].size, es[1].status), - (200, 100, SectorStatus::Finished) - ); - assert_eq!( - (es[2].pos, es[2].size, es[2].status), - (300, 700, SectorStatus::NonTried) - ); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn record_coalesces_adjacent_same_status() { - let p = tmpfile("record_coalesces_adjacent_same_status"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 100, SectorStatus::Finished).unwrap(); - mf.record(200, 100, SectorStatus::Finished).unwrap(); - // Entries: [0..100 NonTried, 100..300 Finished (merged), 300..1000 NonTried] - let es = mf.entries(); - assert_eq!(es.len(), 3); - assert_eq!( - (es[1].pos, es[1].size, es[1].status), - (100, 200, SectorStatus::Finished) - ); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn record_replaces_existing_status() { - let p = tmpfile("record_replaces_existing_status"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(200, 100, SectorStatus::Unreadable).unwrap(); - mf.record(200, 100, SectorStatus::Finished).unwrap(); - let es = mf.entries(); - // The overwrite should result in all finished at 200..300, NonTried elsewhere — 3 entries. - assert_eq!(es.len(), 3); - assert_eq!(es[1].status, SectorStatus::Finished); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn round_trip_load() { - let p = tmpfile("round_trip_load"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 200, SectorStatus::Finished).unwrap(); - mf.record(500, 100, SectorStatus::Unreadable).unwrap(); - // record() batches; explicit flush before reading back from disk. - mf.flush().unwrap(); - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.entries(), mf.entries()); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn write_to_disk_fsyncs_and_leaves_no_tmp() { - // Regression: write_to_disk must recover the File from the BufWriter - // and sync_all() it before rename (NFS durability). The .tmp file - // must not survive a successful write, and the renamed mapfile must - // load back identically. - let p = tmpfile("write_to_disk_fsyncs"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 200, SectorStatus::Finished).unwrap(); - mf.write_to_disk().unwrap(); - - let mut tmp = p.clone().into_os_string(); - tmp.push(".tmp"); - assert!( - !PathBuf::from(&tmp).exists(), - "tmp file should be renamed away after a successful write" - ); - - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.entries(), mf.entries()); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn write_to_disk_fsyncs_parent_dir() { - // Regression: after rename(2), write_to_disk must fsync the parent - // directory so the new dirent is durable (not page-cache-only). We - // can't observe a power-loss window in a unit test, but we exercise - // the parent-fsync branch against a real subdirectory and confirm the - // best-effort dir-sync neither errors the write nor corrupts the - // round-trip. A missing/unsyncable dir must not fail the write. - let dir = tmpfile("write_to_disk_fsyncs_parent_dir"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let p = dir.join("disc.mapfile"); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 400, SectorStatus::Finished).unwrap(); - mf.record(400, 100, SectorStatus::Unreadable).unwrap(); - mf.write_to_disk().unwrap(); - - // The directly-called dir fsync helper must be a no-op-on-error, - // never a panic, even for a nonexistent directory. - crate::io::fsync::dir(&dir.join("does-not-exist")); - - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.entries(), mf.entries()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn stats_sum_correctly() { - let p = tmpfile("stats_sum_correctly"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 400, SectorStatus::Finished).unwrap(); - mf.record(400, 100, SectorStatus::Unreadable).unwrap(); - let s = mf.stats(); - assert_eq!(s.bytes_good, 400); - assert_eq!(s.bytes_unreadable, 100); - assert_eq!(s.bytes_pending, 500); - assert_eq!(s.bytes_total, 1000); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn ranges_with_filters() { - let p = tmpfile("ranges_with_filters"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 50, SectorStatus::Unreadable).unwrap(); - mf.record(300, 50, SectorStatus::Unreadable).unwrap(); - let bad = mf.ranges_with(&[SectorStatus::Unreadable]); - assert_eq!(bad, vec![(100, 50), (300, 50)]); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn stats_consistent_after_overlapping_records() { - let p = tmpfile("stats_consistent_after_overlapping"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - // Record some finished, some unreadable, some nontrimmed - mf.record(0, 300, SectorStatus::Finished).unwrap(); - mf.record(300, 200, SectorStatus::NonTrimmed).unwrap(); - mf.record(500, 100, SectorStatus::Unreadable).unwrap(); - mf.record(600, 400, SectorStatus::Finished).unwrap(); - - // Final entries: [0..300 Finished, 300..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished] - let s = mf.stats(); - assert_eq!(s.bytes_good, 700); // 300 + 400 - assert_eq!(s.bytes_unreadable, 100); // 100 - assert_eq!(s.bytes_pending, 200); // NonTrimmed only (NonTried=0) - assert_eq!(s.bytes_nontried, 0); - assert_eq!(s.bytes_retryable, 200); // NonTrimmed - assert_eq!(s.bytes_total, 1000); - - // Overwrite a NonTrimmed range with Finished - mf.record(300, 100, SectorStatus::Finished).unwrap(); - // Entries: [0..400 Finished, 400..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished] - let s2 = mf.stats(); - assert_eq!(s2.bytes_good, 800); // 400 + 400 - assert_eq!(s2.bytes_unreadable, 100); - assert_eq!(s2.bytes_pending, 100); // NonTrimmed only - assert_eq!(s2.bytes_retryable, 100); - - let _ = std::fs::remove_file(&p); - } - - #[test] - fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() { - let p = tmpfile("uk_round_trips"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 500, SectorStatus::Finished).unwrap(); - // Set a VID first, then unit keys: keys must WIN and clear the VID. - mf.set_vid([0xAA; 16]); - let keys: Vec<(u32, [u8; 16])> = vec![ - ( - 0, - [ - 0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1, - 0x35, 0x9b, 0xad, - ], - ), - (1, [0x11; 16]), - ]; - mf.set_unit_keys(&keys); - assert_eq!( - mf.vid(), - None, - "set_unit_keys must clear vid (keys XOR vid)" - ); - mf.flush().unwrap(); - - let text = std::fs::read_to_string(&p).unwrap(); - assert!( - text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"), - "uk comment format mismatch: {text}" - ); - assert!( - text.contains("# freemkv-uk: 1:11111111111111111111111111111111"), - "second uk missing: {text}" - ); - assert!( - !text.contains("# freemkv-vid:"), - "VID must NOT be written when keys are present: {text}" - ); - - // load() recovers the unit keys (and no VID). - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.unit_keys(), keys.as_slice()); - assert_eq!(loaded.vid(), None); - assert_eq!(loaded.entries(), mf.entries()); - - // VID-only path (no keys) still persists the VID as the retry marker. - let p2 = tmpfile("uk_vid_only"); - let _ = std::fs::remove_file(&p2); - let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap(); - mf2.set_vid([0xBB; 16]); - mf2.flush().unwrap(); - let loaded2 = Mapfile::load(&p2).unwrap(); - assert_eq!(loaded2.vid(), Some([0xBB; 16])); - assert!(loaded2.unit_keys().is_empty()); - let _ = std::fs::remove_file(&p); - let _ = std::fs::remove_file(&p2); - } - - #[test] - fn load_rejects_entry_whose_range_overflows_u64() { - let p = tmpfile("load_overflow"); - let _ = std::fs::remove_file(&p); - // pos near u64::MAX with a nonzero size overflows pos+size. - let body = format!("0x{:x} 0x10 +\n", u64::MAX - 4); - std::fs::write(&p, body).unwrap(); - let kind = match Mapfile::load(&p) { - Ok(_) => panic!("overflowing entry must be rejected"), - Err(e) => e.kind(), - }; - assert_eq!(kind, io::ErrorKind::InvalidData); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn record_rejects_range_overflowing_u64() { - let p = tmpfile("record_overflow"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - let err = mf - .record(u64::MAX - 4, 16, SectorStatus::Finished) - .expect_err("overflowing record must be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn load_enforces_keys_xor_vid_on_malformed_file() { - let p = tmpfile("load_keys_xor_vid"); - let _ = std::fs::remove_file(&p); - // Hand-craft a file carrying BOTH a vid comment and a uk comment - // (which write_to_disk would never emit together). load() must - // resolve to keys-only, matching set_unit_keys()'s invariant. - let body = "# freemkv-vid:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ - # freemkv-uk: 0:11111111111111111111111111111111\n\ - 0x0 0x200 +\n"; - std::fs::write(&p, body).unwrap(); - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!( - loaded.vid(), - None, - "load() must clear vid when unit keys are present" - ); - assert_eq!(loaded.unit_keys(), &[(0u32, [0x11u8; 16])]); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn vid_round_trips_and_data_lines_unaffected() { - let p = tmpfile("vid_round_trips"); - let _ = std::fs::remove_file(&p); - - // Build a mapfile with some data ranges, set a VID, persist. - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 200, SectorStatus::Finished).unwrap(); - mf.record(500, 100, SectorStatus::Unreadable).unwrap(); - mf.record(700, 50, SectorStatus::NonTrimmed).unwrap(); - let vid: [u8; 16] = [ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, - 0xee, 0xff, - ]; - mf.set_vid(vid); - mf.flush().unwrap(); - - // The saved file must contain the VID comment in lowercase hex. - let text = std::fs::read_to_string(&p).unwrap(); - assert!( - text.contains("# freemkv-vid:"), - "saved mapfile missing VID comment: {text}" - ); - assert!( - text.contains("# freemkv-vid: 00112233445566778899aabbccddeeff"), - "VID comment format mismatch: {text}" - ); - - // load() recovers the VID and the identical data ranges. - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.vid(), Some(vid)); - assert_eq!(loaded.entries(), mf.entries()); - - // A mapfile WITHOUT the VID comment must parse the same +/-/? - // data ranges as the one WITH it (comment ignored by parser). - let p2 = tmpfile("vid_round_trips_novid"); - let _ = std::fs::remove_file(&p2); - let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap(); - mf2.record(100, 200, SectorStatus::Finished).unwrap(); - mf2.record(500, 100, SectorStatus::Unreadable).unwrap(); - mf2.record(700, 50, SectorStatus::NonTrimmed).unwrap(); - mf2.flush().unwrap(); - let loaded_novid = Mapfile::load(&p2).unwrap(); - assert_eq!(loaded_novid.vid(), None); - assert_eq!(loaded_novid.entries(), loaded.entries()); - - // Malformed VID comments must not error the load (treated absent). - let mut bad = text.replace("00112233445566778899aabbccddeeff", "zzzz"); - let pbad = tmpfile("vid_round_trips_bad"); - let _ = std::fs::remove_file(&pbad); - std::fs::write(&pbad, &bad).unwrap(); - let loaded_bad = Mapfile::load(&pbad).unwrap(); - assert_eq!(loaded_bad.vid(), None); - assert_eq!(loaded_bad.entries(), loaded.entries()); - - // A load->save cycle preserves the VID (the patch-pass path). - bad.clear(); - let resaved = tmpfile("vid_round_trips_resave"); - let _ = std::fs::remove_file(&resaved); - let mut reloaded = Mapfile::load(&p).unwrap(); - // Repoint at a fresh path and flush; mark dirty via a no-op record. - reloaded.path = resaved.clone(); - reloaded.dirty = true; - reloaded.flush().unwrap(); - let again = Mapfile::load(&resaved).unwrap(); - assert_eq!(again.vid(), Some(vid)); - - let _ = std::fs::remove_file(&p); - let _ = std::fs::remove_file(&p2); - let _ = std::fs::remove_file(&pbad); - let _ = std::fs::remove_file(&resaved); - } - - #[test] - fn parse_vid_hex_does_not_panic_on_multibyte_32_byte_input() { - // A 32-BYTE comment containing a multi-byte char would make the - // old `&s[i*2..i*2+2]` slice fall inside a char boundary and - // panic. Must return None instead. - let s = "中".to_string() + &"a".repeat(29); // 3 + 29 = 32 bytes - assert_eq!(s.len(), 32); - assert_eq!(parse_vid_hex(&s), None); - // A valid 32-char ASCII hex string still parses. - assert_eq!( - parse_vid_hex("00112233445566778899aabbccddeeff"), - Some([ - 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, - 0xee, 0xff, - ]) - ); - } - - #[test] - fn load_rejects_overflowing_pos_plus_size() { - let p = tmpfile("load_rejects_overflow"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0xfffffffffffffff0 0x20 +\n", - ) - .unwrap(); - assert!( - Mapfile::load(&p).is_err(), - "a pos+size that overflows u64 must be rejected, not wrap" - ); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn load_rejects_overlapping_ranges() { - let p = tmpfile("load_rejects_overlap"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000000 0x00000100 +\n\ - 0x000000080 0x00000100 -\n", - ) - .unwrap(); - assert!( - Mapfile::load(&p).is_err(), - "overlapping ranges must be rejected so stats can't double-count" - ); - let _ = std::fs::remove_file(&p); - } - - /// Regression: a mapfile with an INTERNAL hole (a byte range no entry - /// covers) must load with the hole filled as NonTried, so the hole is - /// visible to resume (counted as pending, not silently "complete"). - /// Without the fill, total_size (= last entry's end) would still equal - /// the disc size and copy()'s `covers_disc && bad_bytes == 0` check - /// would report a holed rip as complete. - #[test] - fn load_fills_internal_gap_as_nontried() { - let p = tmpfile("load_fills_internal_gap"); - let _ = std::fs::remove_file(&p); - // Two Finished entries: [0,0x100) and [0x200,0x300). The hole at - // [0x100,0x200) is never covered. - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000000 0x00000100 +\n\ - 0x000000200 0x00000100 +\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).expect("holed mapfile must load (gap filled, not rejected)"); - // The hole [0x100,0x200) must now be a NonTried entry. - let hole = mf - .entries() - .iter() - .find(|e| e.pos == 0x100) - .expect("internal gap must be filled with a synthetic entry"); - assert_eq!(hole.size, 0x100, "filled gap covers the whole hole"); - assert_eq!( - hole.status, - SectorStatus::NonTried, - "filled gap must be NonTried so resume reads it" - ); - // total_size unchanged (last entry end), but the hole is now pending. - assert_eq!(mf.total_size(), 0x300); - assert!( - mf.stats().bytes_pending >= 0x100, - "the hole must count as pending so copy() doesn't report complete" - ); - let _ = std::fs::remove_file(&p); - } - - /// Regression: a LEADING gap (first entry doesn't start at 0) is filled - /// as NonTried too, so resume reads the head of the disc. - #[test] - fn load_fills_leading_gap_as_nontried() { - let p = tmpfile("load_fills_leading_gap"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000080 0x00000100 +\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).expect("leading-gap mapfile must load"); - let head = mf - .entries() - .first() - .expect("must have a leading fill entry"); - assert_eq!(head.pos, 0, "fill must start at byte 0"); - assert_eq!(head.size, 0x80); - assert_eq!(head.status, SectorStatus::NonTried); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn num_bad_ranges_counts_unreadable_entries() { - let p = tmpfile("num_bad_ranges"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 50, SectorStatus::Unreadable).unwrap(); - mf.record(300, 50, SectorStatus::Unreadable).unwrap(); - assert_eq!(mf.stats().num_bad_ranges, 2); - let _ = std::fs::remove_file(&p); - } - - // ── status char round-trip (ddrescue alphabet ?*/-+) ────────── - - /// Every SectorStatus must round-trip through to_char/from_char, and - /// the chars must be the exact ddrescue alphabet (header doc: `?` `*` - /// `/` `-` `+`). A swapped mapping would silently misclassify resume - /// state (e.g. a good sector read back as unreadable). - #[test] - fn status_char_round_trip_is_ddrescue_alphabet() { - let pairs = [ - (SectorStatus::NonTried, '?'), - (SectorStatus::NonTrimmed, '*'), - (SectorStatus::NonScraped, '/'), - (SectorStatus::Unreadable, '-'), - (SectorStatus::Finished, '+'), - ]; - for (st, ch) in pairs { - assert_eq!(st.to_char(), ch, "{st:?} must map to '{ch}'"); - assert_eq!(SectorStatus::from_char(ch), Some(st)); - } - // Any char outside the alphabet is rejected. - for bad in ['x', ' ', '0', '#', '?'.to_ascii_uppercase()] { - if "?*/-+".contains(bad) { - continue; - } - assert_eq!( - SectorStatus::from_char(bad), - None, - "'{bad}' is not a status" - ); - } - } - - // ── parse_hex / parse_uk_line / parse_vid_hex error paths ───── - - /// parse_hex accepts both `0x`-prefixed and bare hex (ddrescue writes - /// `0x`-prefixed). A non-hex field is a MapfileInvalid{kind:"hex"}. - #[test] - fn parse_hex_accepts_prefixed_and_bare_rejects_garbage() { - assert_eq!(parse_hex("0x10").unwrap(), 16); - assert_eq!(parse_hex("10").unwrap(), 16); - assert_eq!(parse_hex("0xffffffff").unwrap(), 0xffff_ffff); - let err = parse_hex("0xzz").unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - } - - /// A `# freemkv-uk:` line missing the `cps:hex` shape, with a bad cps, - /// or a wrong-length key, must parse to None (best-effort, never fatal). - #[test] - fn parse_uk_line_rejects_malformed() { - assert_eq!(parse_uk_line("no-colon"), None); - assert_eq!( - parse_uk_line("notanumber:11111111111111111111111111111111"), - None - ); - // 30 hex chars (15 bytes) — wrong length. - assert_eq!(parse_uk_line("0:1111111111111111111111111111"), None); - // Valid. - assert_eq!( - parse_uk_line("3:000102030405060708090a0b0c0d0e0f"), - Some((3u32, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])) - ); - } - - /// parse_vid_hex tolerates an optional `0x` prefix and uppercase hex, - /// but a 31- or 33-char string (not 32) is rejected — a VID is exactly - /// 16 bytes = 32 hex chars. - #[test] - fn parse_vid_hex_length_and_case() { - assert_eq!( - parse_vid_hex("0xAABBCCDDEEFF00112233445566778899"), - Some([ - 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, - 0x88, 0x99 - ]) - ); - assert_eq!(parse_vid_hex(&"a".repeat(31)), None); - assert_eq!(parse_vid_hex(&"a".repeat(33)), None); - } - - // ── next_with / ranges_with semantics ───────────────────────── - - /// next_with returns the first matching range AT OR AFTER `from`, - /// clipping the returned start to `from` when `from` lands inside a - /// matching range (the patch loop relies on resuming mid-range). - #[test] - fn next_with_clips_start_to_from() { - let p = tmpfile("next_with_clips"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(200, 300, SectorStatus::NonTrimmed).unwrap(); - // from inside the NonTrimmed range [200,500): start clips to 350, - // size is 500-350 = 150. - assert_eq!( - mf.next_with(350, SectorStatus::NonTrimmed), - Some((350, 150)) - ); - // from before the range: returns the whole range from its pos. - assert_eq!(mf.next_with(0, SectorStatus::NonTrimmed), Some((200, 300))); - // from at/after the range end: no match. - assert_eq!(mf.next_with(500, SectorStatus::NonTrimmed), None); - // status with no entries: None. - assert_eq!(mf.next_with(0, SectorStatus::Unreadable), None); - let _ = std::fs::remove_file(&p); - } - - /// ranges_with matches ANY of the supplied statuses, preserving - /// position order. Used to build the Pass-N retry queue (NonTrimmed + - /// NonScraped together). - #[test] - fn ranges_with_multiple_statuses_in_order() { - let p = tmpfile("ranges_with_multi"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(100, 100, SectorStatus::NonTrimmed).unwrap(); - mf.record(300, 100, SectorStatus::NonScraped).unwrap(); - mf.record(500, 100, SectorStatus::Unreadable).unwrap(); - let retry = mf.ranges_with(&[SectorStatus::NonTrimmed, SectorStatus::NonScraped]); - assert_eq!(retry, vec![(100, 100), (300, 100)]); - let _ = std::fs::remove_file(&p); - } - - // ── record edge cases ───────────────────────────────────────── - - /// A zero-size record is a no-op (record() early-returns on size==0): - /// entries and stats are unchanged. - #[test] - fn record_zero_size_is_noop() { - let p = tmpfile("record_zero"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - let before = mf.entries().to_vec(); - mf.record(500, 0, SectorStatus::Finished).unwrap(); - assert_eq!(mf.entries(), before.as_slice()); - assert_eq!(mf.stats().bytes_good, 0); - let _ = std::fs::remove_file(&p); - } - - /// Recording the FULL disc with one status collapses to a single - /// coalesced entry (record splits then merges adjacent same-status). - #[test] - fn record_full_span_coalesces_to_one_entry() { - let p = tmpfile("record_full_span"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 500, SectorStatus::Finished).unwrap(); - mf.record(500, 500, SectorStatus::Finished).unwrap(); - let es = mf.entries(); - assert_eq!(es.len(), 1, "two adjacent Finished must coalesce"); - assert_eq!((es[0].pos, es[0].size), (0, 1000)); - assert_eq!(mf.stats().bytes_good, 1000); - let _ = std::fs::remove_file(&p); - } - - /// A record that exactly overwrites the whole previous entry leaves the - /// partition disjoint and total coverage invariant. bytes_total stays - /// constant; good+pending+unreadable always sums to total. - #[test] - fn record_partition_invariant_total_coverage() { - let p = tmpfile("record_invariant"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 250, SectorStatus::Finished).unwrap(); - mf.record(250, 250, SectorStatus::Unreadable).unwrap(); - mf.record(500, 250, SectorStatus::NonTrimmed).unwrap(); - // NonTried (500..750? no) leftover is [750,1000). - let s = mf.stats(); - assert_eq!( - s.bytes_good + s.bytes_unreadable + s.bytes_pending, - s.bytes_total, - "coverage must partition the disc exactly" - ); - // Entries must be disjoint and sorted. - let es = mf.entries(); - for w in es.windows(2) { - assert!( - w[0].pos + w[0].size <= w[1].pos, - "entries must stay disjoint and sorted" - ); - } - let _ = std::fs::remove_file(&p); - } - - // ── load() current-line heuristic ───────────────────────────── - - /// load() skips the ddrescue "current pos" status line (2nd field is a - /// status char, not a 0x size) and parses the data lines that follow. - /// The header doc shows `0x000000000 ? 1 0` as the status line. - #[test] - fn load_skips_current_status_line() { - let p = tmpfile("load_skips_current"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000000 0x00000100 +\n\ - 0x000000100 0x00000100 -\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).unwrap(); - assert_eq!(mf.entries().len(), 2); - assert_eq!(mf.entries()[0].status, SectorStatus::Finished); - assert_eq!(mf.entries()[1].status, SectorStatus::Unreadable); - let _ = std::fs::remove_file(&p); - } - - /// A mapfile written WITHOUT a current-line (first non-comment line is - /// already a data entry: 2nd field starts `0x`) must still parse that - /// first line as an entry — the heuristic detects it and falls through. - #[test] - fn load_treats_leading_data_line_as_entry() { - let p = tmpfile("load_leading_entry"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 0x00000200 +\n\ - 0x000000200 0x00000100 ?\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).unwrap(); - // First line is NOT a status line; both lines are entries. - assert_eq!(mf.entries().len(), 2); - assert_eq!(mf.entries()[0].size, 0x200); - let _ = std::fs::remove_file(&p); - } - - /// Regression (finding 4): a leading DATA line whose size field has NO - /// `0x` prefix (ddrescue/`parse_hex` both accept bare hex) must still be - /// parsed as an entry, not misclassified as the current-status line and - /// dropped. The shape-based discriminator keys off the 2nd field being a - /// single status char (current line) vs. a multi-char hex size (data line). - #[test] - fn load_treats_leading_data_line_without_0x_prefix_as_entry() { - let p = tmpfile("load_leading_entry_no_0x"); - let _ = std::fs::remove_file(&p); - // Note: sizes/positions written WITHOUT the `0x` prefix. - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 000000000 200 +\n\ - 000000200 100 ?\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).unwrap(); - // The old `0x`-prefix heuristic would have skipped the first line as a - // "current line" and lost a valid `+` entry. Both lines are entries. - assert_eq!(mf.entries().len(), 2); - assert_eq!(mf.entries()[0].size, 0x200); - assert_eq!(mf.entries()[0].status, SectorStatus::Finished); - assert_eq!(mf.entries()[1].status, SectorStatus::NonTried); - let _ = std::fs::remove_file(&p); - } - - /// load() parses the version from the `# Rescue Logfile. Created by` - /// header and exposes it (round-trips through write_to_disk). - #[test] - fn load_parses_version_header() { - let p = tmpfile("load_version"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by libfreemkv v9.9.9\n\ - 0x000000000 ? 1 0\n\ - 0x000000000 0x00000100 +\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).unwrap(); - assert_eq!(mf.version, "libfreemkv v9.9.9"); - let _ = std::fs::remove_file(&p); - } - - /// load() rejects an entry with a non-hex pos/size field - /// (MapfileInvalid{kind:"hex"}) rather than silently skipping it — - /// a corrupt data line must not be dropped, masking missing coverage. - #[test] - fn load_rejects_non_hex_field() { - let p = tmpfile("load_nonhex"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0xZZZ 0x100 +\n", - ) - .unwrap(); - assert!(Mapfile::load(&p).is_err()); - let _ = std::fs::remove_file(&p); - } - - /// load() rejects an unknown status char (MapfileInvalid{kind: - /// "status_char"}). A `~` is not in the ddrescue alphabet. - #[test] - fn load_rejects_unknown_status_char() { - let p = tmpfile("load_badstatus"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000000 0x100 ~\n", - ) - .unwrap(); - let err = match Mapfile::load(&p) { - Ok(_) => panic!("unknown status char must be rejected"), - Err(e) => e, - }; - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - let _ = std::fs::remove_file(&p); - } - - /// An empty mapfile (only comments / blank lines) loads with zero - /// entries and total_size 0 — never panics on the `entries.last()` None. - #[test] - fn load_empty_mapfile_is_zero_total() { - let p = tmpfile("load_empty"); - let _ = std::fs::remove_file(&p); - std::fs::write(&p, "# Rescue Logfile. Created by test\n\n \n").unwrap(); - let mf = Mapfile::load(&p).unwrap(); - assert!(mf.entries().is_empty()); - assert_eq!(mf.total_size(), 0); - assert_eq!(mf.stats().bytes_total, 0); - let _ = std::fs::remove_file(&p); - } - - /// load() sorts entries by pos even when the file lists them out of - /// order, and total_size derives from the highest end (entries are - /// sorted then last().pos+size). - #[test] - fn load_sorts_out_of_order_entries() { - let p = tmpfile("load_sort"); - let _ = std::fs::remove_file(&p); - std::fs::write( - &p, - "# Rescue Logfile. Created by test\n\ - 0x000000000 ? 1 0\n\ - 0x000000200 0x00000100 -\n\ - 0x000000000 0x00000200 +\n", - ) - .unwrap(); - let mf = Mapfile::load(&p).unwrap(); - assert_eq!(mf.entries()[0].pos, 0); - assert_eq!(mf.entries()[1].pos, 0x200); - assert_eq!(mf.total_size(), 0x300); - let _ = std::fs::remove_file(&p); - } - - // ── write_to_disk format ────────────────────────────────────── - - /// write_to_disk emits each entry as `0x{pos:09x} 0x{size:09x} {char}` - /// and a load() recovers identical entries (the canonical resume path). - /// Also verifies the fixed header block (Created by / Current pos / - /// column header) is present so external ddrescue tools parse it. - #[test] - fn write_to_disk_format_round_trips_and_has_headers() { - let p = tmpfile("write_format"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 0x1000, "vTEST").unwrap(); - mf.record(0x100, 0x200, SectorStatus::Finished).unwrap(); - mf.record(0x500, 0x100, SectorStatus::Unreadable).unwrap(); - mf.flush().unwrap(); - let text = std::fs::read_to_string(&p).unwrap(); - assert!(text.contains("# Rescue Logfile. Created by vTEST")); - assert!(text.contains("# Current pos / status / pass / pass_time")); - assert!(text.contains("0x000000100 0x000000200 +")); - assert!(text.contains("0x000000500 0x000000100 -")); - let reloaded = Mapfile::load(&p).unwrap(); - assert_eq!(reloaded.entries(), mf.entries()); - let _ = std::fs::remove_file(&p); - } - - /// create() persists immediately so a resume sees the fresh mapfile - /// even if record() is never called (load right after create matches). - #[test] - fn create_persists_eagerly() { - let p = tmpfile("create_eager"); - let _ = std::fs::remove_file(&p); - let mf = Mapfile::create(&p, 4096, "test").unwrap(); - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.entries(), mf.entries()); - assert_eq!(loaded.total_size(), 4096); - let _ = std::fs::remove_file(&p); - } - - /// open_or_create returns a fresh NonTried mapfile when the path does - /// not exist (NotFound → create), not an error. - #[test] - fn open_or_create_creates_when_absent() { - let p = tmpfile("open_or_create_absent"); - let _ = std::fs::remove_file(&p); - let mf = Mapfile::open_or_create(&p, 2048, "test").unwrap(); - assert_eq!(mf.entries().len(), 1); - assert_eq!(mf.entries()[0].status, SectorStatus::NonTried); - assert_eq!(mf.total_size(), 2048); - let _ = std::fs::remove_file(&p); - } - - /// open_or_create loads an existing file (and does NOT reset it to - /// NonTried) even when the supplied total_size differs from the loaded - /// coverage — the warn path must still return the loaded state. - #[test] - fn open_or_create_loads_existing_despite_size_mismatch() { - let p = tmpfile("open_or_create_mismatch"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.record(0, 500, SectorStatus::Finished).unwrap(); - mf.flush().unwrap(); - // Supply a DIFFERENT total; must still load the existing entries. - let reopened = Mapfile::open_or_create(&p, 999_999, "test").unwrap(); - assert_eq!(reopened.stats().bytes_good, 500); - // Loaded total reflects the file, not the supplied arg. - assert_eq!(reopened.total_size(), 1000); - let _ = std::fs::remove_file(&p); - } - - /// set_unit_keys with an EMPTY slice must NOT clear an existing VID — - /// the keys-XOR-vid invariant only flips when keys are actually present - /// (mapfile.rs: `if !self.unit_keys.is_empty() { self.vid = None }`). - #[test] - fn set_unit_keys_empty_preserves_vid() { - let p = tmpfile("uk_empty_preserves_vid"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - mf.set_vid([0x7Au8; 16]); - mf.set_unit_keys(&[]); // empty — must not clear vid - assert_eq!(mf.vid(), Some([0x7Au8; 16])); - assert!(mf.unit_keys().is_empty()); - let _ = std::fs::remove_file(&p); - } - - /// Drop flushes pending in-memory state (a sweep that returns early - /// must not lose records). After dropping a dirty Mapfile, a fresh - /// load() sees the last record. - #[test] - fn drop_flushes_pending_state() { - let p = tmpfile("drop_flush"); - let _ = std::fs::remove_file(&p); - { - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - // record may or may not flush (time-batched); ensure dirty. - mf.record(0, 400, SectorStatus::Finished).unwrap(); - // Drop here flushes. - } - let loaded = Mapfile::load(&p).unwrap(); - assert_eq!(loaded.stats().bytes_good, 400); - let _ = std::fs::remove_file(&p); - } - - #[test] - fn stats_consistent_after_split_record() { - let p = tmpfile("stats_consistent_after_split"); - let _ = std::fs::remove_file(&p); - let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); - // Mark middle as NonTrimmed - mf.record(200, 400, SectorStatus::NonTrimmed).unwrap(); - // Entries: [0..200 NonTried, 200..600 NonTrimmed, 600..1000 NonTried] - let s = mf.stats(); - assert_eq!(s.bytes_pending, 1000); // NonTried(600) + NonTrimmed(400) - assert_eq!(s.bytes_retryable, 400); // NonTrimmed only - assert_eq!(s.bytes_nontried, 600); // 200 + 400 - - // Overwrite the NonTrimmed with Finished (splitting the remaining NonTried) - mf.record(200, 400, SectorStatus::Finished).unwrap(); - // Entries: [0..200 NonTried, 200..600 Finished, 600..1000 NonTried] - let s2 = mf.stats(); - assert_eq!(s2.bytes_good, 400); - assert_eq!(s2.bytes_pending, 600); // NonTried(200 + 400) - assert_eq!(s2.bytes_nontried, 600); - assert_eq!(s2.bytes_retryable, 0); - - let _ = std::fs::remove_file(&p); - } -} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 872cd44..e8a6f54 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -14,14 +14,9 @@ pub(crate) mod dvd_audio_probe; mod encrypt; mod extract; mod hddvd; -pub mod mapfile; -mod patch; pub(crate) mod pgs_forced_probe; -pub mod read_error; -mod section_recover; -mod sweep; -use crate::drive::{Drive, extract_scsi_context}; +use crate::drive::Drive; use crate::error::{Error, Result}; use crate::sector::SectorSource; use crate::udf; @@ -770,44 +765,6 @@ pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress:: } } -/// One-shot progress snapshot built from a mapfile on disk plus the title. The -/// library reads + parses the mapfile HERE so a client (autorip) gets a -/// fully-rendered [`crate::progress::PassProgress`] without ever touching -/// mapfile internals — used for the pass-boundary paint (before the live -/// callback stream begins) and the terminal done-card verdict. Returns `None` -/// if the mapfile can't be read. `work_done`/`work_total` are `0`: this is a -/// point-in-time snapshot, not a per-pass progress tick. -pub fn progress_snapshot_from_mapfile( - mapfile_path: &std::path::Path, - title: Option<&DiscTitle>, - kind: crate::progress::PassKind, - bytes_total_disc: u64, -) -> Option { - use mapfile::SectorStatus::{NonScraped, NonTrimmed, Unreadable}; - let map = mapfile::Mapfile::load(mapfile_path).ok()?; - let stats = map.stats(); - // MAYBE set = not-yet-good (NonTrimmed/NonScraped/Unreadable), excluding - // NonTried (the unread remainder) — same set the live patch emitter uses. - let maybe = map.ranges_with(&[NonTrimmed, NonScraped, Unreadable]); - let located = title.map(|t| locate_ranges(&maybe, t)).unwrap_or_default(); - let main_bad = title.map(|t| bytes_bad_in_title(t, &maybe)).unwrap_or(0); - Some(crate::progress::PassProgress { - kind, - work_done: 0, - work_total: 0, - bytes_good_total: stats.bytes_good, - bytes_unreadable_total: stats.bytes_unreadable, - bytes_pending_total: stats.bytes_pending, - bytes_retryable_total: stats.bytes_retryable, - bytes_total_disc, - disc_duration_secs: title.map(|t| t.duration_secs), - bytes_bad_in_main_title: main_bad, - main_title_duration_secs: title.map(|t| t.duration_secs), - main_title_size_bytes: title.map(|t| t.size_bytes), - located, - }) -} - // ─── Display helpers ──────────────────────────────────────────────────────── impl Codec { @@ -2950,1150 +2907,13 @@ impl Disc { self.aacs_error = None; Ok(()) } - - /// Copy disc sectors to an ISO image file. - /// - /// NOT a stream operation. Copies sectors byte-for-byte producing a valid - /// ISO/UDF image. Records progress in a ddrescue-format mapfile at - /// `path + ".mapfile"` — flushed every block for crash-safe resume. - /// - /// Auto-detects the pass based on mapfile state: - /// - **No mapfile** → Pass 1 (sweep): sequential read of the entire disc, - /// ECC-aligned batches, damage-jump on contiguous failures, marks bad - /// blocks as NonTrimmed. No drive-level recovery — fast. - /// - **Mapfile with bad ranges** → Pass N (patch): re-reads only bad ranges - /// sector-by-sector with full drive-level recovery. Marks recovered - /// sectors as Finished, failed as Unreadable (terminal). - /// - **Mapfile clean** → no-op: all sectors are Finished. - /// - /// Without `multipass`: aborts on the first read error (legacy single-pass). - pub fn copy( - &self, - reader: &mut dyn SectorSource, - path: &std::path::Path, - opts: &CopyOptions, - ) -> Result { - // Pre-flight decrypt gate. A decrypting copy (`opts.decrypt == true`, - // i.e. NOT `--raw`) of an encrypted disc with no usable key would wrap - // the reader in a pass-through `DecryptingSectorSource` and write - // ciphertext to the ISO, then return `Ok` (bytes_good > 0) — a silent - // garbage success at exit 0. Refuse here, BEFORE any sweep/patch reads a - // single sector, so the failure is pre-flight and no partial ISO is - // written. `opts.decrypt == false` is `--raw`: the gate is a no-op (the - // user wants the encrypted image), and an unencrypted disc passes too. - self.ensure_decryptable(!opts.decrypt)?; - // Mapfile-driven resume dispatch. This runs for BOTH plain and - // `--multipass` copies: an interrupted plain `disc:// → iso://` writes - // a per-block-flushed mapfile (crash-safe), and re-issuing the SAME - // command must pick up where it stopped rather than re-sweep from - // sector 0 (the help/CLI examples promise "auto-resumes if - // interrupted"). The ONLY multipass-specific behaviour is the patch - // (Pass N) dispatch on retryable bytes — plain mode has no patch pass, - // so it returns a terminal result there instead. - let mf_path = self.mapfile_for(path); - if mf_path.exists() { - let map = mapfile::Mapfile::load(&mf_path).map_err(|e| Error::IoError { source: e })?; - let stats = map.stats(); - let disc_size = self.capacity_bytes; - let covers_disc = map.total_size() == disc_size; - let bad_bytes = stats.bytes_pending + stats.bytes_unreadable; - tracing::info!( - "copy dispatch: disc={} map={} covers={} multipass={} good={} nontried={} pending={} unreadable={}", - disc_size, - map.total_size(), - covers_disc, - opts.multipass, - stats.bytes_good, - stats.bytes_nontried, - stats.bytes_pending, - stats.bytes_unreadable, - ); - if covers_disc && bad_bytes == 0 && stats.bytes_nontried == 0 { - // Every sector is Finished — a prior copy completed. Re-issuing - // the command is a no-op (don't re-sweep a finished ISO). - return Ok(CopyResult { - bytes_total: disc_size, - bytes_good: stats.bytes_good, - bytes_unreadable: stats.bytes_unreadable, - bytes_pending: 0, - recovered_this_pass: 0, - complete: true, - halted: false, - }); - } - if !covers_disc { - // Mapfile capacity != disc capacity. Force a full (non- - // resume) sweep on ANY mismatch so [0, disc_size) is covered - // as one fresh region (the non-resume path also set_len's the - // ISO to the full capacity). - // - // UNDER-cover (map.total_size() < disc_size): a resume sweep - // builds its region list only from the mapfile's NonTried - // entries and would silently never read the tail - // [map.total_size(), disc_size) — abandoning readable data - // and the ISO's tail. - // - // OVER-cover (map.total_size() > disc_size): a resume sweep's - // NonTried regions extend past the disc; `reader.read_sectors` - // would then read LBAs beyond capacity (the promised - // capacity clamp was never actually applied). A fresh sweep - // sized to the real disc avoids reading past the end. - tracing::info!( - "copy dispatch: → sweep (covers_disc=false, resume=false, map={}, disc={})", - map.total_size(), - disc_size, - ); - return self.sweep_internal(reader, path, opts, false); - } - // NonTried bytes mean a prior sweep was halted mid-way (Ctrl-C / - // crash) and the mapfile still has un-attempted ranges (the un-swept - // tail). The sweep pass's job is to read those — route to a resume - // sweep FIRST, even when retryable bytes also exist. Checking - // retryable before this (and routing straight to patch) would - // silently abandon the un-swept tail: patch only revisits the - // mapfile's bad ranges, never the NonTried ones. The retry - // (patch) passes run after, driven separately by the caller's - // pass loop, and pick up the retryable bytes the sweep leaves. - // This is the plain-copy resume path too: a clean disc interrupted - // by Ctrl-C leaves exactly this state (NonTried tail), so a re-run - // resumes the sweep instead of restarting from sector 0. - if stats.bytes_nontried > 0 { - tracing::info!( - "copy dispatch: → sweep resume (covers_disc=true, \ - nontried={}, retryable={})", - stats.bytes_nontried, - stats.bytes_retryable, - ); - return self.sweep_internal(reader, path, opts, true); - } - // From here covers_disc=true and nontried=0: the whole disc was - // attempted. Only the retry/patch decision differs by mode. - if opts.multipass { - if stats.bytes_retryable > 0 { - tracing::info!( - "copy dispatch: → patch (retryable={})", - stats.bytes_retryable, - ); - return self.patch_internal(reader, path, opts); - } - // Fallthrough: covers_disc=true, nontried=0, retryable=0. - // All sectors were attempted; any remaining bad bytes are - // already Unreadable. A resume sweep would visit zero new - // sectors and patch has nothing retryable — return the - // terminal result immediately. - tracing::info!( - "copy dispatch: all bad sectors already Unreadable \ - (retryable=0, nontried=0) — returning terminal result", - ); - return Ok(CopyResult { - bytes_total: disc_size, - bytes_good: stats.bytes_good, - bytes_unreadable: stats.bytes_unreadable, - bytes_pending: 0, - recovered_this_pass: 0, - complete: false, - halted: false, - }); - } - // Plain (non-multipass) copy: there is no patch pass and the sweep - // aborts on the first read error, so a fully-attempted mapfile with - // bad bytes is terminal. Re-running must NOT restart from sector 0 - // (that re-reads the whole disc and re-hits the same bad sector); - // return the terminal result so the caller surfaces the failure. - // (`complete` is true only when no bad bytes remain.) - tracing::info!( - "copy dispatch: plain copy, disc fully attempted (bad={}) — terminal result", - bad_bytes, - ); - return Ok(CopyResult { - bytes_total: disc_size, - bytes_good: stats.bytes_good, - bytes_unreadable: stats.bytes_unreadable, - bytes_pending: stats.bytes_pending, - recovered_this_pass: 0, - complete: bad_bytes == 0, - halted: false, - }); - } - self.sweep_internal(reader, path, opts, false) - } - - fn sweep_internal( - &self, - reader: &mut dyn SectorSource, - path: &std::path::Path, - opts: &CopyOptions, - resume: bool, - ) -> Result { - let sweep_opts = SweepOptions { - decrypt: opts.decrypt, - resume, - batch_sectors: None, - skip_on_error: opts.multipass, - progress: opts.progress, - halt: opts.halt.clone(), - vid: opts.vid, - unit_keys: opts.unit_keys.clone(), - key_fetch: opts.key_fetch.clone(), - }; - self.sweep(reader, path, &sweep_opts) - } - - fn patch_internal( - &self, - reader: &mut dyn SectorSource, - path: &std::path::Path, - opts: &CopyOptions, - ) -> Result { - let patch_opts = PatchOptions { - decrypt: opts.decrypt, - // 0.18.13: adaptive batching. patch() reads at 32 sectors - // when the drive is healthy, drops to 1 on failure to - // probe each sector individually, then climbs back after - // 16 consecutive clean singles. Walks NonTrimmed regions - // ~32x faster in clean stretches without sacrificing any - // per-sector recovery quality — the drop-to-1 retry from - // the same position guarantees every sector in a failed - // batch is individually probed. See Disc::patch body. - block_sectors: Some(32), - full_recovery: true, - reverse: true, - wedged_threshold: 50, - progress: opts.progress, - halt: opts.halt.clone(), - key_fetch: opts.key_fetch.clone(), - }; - let pr = self.patch(reader, path, &patch_opts)?; - tracing::info!( - target: "freemkv::disc", - phase = "patch_done", - bytes_recovered = pr.bytes_recovered_this_pass, - halted = pr.halted, - wedged_exit = pr.wedged_exit, - "Patch completed" - ); - Ok(CopyResult { - bytes_total: pr.bytes_total, - bytes_good: pr.bytes_good, - bytes_unreadable: pr.bytes_unreadable, - bytes_pending: pr.bytes_pending, - recovered_this_pass: pr.bytes_recovered_this_pass, - complete: pr.bytes_pending == 0, - halted: pr.halted, - }) - } - - /// Pass 1 of a multipass rip: walk the disc forward, write - /// every readable sector into `path`, and record the result - /// in the sidecar mapfile. With `skip_on_error: true`, a bad - /// sector zero-fills + marks `NonTrimmed` and the sweep keeps - /// going (jumping ahead through dense damage); without it, - /// the first read failure aborts. - /// - /// This is one of the two flat verbs the library exposes - /// for rip orchestration. Multipass + retry decisions are the - /// caller's job — see [`PatchOptions`] for the retry primitive. - pub fn sweep( - &self, - reader: &mut dyn SectorSource, - path: &std::path::Path, - opts: &SweepOptions, - ) -> Result { - use crate::io::{DEFAULT_PIPELINE_DEPTH, Pipeline}; - use crate::sector::{DecryptingSectorSource, SectorSource}; - use sweep::{ProgressSnapshot, SweepSink, WorkItem, try_recv_progress}; - - // Pre-flight decrypt gate (also enforced in `copy`; re-checked here so a - // direct `sweep` caller can't bypass it). A decrypting sweep of an - // encrypted disc with no usable key would write ciphertext to the ISO at - // exit 0; refuse before reading any sector. No-op for `--raw` - // (`opts.decrypt == false`) and unencrypted discs. - self.ensure_decryptable(!opts.decrypt)?; - - let total_bytes = self.capacity_sectors as u64 * 2048; - // Decrypt-aware read. - // - // A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without - // `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext. - // - // Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and - // plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a - // pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI - // read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure. - // (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute - // unit grid against clip-file-anchored AACS units and false-failed good - // clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch - // verify and no post-sweep clip-anchored pass; decryptability is proven at - // mux time, not at capture time.) - 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 sweep: resolve a WHOLE-DISC key map up front (the fetch - // secures any missing CPS-unit key, fail-loud) and decrypt via the map — - // a clear nav/filesystem sector is in no range and passes through, so no - // separate content gate is needed. CSS keeps the content-gated - // self-descramble path (the map path is AACS-only). - let key_map = if opts.decrypt && decrypt_is_aacs { - let halt = opts.halt.clone().map(crate::halt::Halt::from_arc); - Some(std::sync::Arc::new(self.resolve_content_key_map( - reader, - &mut keys, - opts.key_fetch.as_ref(), - halt.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 { - // CSS / clear decrypt: content-gate the self-descramble path. - dec = dec.with_content_ranges(std::sync::Arc::from(content_ranges)); - } - dec - }; - let reader = &mut reader; - - // Mapfile: load if resuming, else wipe + recreate. - let mapfile_path = self.mapfile_for(path); - // covers_disc reconciliation. A resume against a mapfile whose total - // size != the real disc size is unsafe — exactly the case copy()'s - // dispatch forces to a fresh sweep (see Disc::copy). Under-cover - // (map < disc) abandons the disc tail [map.total_size(), disc); - // over-cover (map > disc) reads LBAs past capacity. When sweep() is - // called directly (not via copy()), apply the same downgrade: drop the - // stale mapfile and sweep [0, total_bytes) fresh. - let mut resume = opts.resume; - if resume && mapfile_path.exists() { - match mapfile::Mapfile::load(&mapfile_path) { - Ok(existing) => { - if existing.total_size() != total_bytes { - tracing::info!( - "sweep: mapfile total_size {} != disc {}; forcing fresh sweep", - existing.total_size(), - total_bytes, - ); - resume = false; - } else { - // Inconsistent-resume guard. The mapfile claims prior - // progress (some range past NonTried) but the ISO is - // missing or zero-length — the ISO was deleted or - // truncated while the mapfile survived (reachable via - // autorip ResumeMode::Require). The producer only builds - // work from NonTried ranges, so any Finished range would - // never be re-read and would stay ZERO in the fresh ISO, - // silently holed. Downgrade to a fresh full sweep (mirror - // the total_size-mismatch case) so the rip self-heals. - let iso_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - let claims_progress = - existing.stats().bytes_pending != existing.total_size(); - if iso_len == 0 && claims_progress { - tracing::info!( - "sweep: mapfile claims prior progress (pending {} of {}) but ISO is missing/zero-length; forcing fresh sweep", - existing.stats().bytes_pending, - existing.total_size(), - ); - resume = false; - } - } - } - Err(_) => { - // The mapfile exists but is corrupt / unparseable. Proceeding - // with resume=true would hand a garbage (or empty) mapfile to - // open_or_create and silently skip already-Finished ranges or - // mis-track progress. Downgrade to a fresh sweep — consistent - // with the total_size-mismatch branch above — so the `!resume` - // path below drops the corrupt mapfile and the rip restarts - // clean. - tracing::info!( - "sweep: mapfile at {} is corrupt/unparseable; forcing fresh sweep", - mapfile_path.display(), - ); - resume = false; - } - } - } - if !resume { - // A fresh sweep MUST start from an empty mapfile. If the stale file - // can't be removed, open_or_create would load it and the new disc - // would inherit the old Finished ranges → silently zero-filled ISO. - // ENOENT is fine (nothing to remove); any other error aborts. - match std::fs::remove_file(&mapfile_path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(Error::IoError { source: e }), - } - } - let mut map = mapfile::Mapfile::open_or_create( - &mapfile_path, - total_bytes, - concat!("libfreemkv v", env!("CARGO_PKG_VERSION")), - ) - .map_err(|e| Error::IoError { source: e })?; - - // Persist the disc's decryption state into the mapfile header so it - // survives to deferred-mux / resume. ddrescue-safe (comment lines); - // does not touch the ISO payload. KEYS XOR VID: a keyed disc writes its - // unit keys (the final answer — deferred-mux decrypts directly, no key - // service); an unresolved disc writes only the VID (the retry marker). - if !opts.unit_keys.is_empty() { - map.set_unit_keys(&opts.unit_keys); - } else if let Some(vid) = opts.vid { - map.set_vid(vid); - } - - // ISO file: if resuming and mapfile has Finished ranges, open existing; - // otherwise create fresh and pre-size to total_bytes (sparse holes for - // non-tried regions). - // - // `is_regular` MUST be read from the OPEN file handle, not from - // `metadata(path)` — on a fresh rip the path does not exist yet, so a - // pre-create `metadata(path)` always fails (is_regular=false), which both - // skips the pre-size AND makes `SweepSink::close` swallow a real - // `sync_all()` failure on the just-written ISO as if it were /dev/null. - let (file, is_regular) = if resume - && std::fs::metadata(path) - .map(|m| m.len() > 0) - .unwrap_or(false) - { - let f = std::fs::OpenOptions::new() - .write(true) - .open(path) - .map_err(|e| Error::IoError { source: e })?; - let reg = f - .metadata() - .map(|m| m.file_type().is_file()) - .unwrap_or(false); - (f, reg) - } else { - let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?; - let reg = f - .metadata() - .map(|m| m.file_type().is_file()) - .unwrap_or(false); - if reg { - f.set_len(total_bytes) - .map_err(|e| Error::IoError { source: e })?; - } - (f, reg) - }; - - // Wrap the raw `File` in our bounded-cache `WritebackFile` - // (drains dirty pages continuously instead of bursting; see - // `crate::io`). The `WritebackFile` moves into the consumer - // thread. - let file = crate::io::WritebackFile::new(file).map_err(|e| Error::IoError { source: e })?; - let mut batch: u16 = match opts.batch_sectors { - Some(b) => b, - None if opts.skip_on_error => ecc_sectors(self.format), - None => DEFAULT_BATCH_SECTORS_OPTICAL, - }; - - // AACS unit alignment for a DECRYPTING sweep. AACS aligned units are 3 - // sectors (6144 bytes); `decrypt_sectors` anchors units at buffer offset - // 0, so every read handed to the decrypting reader MUST start on a unit - // boundary AND span a whole number of units — otherwise units straddle - // batch/region boundaries and decrypt under the wrong CBC/unit alignment - // (the verify-gate then leaves content encrypted or aborts DecryptFailed). - // - // ecc_sectors() is 32 for UHD/BD, which is NOT a multiple of 3, so the - // default batch would start every batch-after-the-first mid-unit. Round - // the batch UP to the next multiple of 3 (32 → 33) when this sweep both - // decrypts and is AACS-keyed. Region read-starts are aligned DOWN to a - // unit boundary in the loop below; a fresh sweep starts at LBA 0 (already - // aligned), so alignment only bites on resume NonTried regions. - const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3 - if decrypt_is_aacs && batch % UNIT_SECTORS != 0 { - batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS)); - } - - // Pre-compute the list of NonTried regions before handing the - // mapfile to the consumer thread. Each region is processed by - // the producer in order; the consumer mutates the mapfile per - // work-item. Any regions left as NonTrimmed/Unreadable after - // sweep finishes are the patch pass's job. - let regions: Vec<(u64, u64)> = map.ranges_with(&[mapfile::SectorStatus::NonTried]); - - // Spawn the consumer. It owns WritebackFile + Mapfile; the producer - // (this thread) keeps `reader`, `read_ctx`, halt + set_speed. - // The thread name is preserved from the 0.17.x sweep_pipeline so it - // stays identifiable in stack traces / `top -H`. - let (sink, prog_rx) = SweepSink::new(file, map, is_regular); - let pipe: Pipeline = - Pipeline::spawn_named("freemkv-sweep-consumer", DEFAULT_PIPELINE_DEPTH, sink)?; - - // Translate `Pipeline::send` failure (consumer gone) into a - // numeric library error so the producer-error semantics are - // unchanged but no English leaks into an io::Error. - fn consumer_gone() -> Error { - Error::PipelineConsumerGone - } - - let mut buf = vec![0u8; batch as usize * 2048]; - let mut bytes_done = 0u64; - let mut halt_requested = false; - let copy_t0 = std::time::Instant::now(); - tracing::info!( - target: "freemkv::scan", - phase = "sweep", - total_bytes, - skip_on_error = opts.skip_on_error, - resume, - "begin" - ); - let mut iter_count: u64 = 0; - let mut read_ok_count: u64 = 0; - let mut read_err_count: u64 = 0; - let mut last_log_iter: u64 = 0; - // Sweep heartbeat: fire every 5s OR every 100 iterations, whichever - // comes first, so a slow-but-alive sweep on a marginal disc keeps - // emitting "no silent hang" liveness even between the 100-iter marks. - let mut last_log_time = std::time::Instant::now(); - let mut read_ctx = read_error::ReadCtx::for_sweep(batch); - let mut in_damage_zone = false; - const DAMAGE_ZONE_EXIT_THRESHOLD: u64 = 16; - let mut cached_snapshot: Option = None; - let mut producer_err: Option = None; - - tracing::trace!( - target: "freemkv::disc", - phase = "copy_start", - total_bytes, - batch, - skip_on_error = opts.skip_on_error, - regions = regions.len(), - "Disc::sweep entered (producer/consumer)" - ); - - // Request the drive's max read speed for the whole sweep — removes - // riplock. BD/UHD get their speed from the drive unlock/init, but a - // DVD skips that path (the stock-mode gate, `Drive::disc_is_dvd`), so - // without this explicit SET CD SPEED a DVD rip sweeps at the drive's - // default (riplocked) speed. The damage-recovery branch below also - // re-asserts max speed after slowing on bad sectors; this sets it once - // up front so a clean disc never pays the riplock penalty. - reader.set_speed(0xFFFF); - - 'outer: for (region_pos, region_size) in regions { - let region_end = region_pos + region_size; - // AACS unit alignment: anchor the region's read cursor DOWN to the - // nearest 6144-byte unit boundary so the decrypting reader never gets - // a buffer that starts mid-unit. Re-reading the few already-covered - // head sectors is idempotent (they re-decrypt identically and the - // consumer overwrites the same ISO offsets / mapfile ranges). A fresh - // sweep's NonTried region starts at 0, already unit-aligned; this only - // shifts resume regions that begin mid-unit. - let mut pos = if decrypt_is_aacs { - let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; - region_pos - (region_pos % unit_bytes) - } else { - region_pos - }; - tracing::trace!( - target: "freemkv::disc", - phase = "region_enter", - region_pos, - region_size, - region_end, - "entering NonTried region" - ); - - while pos < region_end { - if let Some(ref h) = opts.halt { - if h.load(std::sync::atomic::Ordering::Relaxed) { - halt_requested = true; - break 'outer; - } - } - - let block_bytes = (region_end - pos).min(batch as u64 * 2048); - let block_lba = (pos / 2048) as u32; - let block_count = (block_bytes / 2048) as u16; - let recovery = !opts.skip_on_error; - - let read_result = reader.read_sectors( - block_lba, - block_count, - &mut buf[..block_bytes as usize], - recovery, - ); - - match read_result { - Ok(_) => { - read_ok_count += 1; - read_ctx.on_success(); - - if read_ctx.consecutive_good >= DAMAGE_ZONE_EXIT_THRESHOLD { - read_ctx.jump_multiplier = 1; - if in_damage_zone { - in_damage_zone = false; - reader.set_speed(0xFFFF); - tracing::debug!( - target: "freemkv::disc", - phase = "damage_exit", - lba = block_lba, - "Exited damage zone; restoring max read speed" - ); - } - } - // bridge_degradation_count is reset inside on_success() - // (called above); no separate reset needed here. - - // Plaintext: the wrapped reader (DecryptingSectorSource) - // applied AACS / CSS in-place during read_sectors above. - // The consumer thread sees decrypted bytes; the - // pre-0.18 inline decrypt_sectors call lived here. - - // Move the batch into the channel via fresh - // owned Vec. The producer's `buf` is reused - // for the next read. - let send_buf = buf[..block_bytes as usize].to_vec(); - if pipe.send(WorkItem::Good { pos, buf: send_buf }).is_err() { - producer_err = Some(consumer_gone()); - break 'outer; - } - bytes_done = bytes_done.saturating_add(block_bytes); - pos += block_bytes; - } - Err(err) if !opts.skip_on_error => { - let (status, sense) = extract_scsi_context(&err); - producer_err = Some(Error::DiscRead { - sector: block_lba as u64, - status: Some(status), - sense, - }); - break 'outer; - } - Err(err) => { - read_err_count += 1; - let action = read_error::handle_read_error(&err, &mut read_ctx); - - match action { - read_error::ReadAction::Retry { pause_secs } => { - sleep_secs_or_halt(pause_secs, opts.halt.as_ref()); - } - read_error::ReadAction::Bisect => { - read_ctx.bisecting = true; - let saved_batch = read_ctx.batch; - read_ctx.batch = 1; - let mut bisect_aborted = false; - for sector_offset in 0..block_count { - if let Some(ref h) = opts.halt { - if h.load(std::sync::atomic::Ordering::Relaxed) { - halt_requested = true; - bisect_aborted = true; - break; - } - } - let sector_lba = block_lba + (sector_offset as u32); - let mut sector_buf = [0u8; 2048]; - let write_pos = pos + (sector_offset as u64 * 2048); - match reader.read_sectors( - sector_lba, - 1, - &mut sector_buf[..], - true, - ) { - Ok(_) => { - read_ctx.on_success(); - // Plaintext via the wrapping - // DecryptingSectorSource — same - // decrypt path the batch read takes. - if pipe - .send(WorkItem::BisectGood { - pos: write_pos, - buf: Box::new(sector_buf), - }) - .is_err() - { - producer_err = Some(consumer_gone()); - bisect_aborted = true; - break; - } - } - Err(inner_err) => { - let inner_action = read_error::handle_read_error( - &inner_err, - &mut read_ctx, - ); - match inner_action { - read_error::ReadAction::Retry { pause_secs } => { - // Transient (NOT_READY / bridge - // degradation): honour the - // cooldown pause, then mark - // BisectBad and move on. We - // are already inside a - // single-sector retry; a - // second bisect would be - // nonsensical (ctx.bisecting - // is true, so handle_read_error - // can't return Bisect). - sleep_secs_or_halt( - pause_secs, - opts.halt.as_ref(), - ); - } - read_error::ReadAction::AbortPass => { - // Transport failure or - // wedge-abort threshold - // reached: stop immediately. - let (status, sense) = - extract_scsi_context(&inner_err); - producer_err = Some(Error::DiscRead { - sector: sector_lba as u64, - status: Some(status), - sense, - }); - bisect_aborted = true; - break; - } - // JumpAhead / SkipBlock: honour - // any indicated pause; the - // bisect-inner loop's job is just - // to classify this specific sector, - // so we still mark BisectBad and - // continue to the next sector. - read_error::ReadAction::JumpAhead { - pause_secs, - .. - } - | read_error::ReadAction::SkipBlock { - pause_secs, - } => { - sleep_secs_or_halt( - pause_secs, - opts.halt.as_ref(), - ); - } - // Bisect cannot recurse: ctx.bisecting - // is true so handle_read_error will - // never return Bisect here. - read_error::ReadAction::Bisect => {} - } - if pipe - .send(WorkItem::BisectBad { pos: write_pos }) - .is_err() - { - producer_err = Some(consumer_gone()); - bisect_aborted = true; - break; - } - } - } - } - read_ctx.bisecting = false; - read_ctx.batch = saved_batch; - if bisect_aborted { - break 'outer; - } - bytes_done = bytes_done.saturating_add(block_bytes); - pos += block_bytes; - } - read_error::ReadAction::SkipBlock { pause_secs } => { - if pipe - .send(WorkItem::SkipFill { - pos, - len: block_bytes, - }) - .is_err() - { - producer_err = Some(consumer_gone()); - break 'outer; - } - bytes_done = bytes_done.saturating_add(block_bytes); - sleep_secs_or_halt(pause_secs, opts.halt.as_ref()); - pos += block_bytes; - } - read_error::ReadAction::JumpAhead { - sectors, - pause_secs, - } => { - if pipe - .send(WorkItem::SkipFill { - pos, - len: block_bytes, - }) - .is_err() - { - producer_err = Some(consumer_gone()); - break 'outer; - } - bytes_done = bytes_done.saturating_add(block_bytes); - - if !in_damage_zone { - in_damage_zone = true; - reader.set_speed(0x0000); - tracing::debug!( - target: "freemkv::disc", - phase = "damage_enter", - lba = block_lba, - "Entered damage zone; dropping to minimum read speed" - ); - } - - // Saturating throughout — the read_error side - // computes the sector count with saturating_mul as - // "defence in depth"; honor the same guarantee at - // the consuming multiply/add so a pathological jump - // distance can't wrap. - let jump_pos = pos - .saturating_add(block_bytes) - .saturating_add(sectors.saturating_mul(2048)) - .min(region_end); - let gap_start = pos + block_bytes; - let gap_bytes = jump_pos.saturating_sub(gap_start); - if gap_bytes > 0 { - if pipe - .send(WorkItem::GapFill { - pos: gap_start, - len: gap_bytes, - }) - .is_err() - { - producer_err = Some(consumer_gone()); - break 'outer; - } - bytes_done = bytes_done.saturating_add(gap_bytes); - } - tracing::warn!( - target: "freemkv::disc", - phase = "damage_jump", - from_lba = block_lba, - to_lba = (jump_pos / 2048) as u32, - jump_mb = gap_bytes / 1_048_576, - "damage-jump" - ); - pos = jump_pos; - sleep_secs_or_halt(pause_secs, opts.halt.as_ref()); - } - read_error::ReadAction::AbortPass => { - let (status, sense) = extract_scsi_context(&err); - producer_err = Some(Error::DiscRead { - sector: block_lba as u64, - status: Some(status), - sense, - }); - break 'outer; - } - } - } - } - - iter_count += 1; - - // Drain any consumer-side stats snapshot. - if let Some(snap) = try_recv_progress(&prog_rx) { - cached_snapshot = Some(snap); - } - - let time_due = last_log_time.elapsed() >= std::time::Duration::from_secs(5); - if iter_count - last_log_iter >= 100 || time_due { - last_log_iter = iter_count; - last_log_time = std::time::Instant::now(); - // Promoted trace -> debug ("no silent hangs"): the sweep - // heartbeat must be visible at the standard debug level, not - // only the trace firehose. Carries lba/pos/region_end and - // bytes_good when a consumer snapshot is available. - let lba = (pos / 2048) as u32; - if let Some(ref snap) = cached_snapshot { - tracing::debug!( - target: "freemkv::disc", - phase = "iter_progress", - iter_count, - read_ok_count, - read_err_count, - lba, - pos, - region_end, - bytes_good = snap.stats.bytes_good, - bytes_pending = snap.stats.bytes_pending, - copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, - "Disc::sweep inner iter" - ); - } else { - tracing::debug!( - target: "freemkv::disc", - phase = "iter_progress", - iter_count, - read_ok_count, - read_err_count, - lba, - pos, - region_end, - copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, - "Disc::sweep inner iter" - ); - } - // Throttled stats refresh request — best-effort - // try_send so a busy consumer doesn't stall the - // producer; the cached snapshot stays current - // enough for one more iteration. - let _ = pipe.try_send(WorkItem::StatsRequest); - } - - if let Some(reporter) = opts.progress { - // Use the latest consumer snapshot if we have - // one; otherwise synthesise a producer-side - // placeholder. On a fresh sweep, before the - // first stats round-trip lands, this means - // bytes_good ≈ bytes_done (producer's notion of - // good-so-far) and the bad-range list is empty — - // close enough for an early UI tick; the next - // real snapshot replaces it. - let main_title = self.titles.first(); - let main_title_bad = match &cached_snapshot { - Some(snap) => self - .titles - .first() - .map(|t| bytes_bad_in_title(t, &snap.bad_ranges)) - .unwrap_or(0), - None => 0, - }; - // The consumer's snapshot is the source of truth for - // bytes_unreadable / bytes_pending (the producer doesn't - // see them), but its bytes_good lags producer-side - // `bytes_done` whenever the consumer is behind on draining - // the work channel. Take the max so the user-visible - // counter never regresses below what the producer has - // already sent — Anomaly B in the 0.18.1 prod test was - // this regression: a stale early snapshot pinned the - // display to 0 GB while bytes_done was already advancing. - let (bytes_good, bytes_unreadable, bytes_pending, bytes_retryable) = - match &cached_snapshot { - Some(snap) => ( - snap.stats.bytes_good.max(bytes_done), - snap.stats.bytes_unreadable, - snap.stats.bytes_pending, - snap.stats.bytes_retryable, - ), - None => ( - bytes_done, - 0u64, - total_bytes.saturating_sub(bytes_done), - 0u64, - ), - }; - let pp = crate::progress::PassProgress { - kind: crate::progress::PassKind::Sweep, - work_done: pos, - work_total: total_bytes, - bytes_good_total: bytes_good, - bytes_unreadable_total: bytes_unreadable, - bytes_pending_total: bytes_pending, - bytes_retryable_total: 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), - // Rendered drilldown from the consumer's in-memory - // snapshot (bad ranges) + title; empty until the first - // snapshot arrives. - located: match &cached_snapshot { - Some(snap) => main_title - .map(|t| locate_ranges(&snap.bad_ranges, t)) - .unwrap_or_default(), - None => crate::progress::LocatedProgress::default(), - }, - }; - if !reporter.report(&pp) { - halt_requested = true; - break 'outer; - } - } - } - } - - // Producer side is done. Drop the channel and let the - // consumer drain whatever's still in flight, then run its - // close() (drain writeback, fsync, mapfile.flush) and return - // the final stats. On consumer panic `pipe.finish` returns - // the wrapped panic message via Error::IoError — same shape - // the previous `consumer_handle.join().map_err(...)` produced. - let summary = pipe.finish(); - - // Producer-side error wins over consumer-side (the read failure - // is what motivated quitting; the consumer's flush error, if - // any, is downstream). - if let Some(e) = producer_err { - // Drop the consumer's result if we already have a producer - // error, but propagate consumer-panic on top of nothing - // since that's strictly informative. - let _ = summary; - return Err(e); - } - let summary = summary?; - - let stats = summary.stats; - tracing::debug!( - target: "freemkv::disc", - phase = "sweep_done", - iter_count, - read_ok_count, - read_err_count, - bytes_good = stats.bytes_good, - bytes_pending = stats.bytes_pending, - halted = halt_requested, - copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, - "Disc::sweep returning" - ); - - // End-of-pass diagnostic summary (added 2026-05-10 alongside - // the per-error timing instrumentation in read_error.rs). - // One INFO line per sweep that lets a post-mortem analyst tell - // at a glance how much damage the disc + drive saw, without - // grepping through the per-error WARN log. The PassSummary - // counters come from `ReadCtx`'s accumulated state. - let pass_sum = read_ctx.pass_summary(); - tracing::info!( - target: "freemkv::disc", - phase = "pass1_summary", - total_reads_ok = pass_sum.total_reads_ok, - total_errors = pass_sum.total_errors, - zones_entered = pass_sum.zones_entered, - jumps_taken = pass_sum.jumps_taken, - marginal_recovered = pass_sum.marginal_recovered, - bytes_good = stats.bytes_good, - bytes_pending = stats.bytes_pending, - copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, - "Pass 1 complete" - ); - Ok(CopyResult { - bytes_total: total_bytes, - bytes_good: stats.bytes_good, - bytes_unreadable: stats.bytes_unreadable, - bytes_pending: stats.bytes_pending, - recovered_this_pass: 0, - complete: stats.bytes_pending == 0 && !halt_requested, - halted: halt_requested, - }) - } -} - -#[derive(Default)] -pub struct CopyOptions<'a> { - pub decrypt: bool, - pub multipass: bool, - pub progress: Option<&'a dyn crate::progress::Progress>, - pub halt: Option>, - /// AACS Volume ID (16 bytes) to persist into the mapfile during - /// Pass 1 so it survives to deferred-mux / resume. `None` for - /// unencrypted / non-AACS discs. Caller wires this from - /// `Disc::aacs.volume_id`. - /// - /// Persisted ONLY when `unit_keys` is empty (the disc didn't resolve a - /// key): the VID is the "still unresolved, retry-able" marker. - pub vid: Option<[u8; 16]>, - /// Resolved AACS unit keys `(CPS unit, key)` to persist into the mapfile - /// during Pass 1. When non-empty these are written (the final answer, so - /// deferred-mux/resume decrypts directly) and the VID is NOT — keys XOR VID. - /// Caller wires this from `Disc::aacs.unit_keys`. - pub unit_keys: Vec<(u32, [u8; 16])>, - /// On-decrypt-miss key fetch (see [`crate::keysource::key_fetch_factory`]). - /// When set, a read that hits AACS ciphertext no held key opens asks the - /// application's key sources for the CPS unit's key, caches it, and retries — - /// recovering an orphan CPS unit never sampled at resolve time. `None` - /// disables it (the prior behaviour). Threaded into sweep + patch. - pub key_fetch: Option, -} - -#[derive(Debug, Clone, Copy)] -pub struct CopyResult { - pub bytes_total: u64, - pub bytes_good: u64, - pub bytes_unreadable: u64, - pub bytes_pending: u64, - pub recovered_this_pass: u64, - pub complete: bool, - pub halted: bool, -} - -/// Options for [`Disc::sweep`] (Pass 1 / forward sequential pass). -pub struct SweepOptions<'a> { - pub decrypt: bool, - pub resume: bool, - pub batch_sectors: Option, - pub skip_on_error: bool, - pub progress: Option<&'a dyn crate::progress::Progress>, - pub halt: Option>, - /// AACS Volume ID (16 bytes) persisted into the mapfile when the - /// sweep creates / opens it. `None` for unencrypted discs. Written ONLY - /// when `unit_keys` is empty (keys XOR VID — the VID is the retry marker). - pub vid: Option<[u8; 16]>, - /// Resolved AACS unit keys persisted into the mapfile when the sweep - /// creates / opens it. When non-empty these win over `vid`. - pub unit_keys: Vec<(u32, [u8; 16])>, - /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). - pub key_fetch: Option, -} - -/// Options for [`Disc::patch`] (Pass N retry pass over bad ranges). -pub struct PatchOptions<'a> { - pub decrypt: bool, - pub block_sectors: Option, - pub full_recovery: bool, - pub reverse: bool, - pub wedged_threshold: u64, - pub progress: Option<&'a dyn crate::progress::Progress>, - pub halt: Option>, - /// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N - /// recover an orphan CPS unit's key when re-reading its bad range. - pub key_fetch: Option, -} - -/// Result returned by [`Disc::patch`]. -pub struct PatchOutcome { - pub bytes_total: u64, - pub bytes_good: u64, - pub bytes_unreadable: u64, - pub bytes_pending: u64, - pub bytes_recovered_this_pass: u64, - pub halted: bool, - pub wedged_exit: bool, - pub wedged_threshold: u64, -} - -/// Sleep `secs` seconds, but break early if `halt` flips to true. -/// Used by Pass 1's wedge-avoidance inter-error pause so halt -/// remains responsive regardless of how long the pause is. -/// Polling granularity 100 ms — bounded latency on halt regardless -/// of pause length. -pub(crate) fn sleep_secs_or_halt( - secs: u64, - halt: Option<&std::sync::Arc>, -) { - if secs == 0 { - return; - } - let Some(h) = halt else { - std::thread::sleep(std::time::Duration::from_secs(secs)); - return; - }; - let total = std::time::Duration::from_secs(secs); - let slice = std::time::Duration::from_millis(100); - let start = std::time::Instant::now(); - while start.elapsed() < total { - if h.load(std::sync::atomic::Ordering::Relaxed) { - return; - } - let remaining = total.saturating_sub(start.elapsed()); - std::thread::sleep(remaining.min(slice)); - } } /// Mapfile path for a regular output file: appends `.mapfile` to the /// output path. For `/dev/null` (benchmark) output use /// [`Disc::mapfile_for`], which special-cases it to a temp-dir path /// derived from the disc title. -pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf { +pub(crate) fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf { let mut s = iso_path.as_os_str().to_os_string(); s.push(".mapfile"); std::path::PathBuf::from(s) @@ -4133,81 +2953,6 @@ const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60; const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192; const MIN_BATCH_SECTORS: u16 = 3; -pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 { - match format { - // BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc. - DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32, - // 32 KiB ECC block (16 × 2048) — DVD and HD-DVD. - DiscFormat::Dvd | DiscFormat::HdDvd => 16, - DiscFormat::Unknown => 32, - } -} - -/// Coarse damage tier for a finished or in-progress rip. Maps the -/// observable signals (bad sector count + lost wallclock playback time) -/// onto a small discrete classification so UIs can render a colored badge -/// and operators can decide whether to rescan / replug / accept. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DamageSeverity { - /// No bad sectors at all. - Clean, - /// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable. - Cosmetic, - /// 51–500 sectors OR 1–30 sec lost. Visible artifacts possible. - Moderate, - /// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan - /// or different drive. - Serious, -} - -/// Classify damage severity from raw counters. `bad_sectors` is the -/// number of sectors marked unreadable (or NonTrimmed pending Pass 2); -/// `lost_ms` is the cumulative wallclock playback time those sectors -/// represent (computed from the title's bytes-per-sec). -pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity { - if bad_sectors == 0 { - return DamageSeverity::Clean; - } - if bad_sectors >= 500 || lost_ms >= 30_000.0 { - return DamageSeverity::Serious; - } - if bad_sectors >= 51 || lost_ms >= 1_000.0 { - return DamageSeverity::Moderate; - } - DamageSeverity::Cosmetic -} - -#[cfg(test)] -mod severity_tests { - use super::*; - #[test] - fn clean_when_no_damage() { - assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean); - } - #[test] - fn cosmetic_for_a_handful() { - assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic); - assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic); - } - #[test] - fn moderate_threshold_by_sectors() { - assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate); - } - #[test] - fn moderate_threshold_by_time() { - assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate); - } - #[test] - fn serious_threshold_by_sectors() { - assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious); - } - #[test] - fn serious_threshold_by_time() { - assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious); - } -} - /// Whether the Linux-sysfs transfer-size probe applies to this device path. /// /// The probe reads `/sys/block//...` / `/sys/class/scsi_generic//...`, @@ -4497,52 +3242,6 @@ mod tests { assert!(!sysfs_batch_probe_supported("\\\\.\\D:")); } - /// AACS unit-alignment of the DECRYPTING multipass sweep. AACS aligned units - /// are 3 sectors (6144 bytes); `decrypt_sectors` anchors units at buffer - /// offset 0, so the sweep MUST (a) round its per-batch sector count UP to a - /// multiple of 3 and (b) align each NonTried region's read cursor DOWN to a - /// unit boundary — otherwise batches after the first start mid-unit and every - /// unit decrypts under the wrong CBC/unit alignment. - /// - /// This mirrors the exact arithmetic the sweep loop uses (the full path needs - /// a live AACS `Disc`, out of reach in a unit test). The decorator-level - /// reject for an unaligned start LBA is covered end-to-end in - /// `sector::decrypting::tests::aacs_unaligned_start_lba_rejected`. - #[test] - fn aacs_sweep_batch_and_region_are_unit_aligned() { - const UNIT_SECTORS: u16 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u16; // 3 - let unit_bytes = crate::aacs::content::ALIGNED_UNIT_LEN as u64; // 6144 - - // (a) Batch rounding: ecc_sectors() for UHD/BD is 32, not a multiple of 3. - // The decrypting-AACS path rounds it up to the next multiple of 3 (33). - for format in [DiscFormat::Uhd, DiscFormat::BluRay] { - let mut batch = ecc_sectors(format); - assert_eq!(batch, 32); - if batch % UNIT_SECTORS != 0 { - batch = batch.saturating_add(UNIT_SECTORS - (batch % UNIT_SECTORS)); - } - assert_eq!(batch, 33, "batch must round 32 -> 33 (a multiple of 3)"); - assert_eq!(batch % UNIT_SECTORS, 0); - // Every full batch read is then a whole number of 6144-byte units. - assert_eq!((batch as u64 * 2048) % unit_bytes, 0); - } - - // (b) Region-start down-alignment. A resume NonTried region can begin - // mid-unit; aligning the read cursor DOWN to the nearest unit boundary - // makes block_lba % 3 == 0 for the first (and thus every) batch read. - // Re-reading the few head sectors is idempotent. - for region_pos in [0u64, 2048, 4096, 6144, 8192, 65536, 67_584] { - let pos = region_pos - (region_pos % unit_bytes); - assert_eq!(pos % unit_bytes, 0, "aligned cursor must be unit-aligned"); - assert!(pos <= region_pos, "alignment only moves the cursor down"); - // block_lba derived as pos/2048 must be a multiple of 3 sectors. - assert_eq!((pos / 2048) % UNIT_SECTORS as u64, 0); - } - // An already-aligned region (fresh sweep starts at 0) is unchanged. - assert_eq!(0u64 - (0u64 % unit_bytes), 0); - assert_eq!(6144u64 - (6144u64 % unit_bytes), 6144); - } - /// Helper: build a DiscTitle with a single video stream at the given resolution. fn title_with_video(codec: Codec, resolution: Resolution) -> DiscTitle { DiscTitle { @@ -4917,42 +3616,6 @@ mod tests { assert_eq!(t.duration_display(), "24h 00m"); } - struct MockReader { - total_sectors: u32, - bad_sectors: std::collections::HashSet, - } - - impl crate::sector::SectorSource for MockReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - let n = count as usize * 2048; - for i in 0..count { - if self.bad_sectors.contains(&(lba + i as u32)) { - return Err(crate::error::Error::DiscRead { - sector: (lba + i as u32) as u64, - status: Some(0x02), - sense: Some(crate::scsi::ScsiSense { - sense_key: 0x02, - asc: 0x04, - ascq: 0x3E, - }), - }); - } - } - buf[..n].fill(0xAA); - Ok(n) - } - - fn capacity_sectors(&self) -> u32 { - self.total_sectors - } - } - fn make_test_disc(sectors: u32, name: &str) -> Disc { Disc { volume_id: name.into(), @@ -6085,930 +4748,6 @@ mod tests { ); } - #[test] - fn sweep_to_dev_null_no_enodev() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 1000; - let bad: std::collections::HashSet = [500u32, 501, 502].into_iter().collect(); - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: bad, - }; - let disc = make_test_disc(sectors, "T1"); - let opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, &iso_path, &opts); - assert!( - result.is_ok(), - "sweep to regular file should succeed: {:?}", - result.err() - ); - } - - /// disc→ISO correctness gate (the headline bug, at the copy entry point): - /// a DECRYPTING copy (`decrypt: true`, i.e. not --raw) of an AACS disc with - /// no resolved key must ERROR before reading any sector — never write - /// ciphertext to the ISO and return Ok. Asserts the error code is NoDiscKey - /// AND that no non-empty ISO was produced. - #[test] - fn copy_decrypting_aacs_no_key_errors_and_writes_nothing() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("garbage.iso"); - let sectors: u32 = 999; // 3-aligned for AACS units - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let mut disc = make_test_disc(sectors, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); // encrypted, no unit key → None - let opts = CopyOptions { - decrypt: true, // NOT --raw → decryption is required - multipass: false, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let err = disc - .copy(&mut reader, &iso_path, &opts) - .expect_err("decrypting copy of AACS-no-key disc must error pre-flight"); - assert_eq!( - err.code(), - crate::error::Error::NoDiscKey { - disc_hash: String::new() - } - .code(), - "must surface NoDiscKey, not silently write ciphertext" - ); - // No partial/garbage ISO: the gate fired before the sweep opened/sized - // the file, so either the file doesn't exist or it's empty. - let produced = std::fs::metadata(&iso_path).map(|m| m.len()).unwrap_or(0); - assert_eq!(produced, 0, "no ciphertext ISO may be written"); - } - - /// The same disc under `--raw` (`decrypt: false`) must PROCEED: the gate is - /// a no-op for raw, the sweep runs as a pass-through and writes the - /// encrypted image the user asked for. Proves the gate doesn't over-fire. - #[test] - fn copy_raw_aacs_no_key_proceeds() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("raw.iso"); - let sectors: u32 = 999; - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let mut disc = make_test_disc(sectors, "UHD"); - disc.encrypted = true; - disc.aacs = Some(aacs_with(Vec::new())); - let opts = CopyOptions { - decrypt: false, // --raw: no decryption, no key needed - multipass: false, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - assert!( - disc.copy(&mut reader, &iso_path, &opts).is_ok(), - "--raw copy of an encrypted disc must proceed (encrypted image is the goal)" - ); - } - - #[test] - fn sweep_to_dev_null_real() { - let sectors: u32 = 1000; - let bad: std::collections::HashSet = [500u32, 501, 502].into_iter().collect(); - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: bad, - }; - let disc = make_test_disc(sectors, "T2"); - let _cleanup = CleanupGuard(disc.mapfile_for(std::path::Path::new("/dev/null"))); - let opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); - assert!( - result.is_ok(), - "sweep to /dev/null should not fail with ENODEV: {:?}", - result.err() - ); - } - - /// End-to-end Pass-1 sweep against a synthetic `MockReader` with an injected - /// bad-sector region, asserting the RESULTING MAPFILE — the thing the sweep - /// loop and damage-jump exist to produce. Drives the real `Disc::sweep` (no - /// live drive, per the project's "synthetic fixtures only" rule) and checks: - /// * the leading good region is marked Finished, - /// * the bad region (and the skip-ahead gap the damage-jump zero-fills) is - /// marked NonTrimmed, - /// * the damage-jump actually engaged — the NonTrimmed span is far larger - /// than the single failed ECC batch, which only happens if Pass-1 jumped - /// ahead (JUMP_BASE_SECTORS×batch) and zero-filled the gap as NonTrimmed, - /// * the mapfile covers the whole disc with no overlap, and good+retryable - /// accounting matches. - /// - /// Note: this exercises the real cooldown/pause pacing, so it spends a few - /// seconds of wall time on the single zone-entry pause (same cost the - /// existing `sweep_to_dev_null_real` already pays) — but unlike that test it - /// asserts the actual recovery bookkeeping, not just `is_ok()`. - #[test] - fn sweep_marks_bad_region_nontrimmed_and_engages_damage_jump() { - use crate::disc::mapfile::{Mapfile, SectorStatus}; - - let sectors: u32 = 1000; - // One bad sector at LBA 320 fails the entire ECC batch [320,352). - // batch=32 for UHD, so [0,320) = 10 clean batches before the failure. - let bad: std::collections::HashSet = [320u32].into_iter().collect(); - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: bad, - }; - let disc = make_test_disc(sectors, "DJ"); - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("dj.iso"); - let opts = SweepOptions { - decrypt: false, - resume: false, - batch_sectors: None, // → ecc batch (32) for UHD - skip_on_error: true, // multipass → damage-jump engaged - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - disc.sweep(&mut reader, &iso_path, &opts).expect("sweep"); - - let mf = Mapfile::load(&disc.mapfile_for(&iso_path)).expect("load mapfile"); - let good = mf.ranges_with(&[SectorStatus::Finished]); - let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]); - const SEC: u64 = crate::consts::SECTOR_BYTES_U64; - let disc_bytes = sectors as u64 * SEC; - - // The first failing batch starts at LBA 320; everything before it read - // cleanly and must be Finished. - let good_bytes: u64 = good.iter().map(|(_, sz)| sz).sum(); - assert!( - good_bytes > 0, - "leading clean region must be marked Finished" - ); - assert!( - good.iter().all(|(pos, sz)| pos + sz <= 320 * SEC), - "all Finished bytes must lie before the bad batch at LBA 320; got {good:?}" - ); - // The clean lead is the 10 batches [0,320) = 320 sectors. - assert_eq!( - good_bytes, - 320 * SEC, - "exactly the 320 clean sectors before the failure are Finished" - ); - - // The bad region must be NonTrimmed and must START at the failed batch. - assert!( - !bad_ranges.is_empty(), - "the failed batch must produce a NonTrimmed range" - ); - let bad_bytes: u64 = bad_ranges.iter().map(|(_, sz)| sz).sum(); - let (first_bad_pos, _) = bad_ranges[0]; - assert_eq!( - first_bad_pos, - 320 * SEC, - "NonTrimmed must begin at the failed ECC batch (LBA 320)" - ); - - // Damage-jump proof: a single ECC batch is 32 sectors. If only the failed - // batch were marked, NonTrimmed would be ~32 sectors. The fast-jump - // (JUMP_BASE_SECTORS=1024 × batch=32) overshoots this 1000-sector disc, so - // the entire tail from the failure to EOF is zero-filled NonTrimmed — far - // more than one batch. That can ONLY happen if the jump engaged. - assert!( - bad_bytes > 32 * SEC, - "NonTrimmed span ({} sectors) must exceed a single ECC batch — proves \ - the damage-jump skipped ahead and zero-filled the gap", - bad_bytes / SEC - ); - // Specifically: the jump overshoots EOF, so the whole tail [320,1000) is - // NonTrimmed. - assert_eq!( - bad_bytes, - (sectors as u64 - 320) * SEC, - "the damage-jump overshoots EOF → the entire tail is NonTrimmed" - ); - - // Whole-disc coverage with no gaps/overlap: Finished + NonTrimmed = disc. - assert_eq!( - good_bytes + bad_bytes, - disc_bytes, - "Finished + NonTrimmed must cover the whole disc exactly" - ); - // Stats agree with the range view. - let stats = mf.stats(); - assert_eq!(stats.bytes_good, good_bytes, "stats.bytes_good vs ranges"); - assert_eq!( - stats.bytes_retryable, bad_bytes, - "NonTrimmed counts as retryable in stats" - ); - assert!( - stats.bytes_unreadable == 0, - "Pass-1 never promotes to Unreadable (that's a later pass's job)" - ); - } - - /// Regression (finding 6): sweep() resume against a mapfile whose - /// total_size != the real disc size must DOWNGRADE to a fresh full sweep - /// covering [0, capacity), not reuse the stale mapfile (which would - /// abandon the disc tail or read past capacity). Mirrors copy()'s - /// covers_disc reconciliation for the direct-sweep entry point. - #[test] - fn sweep_resume_downgrades_on_size_mismatch() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("mismatch.iso"); - - // First sweep: a small disc → mapfile sized to small_sectors. - let small_sectors: u32 = 500; - let mut small_reader = MockReader { - total_sectors: small_sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let small_disc = make_test_disc(small_sectors, "SMALL"); - let opts0 = SweepOptions { - decrypt: false, - resume: false, - batch_sectors: None, - skip_on_error: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - small_disc - .sweep(&mut small_reader, &iso_path, &opts0) - .expect("initial small sweep"); - let mf = small_disc.mapfile_for(&iso_path); - assert_eq!( - mapfile::Mapfile::load(&mf).unwrap().total_size(), - small_sectors as u64 * 2048, - "precondition: mapfile reflects the small disc" - ); - - // Now a LARGER disc resumes against that stale (under-cover) mapfile. - // The reconciliation must force a fresh full sweep of the big disc. - let big_sectors: u32 = 2000; - let mut big_reader = MockReader { - total_sectors: big_sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let big_disc = make_test_disc(big_sectors, "BIG"); - let opts_resume = SweepOptions { - resume: true, - ..opts0 - }; - let result = big_disc - .sweep(&mut big_reader, &iso_path, &opts_resume) - .expect("resume sweep on mismatched mapfile"); - - assert_eq!( - result.bytes_total, - big_sectors as u64 * 2048, - "fresh sweep must be sized to the real (big) disc" - ); - assert_eq!( - result.bytes_good, - big_sectors as u64 * 2048, - "the whole big disc (incl. the tail beyond the stale mapfile) must be swept" - ); - assert_eq!( - mapfile::Mapfile::load(&mf).unwrap().total_size(), - big_sectors as u64 * 2048, - "mapfile must be re-created at the real disc size, not the stale one" - ); - } - - /// Regression (resume/mapfile consistency, MED): a resume sweep against a - /// mapfile that claims prior progress (Finished ranges) while the ISO is - /// missing/zero-length must DOWNGRADE to a fresh full sweep — NOT reuse the - /// stale mapfile. The producer only builds work from NonTried ranges, so a - /// reused mapfile would leave every Finished range unread and ZERO in the - /// new ISO (a silent hole). Reachable via autorip ResumeMode::Require when - /// the ISO was deleted/truncated but the mapfile survived. The fresh-sweep - /// downgrade self-heals: all ranges are re-read and the ISO is fully - /// populated. - #[test] - fn sweep_resume_downgrades_on_zero_iso_with_progress_mapfile() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("zeroed.iso"); - - let sectors: u32 = 500; - let total_bytes = sectors as u64 * 2048; - let disc = make_test_disc(sectors, "ZEROED"); - - // First sweep: clean disc → ISO fully written, mapfile all-Finished. - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let opts0 = SweepOptions { - decrypt: false, - resume: false, - batch_sectors: None, - skip_on_error: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - disc.sweep(&mut reader, &iso_path, &opts0) - .expect("initial clean sweep"); - let mf = disc.mapfile_for(&iso_path); - let loaded = mapfile::Mapfile::load(&mf).unwrap(); - assert_eq!( - loaded.stats().bytes_pending, - 0, - "precondition: a clean sweep leaves no pending (all Finished) ranges" - ); - - // Truncate the ISO to zero length while the progress-claiming mapfile - // survives — exactly the inconsistent-resume case. - std::fs::OpenOptions::new() - .write(true) - .truncate(true) - .open(&iso_path) - .expect("truncate ISO to zero"); - assert_eq!( - std::fs::metadata(&iso_path).unwrap().len(), - 0, - "precondition: ISO is zero-length" - ); - - // Resume sweep: must downgrade to a fresh FULL sweep, re-reading every - // range (including the formerly-Finished ones). - let mut reader2 = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let opts_resume = SweepOptions { - resume: true, - ..opts0 - }; - let result = disc - .sweep(&mut reader2, &iso_path, &opts_resume) - .expect("resume sweep on zero-length ISO"); - - // A holed resume would re-read nothing (no NonTried ranges) → bytes_good - // == 0 and a zero ISO. The downgrade re-reads the whole disc. - assert_eq!( - result.bytes_good, total_bytes, - "downgrade must re-read the whole disc, not skip Finished ranges" - ); - assert_eq!( - std::fs::metadata(&iso_path).unwrap().len(), - total_bytes, - "ISO must be re-sized + fully written, not left zero/holed" - ); - - // The ISO must actually contain the swept data (0xAA) at LBA 0 — proof - // the formerly-Finished head range was re-read, not left as a hole. - let iso = std::fs::read(&iso_path).unwrap(); - assert_eq!( - &iso[..2048], - &[0xAAu8; 2048][..], - "head sector must hold re-read data, not a zero hole" - ); - } - - /// Regression (resume reconciliation, MED follow-on): a resume sweep against - /// a CORRUPT / unparseable mapfile must DOWNGRADE to a fresh full sweep — - /// not proceed with resume=true (which would hand a garbage/empty mapfile to - /// open_or_create and silently skip ranges). The `load()` Err arm sets - /// resume=false; the `!resume` path then drops the corrupt mapfile and the - /// rip restarts clean. Consistent with the total_size-mismatch downgrade. - #[test] - fn sweep_resume_downgrades_on_corrupt_mapfile() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("corrupt.iso"); - - let sectors: u32 = 500; - let total_bytes = sectors as u64 * 2048; - let disc = make_test_disc(sectors, "CORRUPT"); - let mf = disc.mapfile_for(&iso_path); - - // Write a non-empty ISO so the zero-length-ISO guard is NOT what triggers - // the downgrade — we want the corrupt-mapfile path specifically. - std::fs::write(&iso_path, vec![0u8; total_bytes as usize]).unwrap(); - // Plant a corrupt mapfile: garbage bytes that Mapfile::load can't parse. - std::fs::write(&mf, b"this is not a valid ddrescue mapfile\nxxxx\n").unwrap(); - assert!( - mapfile::Mapfile::load(&mf).is_err(), - "precondition: the planted mapfile must be unparseable" - ); - - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let opts = SweepOptions { - decrypt: false, - resume: true, - batch_sectors: None, - skip_on_error: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc - .sweep(&mut reader, &iso_path, &opts) - .expect("resume sweep on corrupt mapfile"); - - // The downgrade must re-sweep the whole disc from a fresh mapfile. - assert_eq!( - result.bytes_good, total_bytes, - "corrupt-mapfile resume must downgrade to a fresh full sweep" - ); - let reloaded = mapfile::Mapfile::load(&mf) - .expect("a valid mapfile must have been written by the fresh sweep"); - assert_eq!( - reloaded.total_size(), - total_bytes, - "mapfile must be re-created at the real disc size" - ); - assert_eq!( - reloaded.stats().bytes_pending, - 0, - "the fresh sweep must leave all ranges Finished" - ); - } - - /// Regression: a fresh (non-resume) sweep MUST abort if the stale mapfile - /// cannot be removed, rather than swallowing the error and letting - /// `open_or_create` load the stale file (which would make the new disc - /// inherit old Finished ranges → silently zero-filled ISO). We force the - /// remove to fail with a non-ENOENT error by placing a NON-EMPTY DIRECTORY - /// at the mapfile path (`remove_file` on a dir fails, and a non-empty dir - /// can't be ENOENT). - #[test] - fn sweep_fresh_aborts_when_stale_mapfile_unremovable() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("blocked.iso"); - - let sectors: u32 = 500; - let disc = make_test_disc(sectors, "BLOCKED"); - let mf = disc.mapfile_for(&iso_path); - // Put a non-empty directory where the mapfile would live. - std::fs::create_dir_all(&mf).unwrap(); - std::fs::write(mf.join("occupant"), b"x").unwrap(); - - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let opts = SweepOptions { - decrypt: false, - resume: false, - batch_sectors: None, - skip_on_error: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.sweep(&mut reader, &iso_path, &opts); - assert!( - result.is_err(), - "fresh sweep must abort when the stale mapfile cannot be removed" - ); - } - - struct CleanupGuard(std::path::PathBuf); - impl Drop for CleanupGuard { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - - #[test] - fn sweep_dev_null_full_good() { - let sectors: u32 = 2000; - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let disc = make_test_disc(sectors, "T3"); - let _cleanup = CleanupGuard(disc.mapfile_for(std::path::Path::new("/dev/null"))); - let opts = CopyOptions { - decrypt: false, - multipass: false, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, std::path::Path::new("/dev/null"), &opts); - assert!( - result.is_ok(), - "full-good sweep to /dev/null should succeed: {:?}", - result.err() - ); - let r = result.unwrap(); - assert!(r.complete, "should be complete"); - assert_eq!(r.bytes_good, sectors as u64 * 2048); - } - - /// Finding #6 regression: on resume, copy() must NOT abandon the un-swept - /// NonTried tail when retryable (NonTrimmed) bytes also remain. The mapfile - /// covers the disc and has BOTH a NonTrimmed (retryable) range and a - /// NonTried tail; dispatch must route to a resume sweep first so the tail is - /// actually read. Before the fix, `bytes_retryable > 0` short-circuited to - /// patch and the NonTried tail was silently left unread. - #[test] - fn resume_sweeps_nontried_tail_even_with_retryable_present() { - use crate::disc::mapfile::{Mapfile, SectorStatus}; - use std::collections::HashSet; - use std::sync::{Arc, Mutex}; - - // Reader that records every LBA it is asked to read. - struct TrackingReader { - total_sectors: u32, - reads: Arc>>, - } - impl crate::sector::SectorSource for TrackingReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - { - let mut r = self.reads.lock().unwrap(); - for i in 0..count as u32 { - r.insert(lba + i); - } - } - let n = count as usize * 2048; - buf[..n].fill(0xAA); - Ok(n) - } - fn capacity_sectors(&self) -> u32 { - self.total_sectors - } - } - - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 200; - let disc = make_test_disc(sectors, "T6Tail"); - - // Pre-build a mapfile covering the whole disc: - // [0..100) Finished - // [100..150) NonTrimmed (retryable) - // [150..200) NonTried (un-swept tail) - let mf_path = disc.mapfile_for(&iso_path); - { - let mut mf = Mapfile::create(&mf_path, sectors as u64 * 2048, "test").unwrap(); - mf.record(0, 100 * 2048, SectorStatus::Finished).unwrap(); - mf.record(100 * 2048, 50 * 2048, SectorStatus::NonTrimmed) - .unwrap(); - // [150..200) stays NonTried from create()'s initial region. - mf.flush().unwrap(); - - // Sanity on the constructed state. - let st = mf.stats(); - assert!(st.bytes_nontried > 0, "must have a NonTried tail"); - assert!(st.bytes_retryable > 0, "must have retryable bytes too"); - assert_eq!(mf.total_size(), sectors as u64 * 2048); - } - // The ISO file must exist for the sweep to write into. - std::fs::write(&iso_path, vec![0u8; sectors as usize * 2048]).unwrap(); - - let reads = Arc::new(Mutex::new(HashSet::new())); - let mut reader = TrackingReader { - total_sectors: sectors, - reads: reads.clone(), - }; - let opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, &iso_path, &opts); - assert!(result.is_ok(), "resume copy failed: {:?}", result.err()); - - // The un-swept tail [150..200) MUST have been read by the resume sweep. - let got = reads.lock().unwrap(); - let tail_read = (150u32..200).any(|lba| got.contains(&lba)); - assert!( - tail_read, - "resume must sweep the NonTried tail; tail sectors were never read" - ); - } - - /// Regression (rc.6 user fix): a PLAIN (non-`--multipass`) `disc:// → iso://` - /// copy interrupted by Ctrl-C must RESUME from where it stopped when the - /// SAME command is re-issued — not restart from sector 0. The CLI help and - /// `rip_iso` examples promise "auto-resumes if interrupted". Before the fix - /// the whole mapfile-resume dispatch in `Disc::copy` was gated behind - /// `if opts.multipass`, so a plain copy always called - /// `sweep_internal(resume=false)`, which wiped the mapfile + ISO and swept - /// the disc again from LBA 0. - /// - /// Simulate an interrupted plain sweep: a mapfile that covers the disc with - /// a Finished prefix [0..100) and a NonTried tail [100..200). A plain re-run - /// must read ONLY the tail (resume) and leave the prefix untouched. - #[test] - fn plain_copy_resumes_nontried_tail_after_interrupt() { - use crate::disc::mapfile::{Mapfile, SectorStatus}; - use std::collections::HashSet; - use std::sync::{Arc, Mutex}; - - // Reader that records every LBA it is asked to read. - struct TrackingReader { - total_sectors: u32, - reads: Arc>>, - } - impl crate::sector::SectorSource for TrackingReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - { - let mut r = self.reads.lock().unwrap(); - for i in 0..count as u32 { - r.insert(lba + i); - } - } - let n = count as usize * 2048; - buf[..n].fill(0xAA); - Ok(n) - } - fn capacity_sectors(&self) -> u32 { - self.total_sectors - } - } - - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 200; - let disc = make_test_disc(sectors, "PlainResume"); - - // Pre-build a mapfile mimicking an interrupted plain sweep: - // [0..100) Finished (already written before Ctrl-C) - // [100..200) NonTried (un-swept tail) - let mf_path = disc.mapfile_for(&iso_path); - { - let mut mf = Mapfile::create(&mf_path, sectors as u64 * 2048, "test").unwrap(); - mf.record(0, 100 * 2048, SectorStatus::Finished).unwrap(); - // [100..200) stays NonTried from create()'s initial region. - mf.flush().unwrap(); - - let st = mf.stats(); - assert!(st.bytes_nontried > 0, "must have a NonTried tail"); - assert_eq!(st.bytes_retryable, 0, "plain interrupt leaves no retryable"); - assert_eq!(mf.total_size(), sectors as u64 * 2048); - } - // The ISO file must already exist (it was being written before the - // interrupt) so the resume opens it rather than recreating it. - std::fs::write(&iso_path, vec![0u8; sectors as usize * 2048]).unwrap(); - - let reads = Arc::new(Mutex::new(HashSet::new())); - let mut reader = TrackingReader { - total_sectors: sectors, - reads: reads.clone(), - }; - // PLAIN copy — multipass: false. This is the path the bug broke. - let opts = CopyOptions { - decrypt: false, - multipass: false, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, &iso_path, &opts); - assert!( - result.is_ok(), - "plain resume copy failed: {:?}", - result.err() - ); - - let got = reads.lock().unwrap(); - // The NonTried tail [100..200) MUST have been read by the resume sweep. - let tail_read = (100u32..200).any(|lba| got.contains(&lba)); - assert!( - tail_read, - "plain copy must resume-sweep the NonTried tail; tail sectors were never read" - ); - // The Finished prefix [0..100) must NOT be re-read — that would mean a - // restart-from-zero (the bug), not a resume. - let prefix_reread = (0u32..100).any(|lba| got.contains(&lba)); - assert!( - !prefix_reread, - "plain copy must NOT re-read the already-Finished prefix (it restarted from sector 0)" - ); - - // The mapfile must now be fully Finished (disc fully swept on resume). - let reloaded = Mapfile::load(&mf_path).unwrap(); - assert_eq!( - reloaded.stats().bytes_nontried, - 0, - "resume sweep must clear the NonTried tail" - ); - } - - #[test] - fn patch_dev_null_after_sweep() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 500; - let bad: std::collections::HashSet = [100u32, 200, 300].into_iter().collect(); - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: bad.clone(), - }; - let disc = make_test_disc(sectors, "T4"); - - let sweep_opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts); - assert!( - sweep_result.is_ok(), - "sweep should succeed: {:?}", - sweep_result.err() - ); - - let mut reader2 = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let patch_opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let patch_result = disc.copy(&mut reader2, &iso_path, &patch_opts); - assert!( - patch_result.is_ok(), - "patch should succeed: {:?}", - patch_result.err() - ); - let pr = patch_result.unwrap(); - assert!( - pr.complete, - "patch should complete: bytes_pending={}", - pr.bytes_pending - ); - } - - #[test] - fn patch_dev_null_direct() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 500; - let bad: std::collections::HashSet = [100u32, 200, 300].into_iter().collect(); - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: bad.clone(), - }; - let disc = make_test_disc(sectors, "T5"); - - let sweep_opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let _sweep_result = disc.copy(&mut reader, &iso_path, &sweep_opts).unwrap(); - - let mut reader2 = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let patch_opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let patch_result = disc.copy(&mut reader2, std::path::Path::new("/dev/null"), &patch_opts); - assert!( - patch_result.is_ok(), - "patch to /dev/null should succeed: {:?}", - patch_result.err() - ); - } - - /// Synthetic regression test for the 0.18 SweepSink + Pipeline - /// migration. ~100 batches of clean reads (6000 sectors at the - /// default 60-sector single-pass batch size); verifies all bytes - /// land in the ISO and the consumer's final stats match the input. - /// The throughput regression check (vs 0.17.13) is a separate - /// manual / live-drive concern; here we only assert correctness. - #[test] - fn sweep_pipeline_full_good_100_batches() { - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - // 6000 sectors / 60-sector default batch = exactly 100 - // produce/consume cycles through the pipeline. - let sectors: u32 = 6000; - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - let disc = make_test_disc(sectors, "TPipeline100"); - let opts = CopyOptions { - decrypt: false, - multipass: false, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - let result = disc.copy(&mut reader, &iso_path, &opts); - let r = result.expect("100-batch clean sweep should succeed"); - assert!(r.complete, "complete=true expected"); - assert!(!r.halted, "halted=false expected"); - assert_eq!( - r.bytes_good, - sectors as u64 * 2048, - "all sectors must be marked good after a 100% clean sweep" - ); - assert_eq!( - r.bytes_pending, 0, - "no pending bytes expected after a clean sweep" - ); - // The ISO file must end up the right size — the consumer - // wrote everything before fsync. - let meta = std::fs::metadata(&iso_path).unwrap(); - assert_eq!(meta.len(), sectors as u64 * 2048); - } - /// bytes_bad_in_title must overlap per-extent, not against a single /// bounding box: a bad range in the gap between two extents of the /// same title must NOT be counted. @@ -7086,183 +4825,4 @@ mod tests { // required ReadAction values are produced by handle_read_error in the // bisect-inner context (bisecting=true, batch=1), so that any regression // to `let _ = ...` would break real behaviour on the tested error paths. - - /// NOT_READY inside a bisect must return Retry, not SkipBlock. - /// If the inner loop discarded the action the 3-second cooldown would be - /// skipped, hammering the drive during a transient NOT_READY condition. - #[test] - fn bisect_inner_not_ready_returns_retry_with_pause() { - use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error}; - use crate::error::Error; - use crate::scsi::ScsiSense; - - let not_ready_err = Error::DiscRead { - sector: 500, - status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), - sense: Some(ScsiSense { - sense_key: crate::scsi::SENSE_KEY_NOT_READY, - asc: 0x04, - ascq: 0x00, // not 0x3E — generic NOT_READY, not bridge degradation - }), - }; - - let mut ctx = ReadCtx::for_patch(1); - ctx.bisecting = true; // simulate being inside the bisect inner loop - - let action = handle_read_error(¬_ready_err, &mut ctx); - match action { - ReadAction::Retry { pause_secs } => { - assert!( - pause_secs > 0, - "NOT_READY retry must carry a non-zero pause; got {pause_secs}s" - ); - } - other => panic!( - "bisect inner NOT_READY must return Retry{{pause_secs}}, got {other:?}; \ - a discard (`let _ = ...`) would skip this pause and hammer the drive" - ), - } - } - - /// A transport failure inside a bisect must return AbortPass. - /// If the inner loop discarded the action the loop would continue - /// issuing reads against a crashed bridge, producing spurious BisectBad - /// entries and potentially looping until the batch is exhausted. - #[test] - fn bisect_inner_transport_failure_returns_abort_pass() { - use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error}; - use crate::error::Error; - - let transport_err = Error::DiscRead { - sector: 500, - status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE), - sense: None, - }; - - let mut ctx = ReadCtx::for_patch(1); - ctx.bisecting = true; - - let action = handle_read_error(&transport_err, &mut ctx); - assert_eq!( - action, - ReadAction::AbortPass, - "bisect inner transport failure must return AbortPass; \ - a discard (`let _ = ...`) would silently keep looping against a crashed drive" - ); - } - - /// After enough consecutive wedge errors with bisecting=true the handler - /// must eventually return AbortPass. Before the fix, the inner loop - /// discarded the returned action and kept issuing reads against a permanently - /// wedged drive at full rate. - /// - /// The threshold is 16 consecutive wedges (WEDGE_ABORT_THRESHOLD in - /// read_error.rs); we drive 20 iterations to give the assertion headroom - /// without hard-coding the internal constant here. - #[test] - fn bisect_inner_wedge_abort_threshold_reached_returns_abort_pass() { - use crate::disc::read_error::{ReadAction, ReadCtx, handle_read_error}; - use crate::error::Error; - use crate::scsi::ScsiSense; - - let hardware_err = || Error::DiscRead { - sector: 500, - status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), - sense: Some(ScsiSense { - sense_key: crate::scsi::SENSE_KEY_HARDWARE_ERROR, - asc: 0x44, - ascq: 0x00, - }), - }; - - let mut ctx = ReadCtx::for_patch(1); - ctx.bisecting = true; - - let mut aborted = false; - for _ in 0..20 { - let action = handle_read_error(&hardware_err(), &mut ctx); - if action == ReadAction::AbortPass { - aborted = true; - break; - } - } - assert!( - aborted, - "bisect inner wedge loop must reach AbortPass after consecutive hardware errors; \ - a discard (`let _ = ...`) would loop forever on a bricked drive" - ); - } - - /// Regression: copy() dispatch with covers_disc=true, retryable=0, nontried>0 must - /// route to sweep_internal(resume=true) so the unread NonTried ranges are actually - /// read rather than silently abandoned. - /// - /// Before the fix the fallthrough returned a terminal CopyResult immediately, - /// leaving the NonTried sectors unread. - #[test] - fn copy_dispatch_routes_to_sweep_when_nontried_gt_zero() { - use crate::disc::mapfile::{self, SectorStatus}; - - let tmp = tempfile::tempdir().unwrap(); - let iso_path = tmp.path().join("test.iso"); - let sectors: u32 = 200; - let disc = make_test_disc(sectors, "DispatchNonTried"); - let disc_size = sectors as u64 * 2048; - - // Synthesise a mapfile that covers the disc (total_size == disc_size) with: - // - [0, half_bytes): Finished - // - [half_bytes, disc_size): NonTried - // This gives covers_disc=true, bytes_retryable=0, bytes_nontried>0. - let mf_path = disc.mapfile_for(&iso_path); - let half_bytes = disc_size / 2; - { - let mut map = - mapfile::Mapfile::create(&mf_path, disc_size, "test").expect("create mapfile"); - map.record(0, half_bytes, SectorStatus::Finished) - .expect("record Finished"); - map.flush().expect("flush"); - } - - // Create an ISO file pre-sized to the full disc size so the resume - // sweep can open it and write the NonTried regions at their offsets. - // (len > 0 selects the resume-open branch; full pre-size avoids - // short-seek writes past EOF.) - { - let f = std::fs::File::create(&iso_path).expect("create iso"); - f.set_len(disc_size).expect("pre-size iso"); - } - - // All sectors are readable in this reader. - let mut reader = MockReader { - total_sectors: sectors, - bad_sectors: std::collections::HashSet::new(), - }; - - let opts = CopyOptions { - decrypt: false, - multipass: true, - progress: None, - halt: None, - vid: None, - unit_keys: Vec::new(), - - key_fetch: None, - }; - - let result = disc.copy(&mut reader, &iso_path, &opts); - assert!( - result.is_ok(), - "copy with nontried>0 should succeed: {:?}", - result.err() - ); - let r = result.unwrap(); - // The sweep must have read the NonTried half — bytes_good should be - // the whole disc, not just the already-Finished half. - assert_eq!( - r.bytes_good, disc_size, - "all sectors must be good after resume sweep reads the NonTried half \ - (before fix: terminal returned with bytes_good={}, skipping {} NonTried bytes)", - half_bytes, half_bytes - ); - } } diff --git a/src/disc/patch.rs b/src/disc/patch.rs deleted file mode 100644 index 76e948a..0000000 --- a/src/disc/patch.rs +++ /dev/null @@ -1,1668 +0,0 @@ -//! 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 { - let halt = opts.halt.clone().map(crate::halt::Halt::from_arc); - Some(std::sync::Arc::new(self.resolve_content_key_map( - reader, - &mut keys, - opts.key_fetch.as_ref(), - halt.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)]); - } -} diff --git a/src/disc/read_error.rs b/src/disc/read_error.rs deleted file mode 100644 index cf229fa..0000000 --- a/src/disc/read_error.rs +++ /dev/null @@ -1,1523 +0,0 @@ -//! Single source of truth for what to do when a sector read fails. -//! -//! Pass 1 (`Disc::sweep`) calls into `handle_read_error` after every failed -//! `read_sectors`. The handler classifies the error, updates the in-flight -//! context (counters, damage window, retry budgets), and returns a -//! `ReadAction` the caller dispatches on. Pass N patch has its own -//! `handle_read_failure` in `disc/patch.rs` that does not route here. -//! -//! Adding a new error class = add one arm in `handle_read_error`. -//! Adding new logging on errors = one place. - -use crate::error::Error; -use crate::scsi; -use crate::scsi::SenseFamily; - -/// In-flight bookkeeping a read loop must keep across iterations. The -/// handler reads and mutates this. Caller owns the storage. -pub struct ReadCtx { - /// Number of sectors per read attempt. The handler uses this to - /// decide whether to bisect (only worthwhile when batch > 1). - pub batch: u16, - /// Successful reads since the last failure. Resets to 0 on failure. - /// Used by callers to drive damage-zone exit / speed restoration. - pub consecutive_good: u64, - /// Failed reads since the last success. Resets to 0 on success. - /// Drives long-pause escalation on persistent failure. - pub consecutive_failures: u64, - /// Failed OUTER batch reads since the last outer success — bisect - /// inner-sector failures are NOT counted here. Drives the - /// fast-entry damage-jump on Pass 1 (skip the disc-level grind - /// once we're clearly in a damaged region; Pass N will recover - /// the actual sectors). Reset on outer success. - pub consecutive_outer_failures: u64, - /// Sliding window of recent read outcomes (true=ok, false=fail). - /// Capped at `damage_window_max`. Drives damage-jump decisions. - pub damage_window: Vec, - /// Maximum number of outcome entries kept in `damage_window`; the - /// oldest is evicted once this is exceeded. A whole count (e.g. 16). - pub damage_window_max: usize, - /// Fraction of `damage_window` entries that must be failures before - /// the window-based damage-jump fires, as a whole-number percentage - /// (e.g. `12` = 12%). - pub damage_threshold_pct: usize, - /// Trigger a damage-jump after this many consecutive outer-batch - /// failures, even when the damage_window isn't full yet. Pass 1 - /// uses a small value (1 — jump on the first outer failure; see - /// the 2026-05-11 rewrite in `for_sweep`) so we don't spend ~40 - /// minutes grinding to fill a 16-block window before the first jump - /// on a damage zone we entered cleanly. Pass N uses a larger value - /// (or disables this — see `bisect_on_marginal`) because Pass N's - /// whole job IS to grind on the bad ranges. - pub fast_jump_threshold: u64, - /// Multiplier applied to damage-jump distance. Doubles each jump, - /// resets to 1 after `damage_window_max` consecutive good reads. - pub jump_multiplier: u64, - /// NOT_READY retries used so far for the current LBA. Reset to 0 - /// on any non-NOT_READY response. - pub not_ready_retries: u32, - /// Bridge-degradation cooldowns used so far. - pub bridge_degradation_count: u32, - /// Whether we're currently inside a damage-jump bisect attempt. - /// Caller sets this true when entering single-sector mode for a - /// failed batch, so the handler doesn't recursively request another - /// bisect on the inner-sector failures. - pub bisecting: bool, - /// Whether to return `Bisect` on a marginal-media batch failure. - /// Pass 1 sweep sets this false: a failed batch becomes - /// SkipBlock (mark the whole 32-sector ECC block NonTrimmed, - /// advance, let Pass N recover the salvageable sectors with - /// proper recovery semantics). Pass N sets this true: bisection - /// is its core job, and it has the right tools (single-sector - /// reads, 60s recovery timeout, retry budget, escalating skip). - pub bisect_on_marginal: bool, - /// Count of consecutive firmware-wedge responses (HARDWARE_ERROR - /// or ILLEGAL_REQUEST sense keys) since the last successful read. - /// Pass 1 uses this to drive the wedge-skip path: each wedge - /// triggers a 1 GB jump + cooldown pause. Reaching - /// `WEDGE_ABORT_THRESHOLD` consecutive wedges with no good read - /// in between → real AbortPass. - pub wedge_count: u64, - // ── Diagnostic counters (added 2026-05-10) ── - // - // Aggregate state for post-mortem analysis of wedge incidents. - // Every Pass 1 / Pass N sweep now produces a structured summary - // at the WARN log on each error AND an end-of-pass INFO summary. - // Goal: when a wedge happens, the operator should be able to tell - // from the logs whether it was triggered by ONE read at a - // physically-damaged sector (immediate failure) or by accumulated - // exposure across MANY reads (firmware-state buildup), and what - // the timing pattern looked like. - /// `Instant` of the most recent successful read. Used to compute - /// "time since last good" for the WARN log on each error. None - /// before the first successful read. - pub last_success_at: Option, - /// `Instant` of the most recent failed read. Used to compute - /// "time since last error" for the WARN log. None before the - /// first error. - pub last_error_at: Option, - /// Last error's sense-key "family" (Medium / Hardware / IllegalRequest - /// / NotReady / Other). Used to detect WEDGE TRANSITIONS — when - /// the family changes from Medium → Hardware/IllegalRequest, the - /// drive almost certainly just entered fast-fail mode. That - /// transition gets its own WARN log so the trace is unambiguous. - pub last_error_family: Option, - /// Sum of all errors observed during this sweep. Reported in the - /// end-of-pass summary. - pub total_errors: u64, - /// Sum of all successful reads during this sweep. - pub total_reads_ok: u64, - /// Count of damage zones entered (transitions from clean → in-damage). - pub zones_entered: u64, - /// Count of damage-jumps executed during this sweep. - pub jumps_taken: u64, - /// True between "first error after a clean period" and "16 consecutive - /// good reads after the last error in the cluster." Used to count - /// zone entries and to bound zone_reads accurately. - pub in_damage_zone: bool, - /// Count of RECOVERED ERROR (marginal) reads the drive reported this pass - /// (surfaced by the PER=1 mode-select at drive-prep). Each is distrusted and - /// marked NonTrimmed for a Pass N re-read; the count is reported in the - /// pass summary so an operator can see how much of a "clean" rip was actually - /// marginal. - pub marginal_recovered: u64, -} - -impl ReadCtx { - /// Initial context for a Pass 1 sweep. The job is "fast and - /// accurate, get the most data in the shortest time" — Pass N - /// is the one that grinds on the bad ranges. So bisect-on- - /// marginal is OFF (failed batches become SkipBlock; whole 32- - /// sector blocks marked NonTrimmed for Pass N to revisit), and - /// the damage-jump fast-path triggers after just 1 consecutive - /// outer-batch failure — the user's wedge-prevention principle - /// (2026-05-11): once the drive returns ANY recoverable error, - /// retrying the same LBA quickly is what triggers the firmware - /// fast-fail transition. On the damage-jump and marginal paths Pass 1 - /// jumps immediately rather than grinding the same LBA. Transient errors - /// (NOT_READY, bridge degradation) are still retried a small bounded - /// number of times (`NOT_READY_MAX_RETRIES` / `BRIDGE_DEGRADATION_MAX_RETRIES`) - /// in both passes before falling through to the skip path. - /// Pass N owns the heavy retries — it gets per-sector timeouts that don't - /// hammer the firmware the same way. - pub fn for_sweep(batch: u16) -> Self { - Self { - batch, - consecutive_good: 0, - consecutive_failures: 0, - consecutive_outer_failures: 0, - damage_window: Vec::with_capacity(16), - damage_window_max: 16, - damage_threshold_pct: 12, - fast_jump_threshold: 1, - jump_multiplier: 1, - not_ready_retries: 0, - bridge_degradation_count: 0, - bisecting: false, - bisect_on_marginal: false, - wedge_count: 0, - last_success_at: None, - last_error_at: None, - last_error_family: None, - total_errors: 0, - total_reads_ok: 0, - zones_entered: 0, - jumps_taken: 0, - in_damage_zone: false, - marginal_recovered: 0, - } - } - - /// Initial context for a Pass 2-N patch. Pass N's whole reason to - /// exist is to recover sectors Pass 1 skipped — bisection on - /// marginal media is part of the job, and the fast-jump - /// threshold is loose so we don't bail too early on a range that - /// has scattered good sectors mixed in. - /// - /// `damage_threshold_pct = 6` is looser than Pass 1 (12%): Pass N triggers - /// the damage-skip at half Pass 1 density because the patch loop exists to chip - /// away at bad ranges, so being more eager to skip clustered bad sectors - /// converges faster on the recoverable good sectors inside a range. - pub fn for_patch(batch: u16) -> Self { - Self { - batch, - consecutive_good: 0, - consecutive_failures: 0, - consecutive_outer_failures: 0, - damage_window: Vec::with_capacity(16), - damage_window_max: 16, - damage_threshold_pct: PATCH_DAMAGE_THRESHOLD_PCT, - // Pass N is allowed to grind: window-based jump only, - // matching the historical behaviour for patch passes. - fast_jump_threshold: u64::MAX, - jump_multiplier: 1, - not_ready_retries: 0, - bridge_degradation_count: 0, - bisecting: false, - bisect_on_marginal: true, - wedge_count: 0, - last_success_at: None, - last_error_at: None, - last_error_family: None, - total_errors: 0, - total_reads_ok: 0, - zones_entered: 0, - jumps_taken: 0, - in_damage_zone: false, - marginal_recovered: 0, - } - } - - /// Caller calls this after every successful read. - pub fn on_success(&mut self) { - self.consecutive_good += 1; - self.consecutive_failures = 0; - self.not_ready_retries = 0; - // Any successful read clears the wedge-skip counter — the - // drive recovered, so further wedges should reset the skip - // budget instead of accumulating toward a real abort. - self.wedge_count = 0; - // A successful read also means the bridge recovered, so the - // 15s-cooldown retry budget should be available again for the - // next bridge-degradation event. Without this reset the budget - // saturates permanently after 5 cumulative events across the - // whole pass and later degradations skip the cooldown retry, - // needlessly losing data. - self.bridge_degradation_count = 0; - // Outer-success only: a good single-sector read inside a - // bisect doesn't mean we've left the damaged batch. Only an - // outer-batch success resets the outer-failure counter. - if !self.bisecting { - self.consecutive_outer_failures = 0; - } - self.damage_window.push(true); - if self.damage_window.len() > self.damage_window_max { - self.damage_window.remove(0); - } - // Diagnostic state. - self.total_reads_ok += 1; - self.last_success_at = Some(std::time::Instant::now()); - // If we were in a damage zone and accumulated enough good - // reads to exit (damage_window now all-good), the zone is - // over. Don't reset zones_entered — that's a sweep total. - if self.in_damage_zone && self.consecutive_good >= self.damage_window_max as u64 { - self.in_damage_zone = false; - self.last_error_family = None; - // Reset the damage-jump multiplier so the NEXT zone starts - // from the base jump distance. Without this the multiplier - // stays at whatever the prior zone inflated it to (up to - // MAX_JUMP_MULTIPLIER=64), so the next zone's first jump is - // 64x oversized and skips recoverable data. The field doc - // promises this reset. - self.jump_multiplier = 1; - } - } - - /// Final per-pass summary suitable for an INFO log at the end of - /// `sweep` / `patch`. Caller renders this to a single structured - /// log line. - pub fn pass_summary(&self) -> PassSummary { - PassSummary { - total_reads_ok: self.total_reads_ok, - total_errors: self.total_errors, - zones_entered: self.zones_entered, - jumps_taken: self.jumps_taken, - marginal_recovered: self.marginal_recovered, - } - } -} - -/// End-of-pass stats logged at INFO for post-mortem analysis. Lets -/// an operator answer "how damaged is this disc?" from a single log -/// line per pass. -#[derive(Debug, Clone, Copy)] -pub struct PassSummary { - pub total_reads_ok: u64, - pub total_errors: u64, - pub zones_entered: u64, - pub jumps_taken: u64, - /// RECOVERED ERROR (marginal) reads distrusted and re-queued for Pass N. - pub marginal_recovered: u64, -} - -/// What the caller should do after a read failure. The caller owns the -/// I/O side-effects (sleep, write zeros, advance pos) — the handler -/// only decides which side-effects. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReadAction { - /// Pause `pause_secs` then retry the same LBA / batch. Used for - /// transient conditions (NOT_READY, bridge degradation) that the - /// drive may recover from on its own. - Retry { pause_secs: u64 }, - /// Re-issue the failed batch as `batch` single-sector reads. Each - /// inner read is itself dispatched through `handle_read_error` with - /// `bisecting = true` so it cannot recurse. - Bisect, - /// Mark the failed range NonTrimmed (zero-fill, retry in Pass N+), - /// then pause `pause_secs` before resuming the next LBA. - SkipBlock { pause_secs: u64 }, - /// Mark the failed range NonTrimmed AND advance position by - /// `sectors` (zero-filling the gap as NonTrimmed). Then pause - /// `pause_secs`. Used when the damage-window threshold is crossed. - JumpAhead { sectors: u64, pause_secs: u64 }, - /// Unrecoverable at this layer. Caller propagates `Err` up to the - /// outer pass loop / autorip, which can attempt USB re-enumeration, - /// drop session, etc. - AbortPass, -} - -// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing -// bridge wedges 524 ms after a 5.4-second internal ECC retry. The -// post-failure pauses give the drive — and the bridge — time to settle. -/// Pause between a failed read and the next read attempt — applied -/// by Pass 1 sweep via `handle_read_error`. Pass N patch uses its own -/// `POST_FAILURE_PAUSE_SECS` (see `disc/patch.rs`). -/// -/// 2026-05-11 reframe: a failed read is a failed read, regardless of -/// which pass is running. The prior split (1s for Pass N, 5s for Pass -/// 1 via `PASS_1_FAIL_PAUSE_SECS`) was solving an imaginary cost -/// problem — real damaged-disc cases mark <50 MB NonTrimmed, and the -/// extra 5s/error is single-digit minutes per pass, not hours. The -/// cost of NOT pausing — a drive wedge that aborts the entire -/// multi-pass recovery — is much worse. -/// -/// The wedge avoidance principle: error → drive ECC retry (5-10s -/// internal) → return → cooldown pause → next read. Same shape -/// everywhere reads can fail. -const FAIL_PAUSE_SECS: u64 = 5; -/// Long cooldown applied when a damage zone is first entered (the -/// FIRST read failure after a clean run, before the drive has had a -/// chance to cycle in retries that push it toward fast-fail). -/// -/// Empirical: a 2026-05-11 wedge incident showed 7 medium -/// errors in 6.5 seconds (~1s per attempt + ~1s pause) push the -/// BU40N's firmware into IllegalRequest fast-fail mode permanently. -/// Once there, only physical eject + reload clears it. Giving the -/// drive 30s of breathing room after the FIRST error in a zone — -/// before we start adding more error counts in the firmware's -/// internal window — prevents the transition. -/// -/// Cost on clean discs: zero (first-error path doesn't trigger). -/// Cost on damaged discs: ~30s × N damage zones; on a 5-zone disc -/// that's 2.5 min extra. Trade for never wedging the drive. -pub(crate) const ZONE_ENTRY_COOLDOWN_SECS: u64 = 30; -/// Cooldown when a long streak of failures suggests the drive is -/// stuck in a damage zone and needs MORE breathing room than the -/// standard inter-error pause. Same value as `FAIL_PAUSE_SECS` -/// because empirically 5s is enough; kept as a separate name so the -/// escalation policy is explicit at the call site. -const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5; -const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10; -const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2; -const NOT_READY_PAUSE_SECS: u64 = 3; -const NOT_READY_MAX_RETRIES: u32 = 3; -const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15; -const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5; - -/// Base of the damage-jump distance formula: `jump_sectors = -/// JUMP_BASE_SECTORS × batch × jump_multiplier`. Bumped 2026-05-10 -/// from 256 → 1024 (4×) so the first damage-jump at batch=32 covers -/// 64 MB instead of 16 MB. Empirically the BU40N's damage clusters -/// are 100+ MB wide; 16 MB jumps landed inside the cluster and the -/// re-read added to the firmware wedge counter. 64 MB → 128 MB -/// (after one doubling) clears almost any single-cluster damage in -/// 2 jumps. -const JUMP_BASE_SECTORS: u64 = 1024; - -// Firmware-wedge skip policy for Pass 1 sweep -// =========================================== -// -// When the BU40N (or similar drives) hits a physical-damage cluster, -// its firmware can transition into a "wedge" state where it returns -// HARDWARE_ERROR or ILLEGAL_REQUEST for every subsequent read — -// often for many LBAs after the actual bad sector. Once wedged, -// recovery requires either a physical eject + reload or a significant -// cool-down period; hammering the same LBA only deepens the state. -// -// Pass 1's pre-fix behavior was to immediately AbortPass on the -// first HARDWARE_ERROR / ILLEGAL_REQUEST, killing the rip at -// whatever percentage it had reached. That's the wrong call when: -// - the damage zone may be small (jumping past it could resume -// normal reads), AND -// - even if the drive stays wedged, finishing the sweep gives us -// an honest mapfile for Pass N to attack later. -// -// New policy: treat wedge sense codes the same way the damage-window -// treats persistent failure — JumpAhead by a large distance with a -// cooldown pause. Allow up to WEDGE_ABORT_THRESHOLD consecutive -// wedges (no successful read in between) before declaring the drive -// truly stuck and surfacing AbortPass to autorip. - -/// One-gigabyte jump (1024 MiB) on each wedge. Big enough to clear -/// almost any single-cluster damage zone we've seen. -const WEDGE_JUMP_SECTORS: u64 = 524_288; -/// Cooldown pause after each wedge. A wedged drive needs a -/// significant cool-down to leave fast-fail; 30 s strikes a balance -/// between giving the drive a chance to recover and not stalling the -/// rip if the drive is permanently stuck. -const WEDGE_PAUSE_SECS: u64 = 30; -/// Bail after this many consecutive wedges with no good read in -/// between. At 1 GB jumps this lets us scan ~16 GB worth of fully -/// wedged area before giving up — generous enough to clear most -/// physical-damage clusters, bounded enough to not loop forever on -/// a permanently bricked drive. -const WEDGE_ABORT_THRESHOLD: u64 = 16; - -/// Pass-N wedge-skip distance. Pass N's batch=1 reads target -/// specific NonTrimmed sectors from Pass 1, so a big 1 GB skip -/// would blow past the current NonTrimmed range and abandon many -/// sectors that might still recover. Use a smaller skip just to -/// move past the bricked LBA + a small buffer — the outer patch -/// loop's next iteration picks up the next sector in the same or -/// next range. -const WEDGE_PASS_N_SKIP_SECTORS: u64 = 64; - -/// Single source of truth for the Pass-N damage-window threshold. -/// [`ReadCtx::for_patch`] reads this constant for the Pass-N damage-skip -/// threshold. -/// -/// 6% means: with a 16-entry sliding window, the damage-skip fires -/// once 1 out of 16 recent reads has failed. Pass 1 uses a 12% -/// threshold via `damage_threshold_pct` on `for_sweep`; Pass N is -/// twice as eager because patch's whole job is to converge on the -/// bad sub-zones inside a NonTrimmed range — a faster trigger -/// produces tighter convergence in fewer iterations. -pub const PATCH_DAMAGE_THRESHOLD_PCT: usize = 6; - -/// THE single error-handling entry point. Updates `ctx`, returns the -/// action the caller must apply. -/// -/// New error class = add a new arm here. New logging on errors = add -/// it once at the top. New retry policy = adjust the constants. No -/// other read site needs to change. -pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction { - ctx.consecutive_failures += 1; - ctx.consecutive_good = 0; - // Outer-failure counter — only OUTER batch failures count toward - // the fast-jump trigger. Bisect inner failures are part of - // recovering an already-failed batch and don't represent - // independent damage signal. - if !ctx.bisecting { - ctx.consecutive_outer_failures += 1; - } - - // Diagnostic instrumentation — compute timing context BEFORE - // mutating the timestamps so the log reflects the gap to the - // PREVIOUS error / success, not zero. - let now = std::time::Instant::now(); - let ms_since_last_error = ctx - .last_error_at - .map(|t| now.duration_since(t).as_millis() as u64); - let ms_since_last_success = ctx - .last_success_at - .map(|t| now.duration_since(t).as_millis() as u64); - - let current_family = err - .scsi_sense() - .map(|s| SenseFamily::from_sense_key(s.sense_key)) - .unwrap_or(SenseFamily::Other); - - // Zone-entry tracking: this is the first error after a clean run - // (or the first error of the sweep). Capture the genuine - // clean->damaged transition here, BEFORE mutating in_damage_zone, - // so the 30s zone-entry cooldown below keys off the real - // transition rather than re-deriving it from a counter that the - // fast-jump path resets after every jump. - // - // A RECOVERED_ERROR (marginal read) is explicitly NOT damage-zone signal - // (see the SkipBlock branch below) — it returns early, so latching - // in_damage_zone here would spuriously consume the zone-entry transition and - // let a genuine hard error that follows skip the 30s wedge cooldown. - let is_recovered = - err.scsi_sense().map(|s| s.sense_key) == Some(scsi::SENSE_KEY_RECOVERED_ERROR); - let is_zone_entry_transition = !ctx.in_damage_zone && !ctx.bisecting && !is_recovered; - if is_zone_entry_transition { - ctx.in_damage_zone = true; - ctx.zones_entered += 1; - } - - ctx.total_errors += 1; - ctx.last_error_at = Some(now); - - // Wedge transition: previous error was MEDIUM, this one is - // HARDWARE or ILLEGAL_REQUEST. That's the moment the drive's - // firmware flipped into fast-fail mode. Distinct WARN so logs - // make it unambiguous when the wedge "started." - let is_wedge_transition = matches!(ctx.last_error_family, Some(prev) if !prev.is_wedge_family()) - && current_family.is_wedge_family(); - ctx.last_error_family = Some(current_family); - - tracing::warn!( - target: "freemkv::disc", - phase = "read_error", - consecutive_failures = ctx.consecutive_failures, - consecutive_outer_failures = ctx.consecutive_outer_failures, - ms_since_last_error, - ms_since_last_success, - total_errors = ctx.total_errors, - total_reads_ok = ctx.total_reads_ok, - batch = ctx.batch, - bisecting = ctx.bisecting, - wedge_count = ctx.wedge_count, - sense_family = ?current_family, - sense_key = err.scsi_sense().map(|s| s.sense_key), - asc = err.scsi_sense().map(|s| s.asc), - ascq = err.scsi_sense().map(|s| s.ascq), - error = %err, - "read failed; classifying" - ); - - if is_wedge_transition { - // NOTE: this is the FIRST escalation into the hardware/illegal-request - // sense family — NOT a confirmed wedge. Drives frequently recover and keep - // reading after one such error (a single bad spot), so calling it a "wedge" - // here over-claims (it sent past investigations chasing a drive ghost). A - // genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD - // path below, which only fires after repeated fast-fails with no recovery. - tracing::warn!( - target: "freemkv::disc", - phase = "fastfail_escalation", - errors_in_zone = ctx.total_errors, - ms_since_last_success, - new_family = ?current_family, - "drive escalated into the fast-fail sense family (was returning recoverable medium \ - errors before this) — often transient; only a PERSISTENT run is a real wedge" - ); - } - - // 1. Transport failure: bridge crash / USB disconnect. The outer - // pass loop knows how to handle this (rediscover sg path, - // re-open drive). Inline single-sector retry here was tried in - // pre-v0.17.0 builds and observed to make wedges worse. - if err.is_scsi_transport_failure() { - return ReadAction::AbortPass; - } - - // 2. Bridge degradation: the SCSI status byte is non-standard — - // neither GOOD (0x00), CHECK CONDITION (0x02), nor TRANSPORT - // FAILURE (0xFF). The USB bridge firmware returns these bogus - // status bytes (e.g. 0x04, 0x05) with empty sense data when it - // enters a semi-stuck state preceding a crash. This is keyed on - // the status byte alone, NOT on sense_key/ASC/ASCQ — a real - // NOT_READY 04/3E bad-sector error arrives as CHECK CONDITION - // (0x02) and is handled by the generic NOT_READY branch below. - // The bridge typically recovers after a long cooldown; if we've - // exhausted our retry budget, fall through to the marginal/skip - // path below. - if err.is_bridge_degradation() && ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES - { - ctx.bridge_degradation_count += 1; - return ReadAction::Retry { - pause_secs: BRIDGE_DEGRADATION_PAUSE_SECS, - }; - } - - let sense_key = err.scsi_sense().map(|s| s.sense_key).unwrap_or(0); - - // 3. Generic NOT_READY (other ASC codes): drive's mechanical - // pickup may be moving. Pause and retry briefly. - if sense_key == scsi::SENSE_KEY_NOT_READY && ctx.not_ready_retries < NOT_READY_MAX_RETRIES { - ctx.not_ready_retries += 1; - return ReadAction::Retry { - pause_secs: NOT_READY_PAUSE_SECS, - }; - } - if sense_key != scsi::SENSE_KEY_NOT_READY { - ctx.not_ready_retries = 0; - } - - // 4. Hardware error / illegal request — the firmware-wedge family. - // The drive transitioned into a fast-fail state where it - // rejects reads near the LBA. Same response shape for both - // passes (2026-05-11 reframe — error handling is centralized, - // and the wedge is a code-induced state we can avoid via - // pacing + skip): - // - // - Pass 1 sweep (bisect_on_marginal=false): jump - // WEDGE_JUMP_SECTORS (1 GB) ahead, pause WEDGE_PAUSE_SECS, - // mark skipped region NonTrimmed. - // - Pass N patch (bisect_on_marginal=true): give up on the - // current sector (the granular target), pause for cooldown, - // let the outer patch loop move to the next NonTrimmed - // range. Implemented as a small JumpAhead so the same code - // path serves both — Pass N's batch=1 means JumpAhead by - // WEDGE_PASS_N_SKIP_SECTORS effectively skips just this - // sector and a small buffer (gives the drive room to - // recover before the next per-sector attempt). - // - // Both paths share the WEDGE_ABORT_THRESHOLD budget — only - // AbortPass after N consecutive wedges with no successful - // read in between. - if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST { - // Count every wedge, including bisect-inner ones. A wedge is a - // firmware fast-fail state regardless of whether we're inside a - // bisect; if we did NOT count bisect-inner wedges, a drive that - // wedges mid-bisect would burn a 30s WEDGE_PAUSE cooldown per - // inner sector and never reach WEDGE_ABORT_THRESHOLD from inside - // the bisect — ~16 min of cooldown sleeping on a batch=32 bisect. - ctx.wedge_count += 1; - if ctx.wedge_count >= WEDGE_ABORT_THRESHOLD { - tracing::warn!( - target: "freemkv::disc", - phase = "wedge_abort", - wedge_count = ctx.wedge_count, - threshold = WEDGE_ABORT_THRESHOLD, - pass = if ctx.bisect_on_marginal { "N" } else { "1" }, - "wedge-skip exhausted — drive appears permanently stuck" - ); - return ReadAction::AbortPass; - } - let jump_sectors = if ctx.bisect_on_marginal { - WEDGE_PASS_N_SKIP_SECTORS - } else { - WEDGE_JUMP_SECTORS - }; - tracing::warn!( - target: "freemkv::disc", - phase = "wedge_skip", - pass = if ctx.bisect_on_marginal { "N" } else { "1" }, - wedge_count = ctx.wedge_count, - jump_sectors, - pause_secs = WEDGE_PAUSE_SECS, - "wedge detected — skipping ahead and pausing for drive cooldown" - ); - ctx.jumps_taken += 1; - return ReadAction::JumpAhead { - sectors: jump_sectors, - pause_secs: WEDGE_PAUSE_SECS, - }; - } - - // 4b. RECOVERED ERROR — the drive returned this sector but had to fight for - // it (ECC worked hard / retried). We enable reporting of these via MODE - // SELECT PER=1 at drive-prep precisely so they surface: on marginal/dirty - // media the drive's best-effort correction can be silently WRONG (a rip - // that "passed clean" but decoded with errors — the Bourne case). We - // distrust it: mark THIS block NonTrimmed and let Pass N re-read it with - // proper recovery (FUA cache-bypass) — a clean re-read wins, a persistent - // marginal becomes an honest concealed gap. - // - // Critically this is a per-block SkipBlock, NOT the damage-jump path: a - // recovered error is a single marginal sector, not a clustered hard-damage - // zone, so it must NOT trigger the 64 MB JumpAhead (which would nuke huge - // swaths of good data on a lightly-smudged disc). It also does NOT touch - // the damage window / multiplier — a recovered read is not damage-zone - // signal. Returns before the fast-jump logic below. - if sense_key == scsi::SENSE_KEY_RECOVERED_ERROR { - ctx.marginal_recovered += 1; - tracing::warn!( - target: "freemkv::disc", - phase = "recovered_error", - marginal_recovered = ctx.marginal_recovered, - asc = err.scsi_sense().map(|s| s.asc), - ascq = err.scsi_sense().map(|s| s.ascq), - "drive reported a recovered (marginal) read — distrusting; marking NonTrimmed for Pass N re-read" - ); - return ReadAction::SkipBlock { - pause_secs: FAIL_PAUSE_SECS, - }; - } - - // 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi- - // sector batch: the drive can often read the same sectors - // individually. Bisect into single-sector reads (gentler on the - // bridge too — shorter SCSI transactions). Avoid recursive - // bisect. - // - // Pass 1 sweep sets `bisect_on_marginal=false` to skip this: - // its job is "fast and accurate, get the most data in the - // shortest time." Pass N is purpose-built to recover - // individual sectors with proper recovery semantics, and Pass - // 1 grinding through 32-sector bisects costs ~2.5 min per bad - // block AND fills the damage window slower than it should. - // Whole-block NonTrimmed → SkipBlock → advance → Pass N - // revisits. - let is_marginal = matches!( - sense_key, - scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND - ); - if is_marginal && ctx.batch > 1 && !ctx.bisecting && ctx.bisect_on_marginal { - return ReadAction::Bisect; - } - - // 6. Single-sector failure or unbisectable error — record in - // damage window, decide between skip-in-place vs damage-jump. - // - // SKIP damage-window updates while bisecting: the window - // represents per-batch outcomes, not per-sector. Updating it - // inside a bisect inner loop (potentially 32+ sector failures - // per batch) would over-weight the window and cause runaway - // JumpAhead distance via excessive multiplier doublings. - if !ctx.bisecting { - ctx.damage_window.push(false); - if ctx.damage_window.len() > ctx.damage_window_max { - ctx.damage_window.remove(0); - } - } - - let bad_count = ctx.damage_window.iter().filter(|&&b| !b).count(); - let bad_pct = if ctx.damage_window.is_empty() { - 0 - } else { - bad_count * 100 / ctx.damage_window.len() - }; - - // Inter-error pause — wedge prevention via pacing. - // - // Zone-entry case (first error after a clean run): apply the - // long ZONE_ENTRY_COOLDOWN_SECS pause. The empirical wedge - // observed 2026-05-11 happened ~7 errors into a damage zone, - // each retry adding to the firmware's internal counter. A 30s - // pause at zone entry lets the drive's bridge / firmware - // counters reset before we issue the next read. - // - // Subsequent errors in the same zone: the standard 5s pause. - // (We've already jumped past the initial damage; further errors - // mean we landed in another bad cluster — same pacing applies.) - // - // Long-streak escalation: same 5s currently; kept as a separate - // branch for future tuning. Pass N (bisect_on_marginal=true) - // uses the standard pauses — it's running single-sector retries - // on already-known-bad LBAs by design. - let is_zone_entry = is_zone_entry_transition && !ctx.bisecting && !ctx.bisect_on_marginal; - let pause_secs = if is_zone_entry { - ZONE_ENTRY_COOLDOWN_SECS - } else if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD { - CONSECUTIVE_FAIL_LONG_PAUSE_SECS - } else { - FAIL_PAUSE_SECS - }; - - // 7. Damage-jump: too many failures → skip ahead by an escalating - // gap. Multiplier capped so we can't accidentally skip the - // entire rest of the disc (observed 2026-05-07: a saturated - // multiplier produced a 56 GB jump). Saturating arithmetic on - // the sector calc as defence in depth. - // - // Jump base bumped 2026-05-10 from 256 to 1024 sectors per - // multiplier unit (= 64 MB first jump at batch=32, up from - // 16 MB). The smaller base routinely landed jumps back inside - // damage clusters of 100+ MB, each landing adding to the - // firmware's wedge counter. 64 MB initial + 128 MB second + - // 256 MB third clears almost any single-cluster damage - // pattern we've seen in 2 jumps. - // - // Two triggers, evaluated in order: - // - // a. **Fast-entry** — `consecutive_outer_failures >= fast_jump_threshold`. - // Fires on Pass 1 (threshold=1) so we don't spend ~40 min - // grinding to fill a 16-block damage window before the - // first jump on a damage zone we entered cleanly. Doesn't - // fire on Pass N (threshold=u64::MAX). - // - // b. **Window-based** — original behaviour: 12% bad in a - // sliding window of 16 outer reads. Pass N's only path, - // and Pass 1's fallback if the failures are scattered - // enough that we don't hit the consecutive threshold. - const MAX_JUMP_MULTIPLIER: u64 = 64; - let fast_trigger = !ctx.bisecting && ctx.consecutive_outer_failures >= ctx.fast_jump_threshold; - let window_trigger = - ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct; - if fast_trigger || window_trigger { - let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER); - let sectors = JUMP_BASE_SECTORS - .saturating_mul(ctx.batch as u64) - .saturating_mul(mult); - ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER); - // Reset the outer-failure counter so a long damaged region - // doesn't keep firing fast-jump every read after the initial - // jump fired. The window-based trigger handles further jumps. - ctx.consecutive_outer_failures = 0; - ctx.jumps_taken += 1; - return ReadAction::JumpAhead { - sectors, - pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS, - }; - } - - // 8. Default: zero-fill the failed batch as NonTrimmed and pause - // before the next read. - ReadAction::SkipBlock { pause_secs } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::Error; - use crate::scsi::ScsiSense; - - fn medium_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(2), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x05, - }), - } - } - - fn hardware_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(2), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_HARDWARE_ERROR, - asc: 0x44, - ascq: 0x00, - }), - } - } - - fn illegal_request_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(2), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_ILLEGAL_REQUEST, - asc: 0x24, - ascq: 0x00, - }), - } - } - - fn recovered_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(2), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_RECOVERED_ERROR, - asc: 0x17, - ascq: 0x01, - }), - } - } - - #[test] - fn recovered_error_skips_block_not_jump_pass_1() { - // A recovered (marginal) read on Pass 1 must NOT trigger the 64 MB - // damage-jump — that would nuke huge good regions on a lightly-smudged - // disc. It marks just this block NonTrimmed (SkipBlock) for a Pass N - // re-read, and is counted as marginal_recovered. - let mut ctx = ReadCtx::for_sweep(32); - let action = handle_read_error(&recovered_err(), &mut ctx); - assert!( - matches!(action, ReadAction::SkipBlock { .. }), - "recovered error must SkipBlock, not JumpAhead; got {action:?}" - ); - assert_eq!(ctx.marginal_recovered, 1); - // It must not have inflated the damage-jump multiplier (not damage signal). - assert_eq!(ctx.jump_multiplier, 1); - assert_eq!(ctx.jumps_taken, 0); - } - - #[test] - fn recovered_error_does_not_consume_zone_entry() { - // A recovered (marginal) read must NOT latch in_damage_zone — otherwise a - // genuine hard error that follows would not be seen as the zone entry and - // would skip the 30s wedge cooldown. - let mut ctx = ReadCtx::for_sweep(32); - handle_read_error(&recovered_err(), &mut ctx); - assert!( - !ctx.in_damage_zone, - "recovered read is not damage-zone signal" - ); - assert_eq!(ctx.zones_entered, 0); - // The following genuine hard error IS the real zone entry. - handle_read_error(&hardware_err(), &mut ctx); - assert!(ctx.in_damage_zone); - assert_eq!(ctx.zones_entered, 1, "hard error registers the zone entry"); - } - - #[test] - fn recovered_error_skips_block_pass_n_too() { - // Pass N sees the same: a recovered read is distrusted → SkipBlock (the - // outer patch loop re-reads the range with FUA). - let mut ctx = ReadCtx::for_patch(1); - let action = handle_read_error(&recovered_err(), &mut ctx); - assert!( - matches!(action, ReadAction::SkipBlock { .. }), - "got {action:?}" - ); - assert_eq!(ctx.marginal_recovered, 1); - } - - #[test] - fn many_recovered_errors_never_jump() { - // Even a run of recovered errors must never escalate to a damage-jump — - // they're marginal reads, not a hard-damage cluster. - let mut ctx = ReadCtx::for_sweep(32); - for _ in 0..40 { - let a = handle_read_error(&recovered_err(), &mut ctx); - assert!(matches!(a, ReadAction::SkipBlock { .. }), "got {a:?}"); - } - assert_eq!(ctx.jumps_taken, 0, "recovered errors never jump"); - assert_eq!(ctx.marginal_recovered, 40); - } - - #[test] - fn pass_n_marginal_with_batch_gt_1_bisects() { - let mut ctx = ReadCtx::for_patch(32); - let action = handle_read_error(&medium_err(), &mut ctx); - assert_eq!(action, ReadAction::Bisect); - } - - #[test] - fn pass_1_marginal_jumps_immediately_not_bisecting() { - // 2026-05-11 wedge-prevention rewrite: Pass 1 jumps on the - // FIRST marginal error (fast_jump_threshold=1) rather than - // SkipBlock. Retrying the same LBA quickly is what triggers - // the BU40N's firmware fast-fail transition; immediate jump - // prevents the cascade. Pass N still bisects (its job is - // per-sector recovery on already-known-bad LBAs). - let mut ctx = ReadCtx::for_sweep(32); - let action = handle_read_error(&medium_err(), &mut ctx); - match action { - ReadAction::JumpAhead { .. } => {} - other => panic!("expected JumpAhead on first Pass 1 marginal error, got {other:?}"), - } - } - - #[test] - fn medium_error_with_batch_1_skips() { - let mut ctx = ReadCtx::for_patch(1); - let action = handle_read_error(&medium_err(), &mut ctx); - match action { - ReadAction::SkipBlock { pause_secs } => assert!(pause_secs >= 1), - other => panic!("expected SkipBlock, got {other:?}"), - } - } - - #[test] - fn medium_error_while_bisecting_does_not_recurse() { - let mut ctx = ReadCtx::for_patch(32); - ctx.bisecting = true; - let action = handle_read_error(&medium_err(), &mut ctx); - match action { - ReadAction::SkipBlock { .. } => {} - other => panic!("expected SkipBlock, got {other:?}"), - } - } - - #[test] - fn pass_1_jumps_immediately_on_first_outer_failure() { - // 2026-05-11 rewrite: fast_jump_threshold is 1 on Pass 1, not - // 4. Even ONE error triggers a jump because BU40N's firmware - // fast-fail mode is sensitive to retry cadence. The wedge - // observed 2026-05-11 happened at 7 errors / 6.5s — by then - // we were already wedged. Jumping on error #1 means we - // physically can't reach the cascade. - let mut ctx = ReadCtx::for_sweep(32); - let a = handle_read_error(&medium_err(), &mut ctx); - assert!( - matches!(a, ReadAction::JumpAhead { .. }), - "expected JumpAhead on first outer failure (fast_jump_threshold=1), got {a:?}" - ); - } - - #[test] - fn pass_n_does_not_fast_jump() { - // Pass N's whole reason to exist is to grind on bad ranges. - // It should NOT bail after 4 consecutive failures the way - // Pass 1 does — it bisects and skips with proper recovery. - let mut ctx = ReadCtx::for_patch(32); - for _ in 0..4 { - let a = handle_read_error(&medium_err(), &mut ctx); - assert!( - !matches!(a, ReadAction::JumpAhead { .. }), - "Pass N must not fast-jump; got {a:?}" - ); - } - } - - #[test] - fn outer_success_resets_consecutive_outer_failures() { - // With fast_jump_threshold=1 each Pass 1 error fires a jump - // and resets `consecutive_outer_failures` to 0 inside the - // handler. So we can't accumulate "3" the old way — instead, - // verify the counter goes back to 0 after on_success too. - let mut ctx = ReadCtx::for_sweep(32); - handle_read_error(&medium_err(), &mut ctx); - // After fast-jump, consecutive_outer_failures already 0. - assert_eq!(ctx.consecutive_outer_failures, 0); - // on_success keeps it at 0 (defensive). - ctx.bisecting = false; - ctx.on_success(); - assert_eq!(ctx.consecutive_outer_failures, 0); - } - - #[test] - fn bisect_inner_success_does_not_reset_outer_counter() { - let mut ctx = ReadCtx::for_patch(32); - for _ in 0..3 { - handle_read_error(&medium_err(), &mut ctx); - } - assert_eq!(ctx.consecutive_outer_failures, 3); - // A successful inner-sector read during bisect is not the - // same as escaping the bad outer batch. - ctx.bisecting = true; - ctx.on_success(); - assert_eq!( - ctx.consecutive_outer_failures, 3, - "bisect inner success must not reset outer-failure counter" - ); - } - - #[test] - fn pass_1_hardware_error_jumps_ahead_not_aborts() { - // New wedge-skip policy: Pass 1 (bisect_on_marginal=false) - // should JumpAhead with a 1 GB skip + cooldown pause instead - // of immediately aborting. Aborting on first wedge was the - // pre-fix behavior that killed rips at 48% on damaged discs. - let mut ctx = ReadCtx::for_sweep(32); - let action = handle_read_error(&hardware_err(), &mut ctx); - match action { - ReadAction::JumpAhead { - sectors, - pause_secs, - } => { - assert_eq!(sectors, WEDGE_JUMP_SECTORS); - assert_eq!(pause_secs, WEDGE_PAUSE_SECS); - } - other => panic!("expected JumpAhead, got {other:?}"), - } - assert_eq!(ctx.wedge_count, 1); - } - - #[test] - fn pass_1_hardware_error_aborts_after_threshold() { - // After WEDGE_ABORT_THRESHOLD consecutive wedges with no good - // read in between, autorip should see a real AbortPass so it - // can surface "drive is stuck, power-cycle required" to the - // user — rather than looping forever on a permanently bricked - // drive. - let mut ctx = ReadCtx::for_sweep(32); - for i in 0..WEDGE_ABORT_THRESHOLD - 1 { - let action = handle_read_error(&hardware_err(), &mut ctx); - assert!( - matches!(action, ReadAction::JumpAhead { .. }), - "iter {i}: expected JumpAhead, got {action:?}" - ); - } - // The Nth wedge crosses the threshold. - let action = handle_read_error(&hardware_err(), &mut ctx); - assert_eq!(action, ReadAction::AbortPass); - } - - #[test] - fn pass_1_good_read_resets_wedge_count() { - // A single successful read between wedges must clear the - // skip counter — otherwise a disc with a few scattered bad - // zones would eventually run out of skip budget even though - // the drive was recovering between zones. - let mut ctx = ReadCtx::for_sweep(32); - for _ in 0..(WEDGE_ABORT_THRESHOLD - 1) { - handle_read_error(&hardware_err(), &mut ctx); - } - assert_eq!(ctx.wedge_count, WEDGE_ABORT_THRESHOLD - 1); - ctx.on_success(); - assert_eq!(ctx.wedge_count, 0); - // After the success, we should still get JumpAhead (not - // AbortPass) on the next wedge. - let action = handle_read_error(&hardware_err(), &mut ctx); - assert!(matches!(action, ReadAction::JumpAhead { .. })); - } - - #[test] - fn pass_n_hardware_error_also_skips_not_aborts() { - // 2026-05-11 reframe: error handling is centralized, the - // wedge is a code-induced state, and the avoidance principle - // (skip + pause + continue) applies to Pass N too. Previously - // Pass N AbortPass'd on first wedge — same fatal-at-48% bug - // Pass 1 had pre-fix. Now Pass N gets a smaller skip - // (WEDGE_PASS_N_SKIP_SECTORS, not the 1 GB Pass 1 jump) - // because Pass N's job IS to revisit specific NonTrimmed - // ranges; over-skipping abandons recoverable sectors. - let mut ctx = ReadCtx::for_patch(1); - let action = handle_read_error(&hardware_err(), &mut ctx); - match action { - ReadAction::JumpAhead { - sectors, - pause_secs, - } => { - assert_eq!(sectors, WEDGE_PASS_N_SKIP_SECTORS); - assert_eq!(pause_secs, WEDGE_PAUSE_SECS); - } - other => panic!("expected JumpAhead, got {other:?}"), - } - assert_eq!(ctx.wedge_count, 1); - } - - #[test] - fn pass_n_hardware_error_aborts_after_threshold() { - // Same threshold as Pass 1 — after WEDGE_ABORT_THRESHOLD - // consecutive wedges with no good read in between, give up. - let mut ctx = ReadCtx::for_patch(1); - for _ in 0..WEDGE_ABORT_THRESHOLD - 1 { - let action = handle_read_error(&hardware_err(), &mut ctx); - assert!(matches!(action, ReadAction::JumpAhead { .. })); - } - let action = handle_read_error(&hardware_err(), &mut ctx); - assert_eq!(action, ReadAction::AbortPass); - } - - #[test] - fn pass_1_illegal_request_also_routes_to_wedge_skip() { - // ILLEGAL_REQUEST is the other half of the wedge family: - // drive saying "I won't parse your CDB" after entering the - // fast-fail state. Same treatment as HARDWARE_ERROR. - let mut ctx = ReadCtx::for_sweep(32); - let action = handle_read_error(&illegal_request_err(), &mut ctx); - assert!(matches!(action, ReadAction::JumpAhead { .. })); - } - - #[test] - fn long_failure_streak_extends_pause_on_pass_n() { - // Pass N keeps the cooldown behaviour: after many consecutive - // failures, pauses extend to give the drive time to recover. - // Pass 1 explicitly does NOT pause — see - // `pass_1_does_not_pause_on_skip` below. - let mut ctx = ReadCtx::for_patch(1); - for _ in 0..15 { - handle_read_error(&medium_err(), &mut ctx); - } - let final_action = handle_read_error(&medium_err(), &mut ctx); - match final_action { - ReadAction::SkipBlock { pause_secs } => { - assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS); - } - ReadAction::JumpAhead { pause_secs, .. } => { - assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS); - } - other => panic!("expected long-pause action, got {other:?}"), - } - } - - #[test] - fn pass_1_zone_entry_uses_long_cooldown() { - // 2026-05-11 wedge-prevention rewrite: Pass 1's FIRST error - // (zone entry) gets a 30 s ZONE_ENTRY_COOLDOWN_SECS pause + - // a 2 s POST_JUMP_EXTRA on top (since we're also jumping). - // The long pause prevents the retry cadence that triggers - // firmware fast-fail. Subsequent errors in the same zone fall - // back to the standard 5 s FAIL_PAUSE_SECS. - let mut ctx = ReadCtx::for_sweep(32); - let action = handle_read_error(&medium_err(), &mut ctx); - match action { - ReadAction::JumpAhead { pause_secs, .. } => { - assert_eq!( - pause_secs, - ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS, - "first-error pause should be 30 + 2 = 32 s" - ); - } - other => panic!("expected JumpAhead on first Pass 1 error, got {other:?}"), - } - } - - #[test] - fn pass_1_subsequent_in_zone_errors_skip_long_cooldown() { - // Regression: the fast-jump path resets consecutive_outer_failures - // to 0 after each jump, so the next in-zone error re-increments it - // to 1. Zone-entry must key off the genuine clean->damaged - // transition (in_damage_zone), not the counter, otherwise every - // error in a damaged region pays the 30 s cooldown. - let mut ctx = ReadCtx::for_sweep(32); - // First error: genuine zone entry, gets the long cooldown. - let first = handle_read_error(&medium_err(), &mut ctx); - match first { - ReadAction::JumpAhead { pause_secs, .. } => assert_eq!( - pause_secs, - ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS - ), - other => panic!("expected JumpAhead on first error, got {other:?}"), - } - // We are now still in the damage zone; the jump reset the outer - // counter. A second error must NOT re-arm the 30 s cooldown. - assert!(ctx.in_damage_zone); - let second = handle_read_error(&medium_err(), &mut ctx); - let pause = match second { - ReadAction::JumpAhead { pause_secs, .. } => pause_secs, - ReadAction::SkipBlock { pause_secs } => pause_secs, - other => panic!("expected pausing action, got {other:?}"), - }; - assert_ne!( - pause, - ZONE_ENTRY_COOLDOWN_SECS + POST_JUMP_EXTRA_PAUSE_SECS, - "subsequent in-zone error must not pay the 30 s zone-entry cooldown" - ); - assert!( - pause <= FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS, - "subsequent in-zone pause should be the standard fail pause, got {pause}" - ); - } - - #[test] - fn pass_n_pauses_uniformly_on_failed_read() { - // Pass N (bisect_on_marginal=true) is exempt from the - // zone-entry long pause — its whole job is to retry single - // sectors on already-known-bad LBAs, and the 30 s pause every - // single-sector failure would multiply slow recovery - // pointlessly. Pass N keeps the standard 5 s FAIL_PAUSE_SECS. - let mut ctx = ReadCtx::for_patch(1); - let action = handle_read_error(&medium_err(), &mut ctx); - match action { - ReadAction::SkipBlock { pause_secs } => assert_eq!(pause_secs, FAIL_PAUSE_SECS), - ReadAction::JumpAhead { pause_secs, .. } => { - assert_eq!(pause_secs, FAIL_PAUSE_SECS + POST_JUMP_EXTRA_PAUSE_SECS) - } - ReadAction::Bisect => {} - other => panic!("expected pausing action, got {other:?}"), - } - } - - #[test] - fn damage_window_fills_then_jumps() { - let mut ctx = ReadCtx::for_sweep(1); - ctx.damage_window_max = 4; - ctx.damage_threshold_pct = 50; - let mut saw_jump = false; - for _ in 0..6 { - let a = handle_read_error(&medium_err(), &mut ctx); - if matches!(a, ReadAction::JumpAhead { .. }) { - saw_jump = true; - break; - } - } - assert!( - saw_jump, - "expected at least one JumpAhead in 6 failures with 50% threshold" - ); - } - - #[test] - fn jump_multiplier_resets_after_damage_zone_exit() { - // A zone that doubles the multiplier must not carry the inflated - // value into the next zone — otherwise the next zone's first - // jump is up to 64x oversized and skips recoverable data. - let mut ctx = ReadCtx::for_sweep(32); - // First zone: a few errors push jumps and double the multiplier. - for _ in 0..4 { - handle_read_error(&medium_err(), &mut ctx); - } - assert!( - ctx.jump_multiplier > 1, - "expected the multiplier to inflate inside a damage zone" - ); - // Exit the zone: damage_window_max consecutive good reads. - ctx.bisecting = false; - for _ in 0..ctx.damage_window_max { - ctx.on_success(); - } - assert!(!ctx.in_damage_zone, "zone should have exited"); - assert_eq!( - ctx.jump_multiplier, 1, - "jump_multiplier must reset to 1 on zone exit" - ); - } - - #[test] - fn bridge_degradation_count_resets_on_success() { - // After a good read the bridge recovered; the 15s-cooldown retry - // budget must be available again instead of staying saturated - // for the whole pass. - let mut ctx = ReadCtx::for_patch(1); - ctx.bridge_degradation_count = BRIDGE_DEGRADATION_MAX_RETRIES; - ctx.on_success(); - assert_eq!(ctx.bridge_degradation_count, 0); - } - - #[test] - fn wedge_abort_reachable_during_bisect() { - // A drive that wedges mid-bisect must still reach the abort - // threshold rather than burning a WEDGE_PAUSE cooldown per inner - // sector forever. - let mut ctx = ReadCtx::for_patch(32); - ctx.bisecting = true; - let mut aborted = false; - for _ in 0..WEDGE_ABORT_THRESHOLD { - if matches!( - handle_read_error(&hardware_err(), &mut ctx), - ReadAction::AbortPass - ) { - aborted = true; - break; - } - } - assert!( - aborted, - "wedge abort threshold must be reachable from inside a bisect" - ); - } - - #[test] - fn on_success_resets_failure_counters_and_pushes_window() { - let mut ctx = ReadCtx::for_sweep(32); - for _ in 0..3 { - handle_read_error(&medium_err(), &mut ctx); - } - assert!(ctx.consecutive_failures > 0); - ctx.bisecting = false; - ctx.on_success(); - assert_eq!(ctx.consecutive_good, 1); - assert_eq!(ctx.consecutive_failures, 0); - assert!(*ctx.damage_window.last().unwrap()); - } - - // ---------------------------------------------------------------- - // Additional hardening: retry-budget boundaries, transport-abort - // precedence, and the bounded-jump invariant. These guard against - // off-by-one in the retry caps (which would either hammer a wedging - // drive or give up a recovery one attempt early) and against an - // unbounded jump multiplier skipping the rest of the disc. - // ---------------------------------------------------------------- - - /// NOT_READY check-condition (status 0x02 so it is NOT classified as - /// bridge degradation, which keys off non-standard status bytes). - /// sense_key=2 with a generic ASC routes to the NOT_READY retry path. - fn not_ready_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_NOT_READY, - asc: 0x04, - ascq: 0x00, - }), - } - } - - /// Transport failure: SCSI status 0xFF (bridge crash). CLAUDE.md - /// "Bad-sector handling": this aborts the copy. - fn transport_failure_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE), - sense: None, - } - } - - /// Bridge degradation: a non-standard status byte (0x04 - neither - /// GOOD/CHECK/TRANSPORT) with empty sense, per `Error::is_bridge_degradation`. - fn bridge_degradation_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(0x04), - sense: None, - } - } - - #[test] - fn not_ready_retries_capped_at_three_then_falls_through() { - // CLAUDE.md "Bad-sector handling" mode 1: NOT READY -> "Pause 3s, - // retry up to 3x, then mark NonTrimmed." NOT_READY_MAX_RETRIES=3. - // The 1st-3rd NOT_READY must Retry; the 4th must NOT Retry (it - // falls through to skip). Pass N (batch=1) so the marginal-bisect - // branch is irrelevant. - // Mutation that makes this RED: change `ctx.not_ready_retries < - // NOT_READY_MAX_RETRIES` to `<=` (retries 4 times) or to `>` - // (never retries). - let mut ctx = ReadCtx::for_patch(1); - for i in 0..NOT_READY_MAX_RETRIES { - let a = handle_read_error(¬_ready_err(), &mut ctx); - assert!( - matches!(a, ReadAction::Retry { .. }), - "NOT_READY attempt {i} should Retry, got {a:?}" - ); - } - // Budget exhausted: the next NOT_READY must not Retry. - let a = handle_read_error(¬_ready_err(), &mut ctx); - assert!( - !matches!(a, ReadAction::Retry { .. }), - "NOT_READY past the retry cap must fall through, got {a:?}" - ); - } - - #[test] - fn transport_failure_aborts_even_mid_bisect() { - // CLAUDE.md "Bad-sector handling" mode 2: a transport failure - // (bridge crash, status 0xFF) aborts the pass so the outer loop - // can re-enumerate the bridge. This must hold even while - // bisecting and even on Pass N - the wedge-skip/jump paths must - // NOT swallow a real transport crash into a JumpAhead. - // Mutation that makes this RED: move the transport-failure check - // below the HARDWARE/ILLEGAL wedge arm, so a transport failure - // that also carried a wedge-family sense would JumpAhead instead. - let mut ctx = ReadCtx::for_patch(32); - ctx.bisecting = true; - assert_eq!( - handle_read_error(&transport_failure_err(), &mut ctx), - ReadAction::AbortPass - ); - // And on a fresh Pass 1 context, still AbortPass. - let mut ctx1 = ReadCtx::for_sweep(32); - assert_eq!( - handle_read_error(&transport_failure_err(), &mut ctx1), - ReadAction::AbortPass - ); - } - - #[test] - fn bridge_degradation_retries_to_budget_then_falls_through() { - // The bridge-degradation cooldown retry is bounded by - // BRIDGE_DEGRADATION_MAX_RETRIES (=5). The first 5 degradation - // errors must Retry with the long bridge cooldown; the 6th must - // fall through to skip/jump rather than retrying forever and - // stalling the pass. - // Mutation that makes this RED: change the budget comparison - // `ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES` - // to `<=` (retries 6 times). - let mut ctx = ReadCtx::for_patch(1); - for i in 0..BRIDGE_DEGRADATION_MAX_RETRIES { - let a = handle_read_error(&bridge_degradation_err(), &mut ctx); - match a { - ReadAction::Retry { pause_secs } => { - assert_eq!( - pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS, - "bridge retry {i} should use the bridge cooldown" - ); - } - other => panic!("bridge degradation attempt {i} should Retry, got {other:?}"), - } - } - let a = handle_read_error(&bridge_degradation_err(), &mut ctx); - assert!( - !matches!(a, ReadAction::Retry { .. }), - "bridge degradation past the retry budget must fall through, got {a:?}" - ); - } - - /// The documented BU40N bad-sector signature: NOT_READY - /// (sense_key=2, ASC=0x04, ASCQ=0x3E) delivered as a CHECK CONDITION - /// (status 0x02). This is the case the old comment on the bridge - /// branch wrongly claimed `is_bridge_degradation` matched. - fn not_ready_04_3e_err() -> Error { - Error::DiscRead { - sector: 100, - status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), - sense: Some(ScsiSense { - sense_key: scsi::SENSE_KEY_NOT_READY, - asc: 0x04, - ascq: 0x3E, - }), - } - } - - #[test] - fn not_ready_04_3e_does_not_take_bridge_branch() { - // Regression guard for the misleading-comment fix: the bridge - // branch keys on the *status byte* (non-standard, i.e. not - // GOOD/CHECK/TRANSPORT), NOT on the NOT_READY 04/3E sense. A real - // 04/3E bad-sector error arrives as CHECK CONDITION (0x02), so - // `is_bridge_degradation()` must be false for it, and it must - // route to the generic NOT_READY retry (3 s pause) rather than - // the bridge cooldown (15 s pause). - let err = not_ready_04_3e_err(); - assert!( - !err.is_bridge_degradation(), - "04/3E arrives as CHECK CONDITION (0x02); it is not bridge degradation" - ); - - let mut ctx = ReadCtx::for_patch(1); - match handle_read_error(&err, &mut ctx) { - ReadAction::Retry { pause_secs } => { - assert_eq!( - pause_secs, NOT_READY_PAUSE_SECS, - "04/3E must use the generic NOT_READY pause, not the bridge cooldown" - ); - assert_ne!( - pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS, - "04/3E must not take the bridge-degradation branch" - ); - // Confirm it really went through the NOT_READY path. - assert_eq!(ctx.not_ready_retries, 1); - assert_eq!(ctx.bridge_degradation_count, 0); - } - other => panic!("04/3E should Retry via the NOT_READY path, got {other:?}"), - } - } - - #[test] - fn jump_multiplier_caps_and_jump_distance_stays_bounded() { - // CLAUDE.md damage-jump: multiplier doubles per jump but is - // capped at MAX_JUMP_MULTIPLIER=64 (the "4 GiB cap"); a single - // jump must never be allowed to grow without bound and skip the - // rest of the disc. Drive a long single-sector failure streak on - // a sweep ctx with a tiny window so window-trigger jumps fire - // repeatedly, and verify the multiplier saturates at 64 and the - // emitted jump distance equals JUMP_BASE_SECTORS * batch * 64. - // Mutation that makes this RED: remove the - // `.min(MAX_JUMP_MULTIPLIER)` on the multiplier doubling, or use - // wrapping/non-saturating mul -> distance overshoots or panics. - const MAX_JUMP_MULTIPLIER: u64 = 64; - let batch: u16 = 32; - let mut ctx = ReadCtx::for_sweep(batch); - // Small window + 0% threshold so every failure can window-trigger - // a jump and keep doubling the multiplier toward the cap. - ctx.damage_window_max = 2; - ctx.damage_threshold_pct = 0; - let mut last_jump_sectors = 0u64; - for _ in 0..40 { - // Reset bisecting flag defensively; these are outer failures. - ctx.bisecting = false; - if let ReadAction::JumpAhead { sectors, .. } = - handle_read_error(&medium_err(), &mut ctx) - { - last_jump_sectors = sectors; - } - assert!( - ctx.jump_multiplier <= MAX_JUMP_MULTIPLIER, - "jump_multiplier {} exceeded the cap {}", - ctx.jump_multiplier, - MAX_JUMP_MULTIPLIER - ); - } - // After saturation, the jump distance is exactly base*batch*cap. - let expected = JUMP_BASE_SECTORS * batch as u64 * MAX_JUMP_MULTIPLIER; - assert_eq!( - last_jump_sectors, expected, - "saturated jump distance must equal base*batch*64" - ); - } -} diff --git a/src/disc/section_recover.rs b/src/disc/section_recover.rs deleted file mode 100644 index 1577da2..0000000 --- a/src/disc/section_recover.rs +++ /dev/null @@ -1,2134 +0,0 @@ -//! Handler-chain recovery of a single bad section (Pass-N rework, #55). -//! -//! The pre-existing patch loop grinds one bad range end-to-end, and when the -//! drive wedges it aborts the WHOLE pass — so a dead cluster at the *front* of -//! a range starves every later range of any attempt. This module replaces that -//! with a chain of time-bounded recovery *handlers*, each a single recovery -//! *idea* (read backwards, forwards, fast, slow, bisect...). A coordinator runs -//! them in sequence over one section's still-bad sub-ranges: -//! -//! - each handler gets a hard wall-clock `deadline` and MUST return promptly -//! once it passes — no handler ever blocks unbounded (that is the whole -//! point); -//! - a handler recovers what it can, shrinking the shared [`SubRanges`] via -//! [`SubRanges::remove`], and returns [`HandlerOutcome::Remaining`] with the -//! rest still bad — the NEXT handler then tries a different idea on what is -//! left; -//! - whatever is still bad after every handler is the residue the caller -//! records as loss (NonTrimmed) before MOVING ON to the next section. -//! -//! Adding a new recovery idea is one new [`SectionHandler`] impl pushed onto the -//! chain — nothing else changes. -//! -//! This module is deliberately decoupled from the live `patch` machinery -//! (`PatchSink`, `PatchItem`, mapfile locks): recovered bytes flow through the -//! tiny [`RecoverySink`] trait, and the clock is injected as `&dyn Fn`, so every -//! handler and the coordinator are unit-testable against a synthetic -//! `SectorSource` with a fake clock — no live drive, no real sleeps. -//! -//! Wired into `patch_region` (#55): [`run_handlers`] is the live Pass-N recovery -//! engine. `SubRanges` stays the shared still-bad set. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Instant; - -use super::patch::{SubRanges, recovery_read}; -use crate::scsi::SenseFamily; -use crate::sector::SectorSource; - -/// One 2048-byte sector. -const SECTOR: u64 = 2048; -/// Batch size a linear handler reads at once (sectors). A partially-dead batch -/// falls back to single-sector reads, so this only trades throughput on clean -/// spans against granularity on dead ones. -const BATCH_SECTORS: u64 = 32; - -/// `Jump` handler: after this many consecutive failed batches it jumps to the -/// middle of the remaining span (see the handler) to find where readable data -/// resumes rather than reading every dead sector. -const JUMP_AFTER_FAILS: u32 = 2; - -/// Early-yield threshold: after this many consecutive reads that recover NOTHING, -/// a handler hands the still-bad set to the next handler instead of grinding out -/// its whole time budget on a dead zone. The baton comes back — a later handler, -/// or the next pass, retries the same sectors from a different angle / after the -/// drive state has shifted (recovery is stochastic). This is what turns a -/// "60 s of 0 B/s" stall into a fast hand-off. -const UNPRODUCTIVE_YIELD: u32 = 4; - -/// Wedge abort: after this many CONSECUTIVE wedge-family senses (Hardware / -/// IllegalRequest — the BU40N firmware's fast-fail state, where it rejects every -/// CDB in <100 ms without attempting recovery) the drive is wedged. `read_span` -/// escalates the read to `Transport`, which every handler propagates as -/// `TransportFault` → the whole pass aborts and the caller spin-cycles the drive -/// instead of hammering all remaining sections (which only deepens the wedge). -/// Any Good read or non-wedge (medium-error) read resets the streak, so only a -/// sustained fast-fail run — never scattered bad sectors on real media — trips -/// it. Counted at the PASS level (persisted across sections) so a wedge is caught -/// even when every bad sub-range is smaller than the streak. Learned the hard way -/// (2026-07-01): the handler chain ground a wedged drive for 28 min at 0 B/s -/// because a fast-fail sense was classified as an ordinary bad sector. -/// -/// Detection latency scales with how much streak one section can build. Tier 0's -/// 4 handlers × [`UNPRODUCTIVE_YIELD`] = 16 reads, so a single large wedged -/// section trips it within one `run_handlers` call. Tier 1 has only 2 handlers -/// (max 8 per section), so a wedge seen only in tier 1 relies on the pass-level -/// streak PERSISTING across sections to reach the threshold — regression-tested -/// by `wedge_streak_persists_across_sections_for_tier1`. -const WEDGE_ABORT_STREAK: u32 = 16; - -/// A wedge-family failure only counts toward [`WEDGE_ABORT_STREAK`] if it came -/// back faster than this — the fast-fail wedge rejects a CDB in <100ms with no -/// recovery attempt, whereas a genuine uncorrectable sector on Hardware-error -/// media spends real time on ECC recovery before failing. Gating on latency stops -/// slow, real damage that happens to report a Hardware sense from false-tripping -/// the wedge abort. Generous (500ms) so a slow bus adds margin without admitting -/// a true fast-fail. -const WEDGE_FASTFAIL_MS: u64 = 500; - -/// Max read speed sentinel for `SET CD SPEED` (0xFFFF = "as fast as the drive -/// will go"). The default for every read; a handler that wants to slow the -/// spindle passes [`SpeedPref::Min`] and [`read_span`] restores this on exit. -const SPEED_MAX_KBS: u16 = 0xFFFF; - -/// Min read speed (~DVD 1×; the drive clamps up to its own supported minimum). -/// Slower rotation gives the servo more dwell and the ECC engine more -/// integration time per sector — the SlowSpin / SpeedSweep lever. The exact -/// value only has to be well below max; the drive rounds it to a supported step. -const SPEED_MIN_KBS: u16 = 1385; - -/// Which spindle speed a read requests. `Max` is the streaming default; `Min` -/// slows the spindle for marginal-sector recovery (more servo dwell + ECC -/// integration). `Mid` is reserved for a future resonance step (SpeedSweep). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum SpeedPref { - Max, - Min, -} - -impl SpeedPref { - /// The `SET CD SPEED` value (KB/s) this preference maps to. - fn kbs(self) -> u16 { - match self { - SpeedPref::Max => SPEED_MAX_KBS, - SpeedPref::Min => SPEED_MIN_KBS, - } - } -} - -/// Which SCSI read timeout a read requests. `Fast` is the 10 s single-attempt -/// budget (scouting); `Deep` is the 60 s ECC-recovery budget (deep recovery). -/// Maps onto `recovery_read`'s `recovery` bool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum TimeoutPref { - Fast, - Deep, -} - -impl TimeoutPref { - /// The `recovery` bool (true = 60 s deep) this timeout maps to. - fn recovery(self) -> bool { - matches!(self, TimeoutPref::Deep) - } -} - -/// The per-read knobs a handler hands to [`read_span`]. A handler is a point in -/// the (direction × speed × cache × timeout) space; `ReadParams` carries the -/// speed / cache(FUA) / timeout axes (direction is the handler's own walk), so -/// the SAME read primitive serves every handler — a new technique is a new -/// *parameterisation*, never a bypass of the wedge-safe read path. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct ReadParams { - pub speed: SpeedPref, - pub fua: bool, - pub timeout: TimeoutPref, -} - -impl ReadParams { - /// Tier-0 scout read: max speed, cache on, 10 s single-attempt. - pub(super) fn fast() -> Self { - Self { - speed: SpeedPref::Max, - fua: false, - timeout: TimeoutPref::Fast, - } - } - - /// Tier-1 deep read: max speed, cache on, 60 s ECC-recovery budget. - pub(super) fn deep() -> Self { - Self { - speed: SpeedPref::Max, - fua: false, - timeout: TimeoutPref::Deep, - } - } - - /// Scorecard tag for the speed / cache / timeout axes, e.g. `min:fua:deep`. - /// The handler prepends its own name + direction (`linear:fwd:` + tag). - fn tag(&self) -> String { - let speed = match self.speed { - SpeedPref::Max => "max", - SpeedPref::Min => "min", - }; - let timeout = match self.timeout { - TimeoutPref::Fast => "fast", - TimeoutPref::Deep => "deep", - }; - if self.fua { - format!("{speed}:fua:{timeout}") - } else { - format!("{speed}:{timeout}") - } - } -} - -/// Where a handler left the section after its bounded attempt. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum HandlerOutcome { - /// The still-bad set is now empty — the section is fully recovered. The - /// coordinator stops the chain. - Complete, - /// The handler finished or hit its deadline with bad sub-ranges remaining — - /// the coordinator moves to the next handler. - Remaining, - /// The caller's halt token was observed set — abort the chain. - Halted, - /// A transport-layer fault (bridge wedge / dead bus) — the device never - /// answered. The coordinator returns this so the caller can un-wedge - /// (spin-cycle) before deciding whether to continue. - TransportFault, -} - -/// Receives sectors a handler successfully read back. Kept minimal and -/// decoupled from `PatchSink` so handlers are unit-testable in isolation; the -/// live wiring maps `recovered` onto the mapfile write + Finished mark. -pub(super) trait RecoverySink { - /// `buf` holds the plaintext bytes for the byte-range `[pos, pos+buf.len())` - /// (all multiples of [`SECTOR`]). - fn recovered(&mut self, pos: u64, buf: &[u8]); -} - -/// Everything a handler needs, borrowed for the duration of one `recover` call. -/// The `deadline` is passed separately to `recover` (not stored here) so each -/// handler invocation is independently bounded. -pub(super) struct HandlerCtx<'a> { - pub reader: &'a mut dyn SectorSource, - pub sink: &'a mut dyn RecoverySink, - /// Clock seam — handlers read wall time through this, never `Instant::now()` - /// inline, so tests advance a fake clock deterministically. - pub now: &'a dyn Fn() -> Instant, - pub halt: Option<&'a AtomicBool>, - /// Widen mid-unit reads to the aligned AACS unit (see [`recovery_read`]). - pub decrypt_is_aacs: bool, - /// Progress heartbeat. Handlers call [`HandlerCtx::progress`] frequently (it - /// is internally throttled); this pushes a fresh progress snapshot to the - /// caller's reporter DURING a handler, not just at range boundaries — so the - /// bar and speed move as recovery happens instead of jumping once per - /// section. `None` in tests (no reporter). - pub tick: Option<&'a mut dyn FnMut()>, - /// Consecutive reads that recovered nothing, updated by [`read_span`]. When - /// it reaches [`UNPRODUCTIVE_YIELD`] the handler should yield to the next one - /// (see [`HandlerCtx::stalled`]). Reset to 0 before each handler runs. - pub unproductive: u32, - /// Consecutive wedge-family senses (Hardware / IllegalRequest), updated by - /// [`read_span`]. At [`WEDGE_ABORT_STREAK`] the drive is wedged and the read - /// escalates to `Transport`. Seeded from and read back into the pass-level - /// counter so the streak spans sections; a Good or non-wedge read resets it. - pub wedge_streak: u32, - /// The spindle speed (`SET CD SPEED` KB/s) currently programmed into the - /// drive. [`read_span`] issues `SET CD SPEED` only when a read's requested - /// speed DIFFERS from this (a `SET CD SPEED` per read would thrash the - /// spindle), and [`run_handlers`] restores [`SPEED_MAX_KBS`] after each - /// handler. Seeded to max — the caller resets the drive to max before the - /// chain runs. - pub cur_speed: u16, -} - -impl HandlerCtx<'_> { - fn halted(&self) -> bool { - self.halt.is_some_and(|h| h.load(Ordering::Relaxed)) - } - - /// The universal "stop this handler now" check every handler loop already - /// calls between reads. True when the deadline passed OR the handler has hit - /// its early-yield dead streak — folding the yield in here means every - /// handler hands the baton off on a dead zone with no per-handler edits. - fn past(&self, deadline: Instant) -> bool { - self.stalled() || (self.now)() >= deadline - } - - /// True once the handler has read `UNPRODUCTIVE_YIELD` sectors in a row with - /// no recovery — its cue to hand the baton to the next handler instead of - /// grinding a dead zone for its whole budget. - fn stalled(&self) -> bool { - self.unproductive >= UNPRODUCTIVE_YIELD - } - - /// Deadline-only stop check (ignores the early-yield stall streak). Used - /// inside Bisect's boundary-probing loops, where a short run of failing - /// reads is the *expected* way to home in on a dead edge — not a stall. - fn timed_out(&self, deadline: Instant) -> bool { - (self.now)() >= deadline - } - - /// Emit a progress heartbeat (throttling lives in the tick closure). - fn progress(&mut self) { - if let Some(t) = self.tick.as_mut() { - t(); - } - } -} - -/// Outcome of one physical read attempt, before the caller decides what to do -/// with the still-bad set. -enum ReadHit { - /// Bytes came back and were handed to the sink. - Good, - /// A recoverable bad-sector error (media / check-condition). Leave the span - /// bad and move on. - Bad, - /// Transport-layer fault — the bus is gone. Abort now. - Transport, -} - -/// Read `count` sectors at byte offset `pos` and, on success, hand them to the -/// sink. Does NOT touch the still-bad set — the caller removes recovered spans -/// so the read helper stays independent of `SubRanges`. -fn read_span( - ctx: &mut HandlerCtx, - buf: &mut [u8], - pos: u64, - count: u16, - params: ReadParams, -) -> ReadHit { - let lba = (pos / SECTOR) as u32; - let bytes = count as usize * SECTOR as usize; - // Every SubRange enters via `from_section` / `remove`, which keep byte - // offsets sector-aligned, so a handler never asks for a sub-sector span. Pin - // that invariant: a zero `count` (span < SECTOR) would be a 0-sector read - // that silently "recovers" nothing — surface the caller bug in tests. - debug_assert!( - count >= 1 && pos % SECTOR == 0, - "read_span requires a sector-aligned, >=1-sector span (pos={pos}, count={count})" - ); - // Program the spindle speed ONLY when it changes — a `SET CD SPEED` per read - // would thrash the drive. `run_handlers` restores max after the handler. - let want_speed = params.speed.kbs(); - if want_speed != ctx.cur_speed { - ctx.reader.set_speed(want_speed); - ctx.cur_speed = want_speed; - } - let recovery = params.timeout.recovery(); - let read_started = (ctx.now)(); - let hit = match recovery_read( - ctx.reader, - ctx.decrypt_is_aacs, - lba, - count, - buf, - recovery, - params.fua, - ) { - Ok(_) => { - ctx.sink.recovered(pos, &buf[..bytes]); - ReadHit::Good - } - Err(e) if e.is_scsi_transport_failure() => ReadHit::Transport, - Err(e) => { - // Wedge watch: the drive's fast-fail wedge REJECTS every CDB in <100ms - // without attempting recovery, with a Hardware / IllegalRequest sense. - // Both signals are required to count toward the streak: - // (1) wedge-family sense (Hardware / IllegalRequest), AND - // (2) the failure came back FAST (< WEDGE_FASTFAIL_MS). - // The latency gate is what keeps a genuine uncorrectable sector on - // Hardware-error media from false-tripping the wedge abort: a real - // ECC-recovery attempt takes far longer than a fast-fail rejection, so - // a SLOW Hardware-error is real damage (resets the streak, retried - // next pass), while only the fast rejections — the actual wedge — - // accumulate. A medium error or any success below also resets it. - let sense_is_wedge = e - .scsi_sense() - .map(|s| SenseFamily::from_sense_key(s.sense_key).is_wedge_family()) - .unwrap_or(false); - let elapsed = (ctx.now)().duration_since(read_started); - let fast_fail = elapsed.as_millis() < WEDGE_FASTFAIL_MS as u128; - if sense_is_wedge && fast_fail { - ctx.wedge_streak = ctx.wedge_streak.saturating_add(1); - if ctx.wedge_streak >= WEDGE_ABORT_STREAK { - ReadHit::Transport - } else { - ReadHit::Bad - } - } else { - ctx.wedge_streak = 0; - ReadHit::Bad - } - } - }; - // Track the dead streak for the early-yield hand-off: a recovering read - // resets it, a fruitless one advances it toward UNPRODUCTIVE_YIELD. - match hit { - ReadHit::Good => { - ctx.unproductive = 0; - ctx.wedge_streak = 0; - } - // A Bad read is unproductive grinding — advance the yield streak. - ReadHit::Bad => ctx.unproductive = ctx.unproductive.saturating_add(1), - // A Transport hit aborts the handler immediately (bus fault / wedge - // escalation), so it is NOT unproductive grinding — leave the streak - // untouched (the counter is never read again after TransportFault, but - // keep the semantics honest in case an arm is ever reordered). - ReadHit::Transport => {} - } - // Heartbeat after every read (the tick closure throttles to ~250 ms) so the - // UI's bar/speed move DURING a handler, not just when the section finishes. - ctx.progress(); - hit -} - -/// One recovery idea, given a bounded shot at the section's still-bad set. -/// -/// Contract: check `ctx.halted()` and `ctx.past(deadline)` between reads and -/// return promptly (`Halted` / `Remaining`) — never loop past the deadline. On a -/// good read call `ctx.sink.recovered` and [`SubRanges::remove`] the span; on a -/// bad read leave it in `bad` and advance (skip-and-move-on); on a transport -/// fault return [`HandlerOutcome::TransportFault`] immediately. -pub(super) trait SectionHandler { - /// Scorecard identity — the FULL config (technique + direction + speed + - /// cache + timeout), e.g. `linear:fwd:min:fua:deep`. The scoreboard keys on - /// this, so two instances of the same handler at different [`ReadParams`] - /// score independently and can flip past each other. - fn name(&self) -> String; - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome; -} - -/// Which end a [`Linear`] sweep walks from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum Direction { - /// start→end (the front the reverse pass kept dying on). - Forward, - /// end→start (the disc sweep overshoots forward, so a NonTrimmed range's - /// good data sits at its tail — reverse hits it first). - Reverse, -} - -impl Direction { - fn is_reverse(self) -> bool { - matches!(self, Direction::Reverse) - } - - fn tag(self) -> &'static str { - match self { - Direction::Forward => "fwd", - Direction::Reverse => "rev", - } - } -} - -/// Linear batch sweep of each bad sub-range, in `direction`, at `params`. The -/// direction × the [`ReadParams`] axes (speed / FUA / timeout) give every -/// backwards/forwards × fast/slow × max/min × cache/FUA combination from one -/// handler — the tier-0 fast scouts, the tier-1 deep sweeps, and the tier-2 -/// SlowSpin / FuaRetry / SlowFua specialists are all just `Linear` at different -/// `params`. -pub(super) struct Linear { - pub direction: Direction, - pub params: ReadParams, -} - -impl SectionHandler for Linear { - fn name(&self) -> String { - format!("linear:{}:{}", self.direction.tag(), self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - let reverse = self.direction.is_reverse(); - let batch_bytes = BATCH_SECTORS * SECTOR; - let mut buf = vec![0u8; batch_bytes as usize]; - // Snapshot the sub-ranges: we mutate `bad` via remove() as we recover, - // and iterating the snapshot keeps that from disturbing the walk. - let mut snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - if reverse { - snapshot.reverse(); - } - - for (rp, rl) in snapshot { - // Position within the range, in bytes, walked from whichever end. - let mut done = 0u64; - while done < rl { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let span = batch_bytes.min(rl - done); - let pos = if reverse { - rp + (rl - done - span) - } else { - rp + done - }; - let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf, pos, count, self.params) { - ReadHit::Good => bad.remove(pos, span), - // Keep reads at the full batch — no per-sector grind (proven - // worse on the BU40N, and it's what stalled a handler on a - // dead front). Leave the failed batch bad and advance; the - // readable tail past it is reached by the next batch, and - // Bisect salvages readable islands inside a dead batch. - ReadHit::Bad => {} - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - done += span; - } - } - - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// Bisect + expand. Probe the middle sector of a bad sub-range; when it reads, -/// EXPAND outward from it — forward and backward in full batches — until a read -/// fails, recovering the whole readable island around the good centre in large -/// reads. The two failing ends become smaller bad sub-ranges, pushed back to be -/// bisected again. A dead middle just splits into halves. This shreds one huge -/// bad range into precisely-located small dead clusters (a handful of sectors) -/// instead of leaving the whole thing bad. `params` is normally fast reads: it -/// LOCATES readable data; deep-recovering the dead sectors is the slow linear -/// handlers' job. Tier 2 also runs a Bisect at FUA/deep params to shred islands -/// under cache-bypass. -pub(super) struct Bisect { - pub params: ReadParams, -} - -impl SectionHandler for Bisect { - fn name(&self) -> String { - format!("bisect:{}", self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - let batch = BATCH_SECTORS * SECTOR; - let mut buf = vec![0u8; batch as usize]; - let mut probe = [0u8; SECTOR as usize]; - // Work stack of still-bad chunks. A good probe recovers the readable - // island around it and pushes the two (smaller) failing ends; a dead - // probe pushes the two halves. Either way the stack shrinks toward small - // bad clusters, so it drains in bounded steps. - let mut stack: Vec<(u64, u64)> = bad.ranges().to_vec(); - while let Some((rp, rl)) = stack.pop() { - if rl == 0 { - continue; - } - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let end = rp + rl; - let mid = rp + (rl / SECTOR / 2) * SECTOR; - match read_span(ctx, &mut probe, mid, 1, self.params) { - ReadHit::Good => { - bad.remove(mid, SECTOR); - // Expand FORWARD from mid+1 in batches until a read fails. - let mut fwd = mid + SECTOR; - let mut step = batch; - while fwd < end { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.timed_out(deadline) { - return HandlerOutcome::Remaining; - } - let span = step.min(end - fwd); - let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], fwd, count, self.params) { - ReadHit::Good => { - bad.remove(fwd, span); - fwd += span; - step = batch; - } - // Halve at the dead boundary instead of giving up, so - // the readable sectors right up to the dead one are - // recovered in ~log2(batch) reads (no per-sector grind). - ReadHit::Bad => { - if span > SECTOR { - step = ((span / SECTOR) / 2).max(1) * SECTOR; - } else { - break; - } - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - } - // Expand BACKWARD from mid toward rp until a read fails. - let mut bwd = mid; - let mut step = batch; - while bwd > rp { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.timed_out(deadline) { - return HandlerOutcome::Remaining; - } - let span = step.min(bwd - rp); - let pos = bwd - span; - let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { - ReadHit::Good => { - bad.remove(pos, span); - bwd = pos; - step = batch; - } - ReadHit::Bad => { - if span > SECTOR { - step = ((span / SECTOR) / 2).max(1) * SECTOR; - } else { - break; - } - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - } - // Locating this readable island was productive work; the - // failed reads that pinned its dead edges are boundary probes, - // not a stall. Clear the streak so the re-bisect (and the next - // handler) start fresh. - ctx.unproductive = 0; - // The two failing ends stay bad — bisect them again to pin - // the exact dead sectors. - if bwd > rp { - stack.push((rp, bwd - rp)); - } - if fwd < end { - stack.push((fwd, end - fwd)); - } - } - ReadHit::Bad => { - // Dead middle: split and keep hunting for a good centre. - if mid > rp { - stack.push((rp, mid - rp)); - } - let right = mid + SECTOR; - if right < end { - stack.push((right, end - right)); - } - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - } - - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// Blow through a LARGE dead run fast. Reads forward in batches; after -/// [`JUMP_AFTER_FAILS`] consecutive failed batches it SKIPS AHEAD an escalating -/// distance (1 MiB → 2 → 4 … capped at [`JUMP_CAP_BYTES`]), leaving the skipped -/// span bad, to find where readable data RESUMES — mirroring the Pass-1 -/// damage-jump. A later handler / `Bisect` pins the exact good/bad boundary the -/// jump stepped over. Uses fast reads (this is a scout, not a deep-recovery -/// pass). Without it a linear walk pays one up-to-10 s read per dead batch -/// across the whole run, so a deadline-bounded pass never reaches readable data -/// buried behind a big dead front (exactly the 192 MB range on Dune). -pub(super) struct Jump { - pub params: ReadParams, -} - -impl SectionHandler for Jump { - fn name(&self) -> String { - format!("jump:{}", self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - let batch = BATCH_SECTORS * SECTOR; - let mut buf = vec![0u8; batch as usize]; - let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - for (rp, rl) in snapshot { - let mut off = 0u64; - let mut consec_fail = 0u32; - while off < rl { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let span = batch.min(rl - off); - let pos = rp + off; - let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { - ReadHit::Good => { - bad.remove(pos, span); - consec_fail = 0; - off += span; - } - ReadHit::Bad => { - consec_fail += 1; - if consec_fail >= JUMP_AFTER_FAILS { - // Sustained dead run — jump to the MIDDLE of the - // remaining span (never overshoot the range). Halving - // adapts to any size: a big dead run is crossed in - // ~log2 jumps, and a small range lands mid-range - // instead of being skipped past entirely (the 8 MiB - // fixed jump used to leap clean over a <8 MiB range and - // miss readable data in its middle). The skipped span - // stays bad for Bisect to reclaim. - let remaining = rl - off; - let step = ((remaining / 2) / SECTOR).max(1) * SECTOR; - off += step; - consec_fail = 0; - } else { - off += span; - } - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - } - } - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// SpeedSweep — per residual sector, try Max→Min spindle speeds until one reads. -/// *Failure mode:* speed resonance — the best speed is NOT always the slowest; -/// some marginal sectors hit a read-channel sweet spot at a higher speed, so a -/// per-sector search beats committing to min. Distinct from SlowSpin (a `Linear` -/// pinned to min): this searches. `params` carries the FUA / timeout axes; the -/// speed axis is what it sweeps. Single-sector, so it runs on the true residual. -pub(super) struct SpeedSweep { - pub params: ReadParams, -} - -impl SectionHandler for SpeedSweep { - fn name(&self) -> String { - format!("speedsweep:{}", self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - // Fastest first — resonance means the sweet spot isn't always the - // slowest, and the fast read costs least when it happens to work. - const SWEEP: [SpeedPref; 2] = [SpeedPref::Max, SpeedPref::Min]; - let mut probe = [0u8; SECTOR as usize]; - let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - for (rp, rl) in snapshot { - let mut off = 0u64; - while off < rl { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let pos = rp + off; - for speed in SWEEP { - let params = ReadParams { - speed, - fua: self.params.fua, - timeout: self.params.timeout, - }; - match read_span(ctx, &mut probe, pos, 1, params) { - ReadHit::Good => { - bad.remove(pos, SECTOR); - break; - } - // This speed didn't read it; try the next one. - ReadHit::Bad => continue, - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - } - off += SECTOR; - } - } - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// CachePrime — before reading a residual island, read the good run immediately -/// PRECEDING it to lock the drive's PLL/servo, then read the marginal sectors -/// while the channel is warm. *Failure mode:* a boundary sector the drive can't -/// lock onto from a cold seek (ddrescue's "back up, run forward"). The priming -/// read is a normal wedge-safe [`read_span`]; if the preceding sector is itself -/// bad the prime just fails and the island is read cold (no worse than Linear). -pub(super) struct CachePrime { - pub params: ReadParams, -} - -impl SectionHandler for CachePrime { - fn name(&self) -> String { - format!("cacheprime:{}", self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - let batch_bytes = BATCH_SECTORS * SECTOR; - let mut buf = vec![0u8; batch_bytes as usize]; - let mut prime = [0u8; SECTOR as usize]; - let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - for (rp, rl) in snapshot { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - // Prime: read the good sector immediately before the island to lock - // the servo/PLL, so the boundary sector is read warm, not cold-seeked. - if rp >= SECTOR { - // A bad/absent preceding sector just means no prime — read cold. - if let ReadHit::Transport = read_span(ctx, &mut prime, rp - SECTOR, 1, self.params) - { - return HandlerOutcome::TransportFault; - } - } - // Now walk the island forward while warm; contiguous reads keep the - // channel primed across it (each sector's predecessor was just read). - let mut done = 0u64; - while done < rl { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let span = batch_bytes.min(rl - done); - let pos = rp + done; - let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { - ReadHit::Good => bad.remove(pos, span), - ReadHit::Bad => {} - ReadHit::Transport => return HandlerOutcome::TransportFault, - } - done += span; - } - } - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// Oscillate — read each residual sector by ALTERNATING approach: forward-into -/// (prime from the sector below, then read) and reverse-into (prime from the -/// sector above, then read). *Failure mode:* direction-dependent tracking — a -/// sector's servo lock differs by approach direction, so it may read one way but -/// not the other. Combines the two Linear directions into a per-sector -/// alternation on the true residual. `params` carries the speed / FUA / timeout -/// axes; the alternation is the direction axis. -pub(super) struct Oscillate { - pub params: ReadParams, -} - -impl SectionHandler for Oscillate { - fn name(&self) -> String { - format!("oscillate:{}", self.params.tag()) - } - - fn recover( - &mut self, - ctx: &mut HandlerCtx, - bad: &mut SubRanges, - deadline: Instant, - ) -> HandlerOutcome { - let mut probe = [0u8; SECTOR as usize]; - let snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - for (rp, rl) in snapshot { - let mut off = 0u64; - while off < rl { - if ctx.halted() { - return HandlerOutcome::Halted; - } - if ctx.past(deadline) { - return HandlerOutcome::Remaining; - } - let pos = rp + off; - // Forward-into: prime from the sector below, then read the target - // (the head approaches from a lower LBA). - if pos >= SECTOR { - if let ReadHit::Transport = - read_span(ctx, &mut probe, pos - SECTOR, 1, self.params) - { - return HandlerOutcome::TransportFault; - } - } - let mut recovered = match read_span(ctx, &mut probe, pos, 1, self.params) { - ReadHit::Good => { - bad.remove(pos, SECTOR); - true - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - ReadHit::Bad => false, - }; - // Reverse-into: prime from the sector above, then read the target - // (the head approaches from a higher LBA). - if !recovered { - if let ReadHit::Transport = - read_span(ctx, &mut probe, pos + SECTOR, 1, self.params) - { - return HandlerOutcome::TransportFault; - } - recovered = match read_span(ctx, &mut probe, pos, 1, self.params) { - ReadHit::Good => { - bad.remove(pos, SECTOR); - true - } - ReadHit::Transport => return HandlerOutcome::TransportFault, - ReadHit::Bad => false, - }; - } - let _ = recovered; - off += SECTOR; - } - } - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } - } -} - -/// EWMA smoothing factor for the decayed recovery rate. Each new attempt is -/// weighted `α`, the running average `1-α`, so a handler's score tracks its -/// RECENT performance and forgets its distant past at a rate set by `α`. Higher -/// = more reactive (leadership flips sooner); lower = steadier. 0.5 halves the -/// weight of the previous score on every attempt — reactive enough that a proven -/// early winner whose territory is exhausted decays out of the lead within a few -/// barren attempts, while a late-starting specialist climbs as it earns. -const SCORE_EWMA_ALPHA: f64 = 0.5; - -/// Per-rip handler scorecard. Grades each handler by a DECAYED recovery rate (an -/// EWMA of bytes-recovered-per-second, [`SCORE_EWMA_ALPHA`]) so the coordinator -/// runs whoever is winning *now* FIRST on later sections. The residual shrinks -/// and hardens mid-pass, so the best technique CHANGES: the fast scouts clean -/// the range-fronts, then the leftovers are exactly the marginal sectors where -/// the specialists win — and the ranking must FLIP. A cumulative rate froze the -/// early winner in the lead forever; the EWMA re-prices continuously — a handler -/// that stops earning decays down, one that starts earning climbs. Ephemeral — -/// reset each rip, no persistence. A handler not yet tried ranks top -/// (`u64::MAX`) so every handler is calibrated once before the ranking narrows. -#[derive(Default)] -pub(super) struct HandlerScoreboard { - stats: std::collections::HashMap, -} - -#[derive(Default, Clone, Copy)] -struct ScoreStat { - /// Decayed recovery rate (bytes/second), the ranking signal. `None` until - /// the first attempt that spent measurable time (a zero-elapsed call proves - /// no rate). Seeded to the first timed sample, then EWMA'd. - ewma_rate: Option, - // Cumulative totals — for the operator log line only, NOT for ranking. - recovered: u64, - nanos: u128, - attempts: u64, -} - -impl HandlerScoreboard { - /// Fold one timed sample (bytes/second) into the decayed rate. - fn decay(prev: Option, sample: f64) -> f64 { - match prev { - None => sample, - Some(p) => SCORE_EWMA_ALPHA * sample + (1.0 - SCORE_EWMA_ALPHA) * p, - } - } - - /// Record one attempt: `recovered` bytes over `elapsed`. A timed attempt - /// (elapsed > 0) decays a fresh bytes/second sample into `ewma_rate` — a - /// barren attempt (recovered = 0) contributes a 0 sample that decays the - /// score DOWN, which is exactly what lets an exhausted early winner lose its - /// lead. A zero-elapsed call (handler yielded before any timed read) - /// contributes no rate sample. - fn record(&mut self, name: &str, recovered: u64, elapsed: std::time::Duration) { - let e = self.stats.entry(name.to_string()).or_default(); - e.recovered = e.recovered.saturating_add(recovered); - e.nanos = e.nanos.saturating_add(elapsed.as_nanos()); - e.attempts += 1; - let secs = elapsed.as_secs_f64(); - if secs > 0.0 { - let sample = recovered as f64 / secs; - e.ewma_rate = Some(Self::decay(e.ewma_rate, sample)); - } - } - - /// Ranking key (higher runs earlier). Untried → top, so it gets calibrated. - fn rank(&self, name: &str) -> u64 { - match self.stats.get(name) { - // Never attempted → top, so every handler is calibrated once. - None => u64::MAX, - // Attempted but no timed sample yet — e.g. it returned `Halted` on - // its first check or did zero reads. It proved nothing, so rank it at - // the BOTTOM (0), not the top: otherwise a called-but-idle handler - // perpetually crowds out proven performers. - Some(s) => match s.ewma_rate { - None => 0, - Some(r) => r.max(0.0).min(u64::MAX as f64) as u64, - }, - } - } - - /// Emit the scorecard to the log so the operator can see, per rip, which - /// handler is pulling the weight and which is a dud on this drive/disc. - pub(super) fn log(&self) { - let mut rows: Vec<_> = self.stats.iter().collect(); - // Rank by the decayed rate (the live signal), highest first. - rows.sort_by_key(|(name, _)| std::cmp::Reverse(self.rank(name))); - for (name, s) in rows { - let mbps = s.recovered as f64 / (s.nanos as f64 / 1e9).max(1e-9) / 1_048_576.0; - tracing::info!( - target: "freemkv::disc", - phase = "scorecard", - handler = name.as_str(), - recovered_mb = s.recovered as f64 / 1_048_576.0, - attempts = s.attempts, - decayed_bytes_per_s = s.ewma_rate.unwrap_or(0.0), - mb_per_s = mbps, - "handler scorecard (this rip)" - ); - } - } -} - -/// Run the handler chain over one section's still-bad set, ordered best-first by -/// the rip scorecard. Never-hang guarantee: each handler is deadline-bounded and -/// the loop always drains to `Complete`/`Remaining`. `Halted` / `TransportFault` -/// short-circuit so the caller can abort or un-wedge. Each attempt is scored so -/// later sections run the winners first. -pub(super) fn run_handlers( - ctx: &mut HandlerCtx, - handlers: &mut [Box], - bad: &mut SubRanges, - scoreboard: &mut HandlerScoreboard, - section_deadline_for: impl Fn(&SubRanges) -> Instant, -) -> HandlerOutcome { - // Best-first by recovery rate so far; untried handlers rank top (calibrate). - handlers.sort_by_key(|h| std::cmp::Reverse(scoreboard.rank(&h.name()))); - for handler in handlers.iter_mut() { - if bad.is_empty() { - return HandlerOutcome::Complete; - } - let name = handler.name(); - let before = bad.total_len(); - let deadline = section_deadline_for(bad); - let started = (ctx.now)(); - // Fresh dead-streak budget per handler: each gets its own chance before - // the early-yield trips. - ctx.unproductive = 0; - let outcome = handler.recover(ctx, bad, deadline); - // A handler may have dropped the spindle (SlowSpin / SpeedSweep) or set - // FUA; restore max speed before the next handler so it starts from the - // streaming default (FUA is a per-read param, so nothing to unwind there). - if ctx.cur_speed != SPEED_MAX_KBS { - ctx.reader.set_speed(SPEED_MAX_KBS); - ctx.cur_speed = SPEED_MAX_KBS; - } - let elapsed = (ctx.now)().duration_since(started); - let after = bad.total_len(); - scoreboard.record(&name, before.saturating_sub(after), elapsed); - tracing::info!( - target: "freemkv::disc", - phase = "section_recover.handler", - handler = name.as_str(), - bad_bytes_before = before, - bad_bytes_after = after, - recovered = before.saturating_sub(after), - outcome = ?outcome, - "handler finished; remaining bad bytes carry to the next handler" - ); - match outcome { - HandlerOutcome::Complete => return HandlerOutcome::Complete, - HandlerOutcome::Remaining => continue, - HandlerOutcome::Halted => return HandlerOutcome::Halted, - HandlerOutcome::TransportFault => return HandlerOutcome::TransportFault, - } - } - if bad.is_empty() { - HandlerOutcome::Complete - } else { - HandlerOutcome::Remaining - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::error::{Error, Result}; - use std::collections::{HashMap, HashSet}; - use std::sync::Arc; - use std::sync::atomic::AtomicU64; - use std::time::Duration; - - /// Synthetic disc: a set of dead LBAs, an optional transport-fault LBA, and - /// an injectable per-read time cost that advances a shared fake clock. No - /// real sleeps — the clock is an `AtomicU64` of nanoseconds so the reader - /// (which owns `&mut self`) and the `now` closure share one timeline while - /// staying `Send`. - struct FakeDisc { - dead: HashSet, - /// LBAs that return a wedge-family sense (IllegalRequest) — the drive - /// fast-fail state, distinct from an ordinary dead sector (which carries - /// no sense). Used to exercise wedge detection. - wedge: HashSet, - transport_at: Option, - clock_nanos: Arc, - per_read: Duration, - reads: Arc, - // ── Physical failure-mode models (all default-empty) ───────────────── - // Each conditional sector reads ONLY when the drive state the handler - // manipulates (speed / FUA / approach direction) matches — so a test - // that recovers it PROVES the technique was actually exercised, not that - // a plain read happened to work. - /// Current `SET CD SPEED` value (updated by `set_speed`); max at build. - speed: u16, - /// Reads ONLY at min speed (fails at max) → SlowSpin / SpeedSweep. - slow_only: HashSet, - /// Reads ONLY on the Nth *physical* (FUA) attempt; a cached (non-FUA) - /// re-read never gets it → FuaRetry. Maps LBA → attempts required. - fua_need: HashMap, - /// Physical (FUA) attempts observed so far, per LBA. - fua_seen: HashMap, - /// Reads ONLY when approached from ABOVE (the previous physical access - /// was a higher LBA) → Oscillate's reverse-into pass. - dir_reverse_only: HashSet, - /// Reads ONLY when the immediately-preceding sector was the previous - /// physical access (PLL/servo primed) → CachePrime. - prime_only: HashSet, - /// LBA of the last sector physically accessed (success or fail) — the - /// approach-direction / priming signal the specialists drive. - last_lba: Option, - } - - impl SectorSource for FakeDisc { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> Result { - // Bulk (non-FUA) path. - self.read_sectors_fua(lba, count, buf, recovery, false) - } - - fn read_sectors_fua( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - fua: bool, - ) -> Result { - self.reads.fetch_add(1, Ordering::Relaxed); - self.clock_nanos - .fetch_add(self.per_read.as_nanos() as u64, Ordering::Relaxed); - // The head moved across this span; record where it ended so the NEXT - // read can see the approach direction / priming (both success and - // failure move the head). - let prev = self.last_lba; - self.last_lba = Some(lba + count as u32 - 1); - if let Some(t) = self.transport_at { - if (lba..lba + count as u32).contains(&t) { - return Err(Error::ScsiError { - opcode: crate::scsi::SCSI_READ_10, - status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }); - } - } - for l in lba..lba + count as u32 { - if self.wedge.contains(&l) { - // Fast-fail wedge sense: ILLEGAL REQUEST / INVALID FIELD IN - // CDB (0x05/0x24), the real BU40N wedge signature. Non- - // transport status so it isn't caught as a bus fault, but - // carries sense so the wedge classifier sees it. - return Err(Error::ScsiError { - opcode: crate::scsi::SCSI_READ_10, - status: 0x02, - sense: Some(crate::scsi::ScsiSense { - sense_key: crate::scsi::SENSE_KEY_ILLEGAL_REQUEST, - asc: 0x24, - ascq: 0x00, - }), - }); - } - if self.dead.contains(&l) { - // Non-transport bad-sector error (CHECK CONDITION, 0x02). - return Err(Error::DiscRead { - sector: l as u64, - status: Some(0x02), - sense: None, - }); - } - // Marginal sector: reads only at min spindle speed. - if self.slow_only.contains(&l) && self.speed != SPEED_MIN_KBS { - return Err(bad_sector(l)); - } - // Stochastic sector: needs N physical (FUA) reads; a cached read - // can never land it (cache masks the good re-read). - if let Some(need) = self.fua_need.get(&l).copied() { - if !fua { - return Err(bad_sector(l)); - } - let seen = self.fua_seen.entry(l).or_insert(0); - *seen += 1; - if *seen < need { - return Err(bad_sector(l)); - } - } - // Direction-dependent tracking: reads only when approached from - // above (previous physical access was a higher LBA). - if self.dir_reverse_only.contains(&l) && prev.is_none_or(|p| p <= l) { - return Err(bad_sector(l)); - } - // Boundary sector: reads only when the preceding sector was the - // previous physical access (servo primed). - if self.prime_only.contains(&l) && prev != l.checked_sub(1) { - return Err(bad_sector(l)); - } - } - let bytes = count as usize * SECTOR as usize; - for (i, b) in buf[..bytes].iter_mut().enumerate() { - *b = (lba as usize + i / SECTOR as usize) as u8; - } - Ok(bytes) - } - - fn set_speed(&mut self, kbs: u16) { - self.speed = kbs; - } - } - - /// The ordinary recoverable bad-sector error (CHECK CONDITION, no sense) the - /// conditional failure modes return when their precondition isn't met. - fn bad_sector(l: u32) -> Error { - Error::DiscRead { - sector: l as u64, - status: Some(0x02), - sense: None, - } - } - - /// Records every recovered span so a test can assert which sectors came back. - #[derive(Default)] - struct RecordSink { - got: HashMap, // pos -> bytes - } - impl RecoverySink for RecordSink { - fn recovered(&mut self, pos: u64, buf: &[u8]) { - self.got.insert(pos, buf.len()); - } - } - - /// A fake clock plus a disc sharing its timeline. - struct Harness { - clock_nanos: Arc, - reads: Arc, - base: Instant, - } - - impl Harness { - fn build(dead: &[u32], transport_at: Option, per_read: Duration) -> (Self, FakeDisc) { - let clock_nanos = Arc::new(AtomicU64::new(0)); - let reads = Arc::new(AtomicU64::new(0)); - let disc = FakeDisc { - dead: dead.iter().copied().collect(), - wedge: HashSet::new(), - transport_at, - clock_nanos: clock_nanos.clone(), - per_read, - reads: reads.clone(), - speed: SPEED_MAX_KBS, - slow_only: HashSet::new(), - fua_need: HashMap::new(), - fua_seen: HashMap::new(), - dir_reverse_only: HashSet::new(), - prime_only: HashSet::new(), - last_lba: None, - }; - ( - Harness { - clock_nanos, - reads, - base: Instant::now(), - }, - disc, - ) - } - - fn now_fn(&self) -> impl Fn() -> Instant { - let c = self.clock_nanos.clone(); - let base = self.base; - move || base + Duration::from_nanos(c.load(Ordering::Relaxed)) - } - - fn read_count(&self) -> u64 { - self.reads.load(Ordering::Relaxed) - } - } - - fn lba(pos: u64) -> u32 { - (pos / SECTOR) as u32 - } - - #[test] - fn chain_recovers_readable_in_a_dead_batch_leaving_only_dead() { - // Section [0, 10 sectors). Dead: sectors 3 and 7. Linear reads it as one - // batch, which fails (it contains dead sectors), so Linear leaves the - // whole batch bad — NO per-sector grind (that's the point of dropping - // narrow_batch). Bisect then probes/expands and salvages the 8 readable - // sectors, leaving ONLY 3 and 7. Proves the Linear→Bisect division of - // labour: Linear sweeps at batch granularity, Bisect finds the islands. - let dead = [3u32, 7u32]; - let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 10 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(10); - // Linear leaves the failed 10-sector batch whole. - Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!( - bad.total_len(), - 10 * SECTOR, - "linear leaves the dead batch whole" - ); - // Bisect salvages the readable sectors around the dead ones. - ctx.unproductive = 0; - let out = Bisect { - params: ReadParams::fast(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - // Exactly the two dead sectors remain. - assert_eq!(bad.total_len(), 2 * SECTOR); - for &(p, l) in bad.ranges() { - assert_eq!(l, SECTOR); - assert!( - lba(p) == 3 || lba(p) == 7, - "unexpected bad sector {}", - lba(p) - ); - } - } - - #[test] - fn linear_forward_front_dead_still_reaches_readable_tail() { - // THE bug: front dead, tail readable. Section [0, 40 sectors). First 32 - // (one whole batch) are dead; the tail 8 are readable. Forward linear - // must recover the tail — it does not hang at the front. - let dead: Vec = (0..32).collect(); - let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 40 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(10); - let mut lin = Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - }; - let out = lin.recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - // The 32 dead front sectors remain; the 8-sector readable tail is - // recovered as one clean batch (one sink span covering 8 sectors). - assert_eq!(bad.total_len(), 32 * SECTOR); - assert_eq!(sink.got.len(), 1, "tail is one clean 8-sector batch"); - assert_eq!( - sink.got.get(&(32 * SECTOR)).copied(), - Some(8 * SECTOR as usize), - "tail batch not recovered" - ); - } - - #[test] - fn linear_honors_deadline_and_returns_promptly() { - // 1000 clean sectors, but each read costs 1 s and the budget is 3 s. The - // handler must stop after ~3 reads, NOT drain all 1000 — proving bounded - // wall-clock even on a huge range. - let (h, disc) = Harness::build(&[], None, Duration::from_secs(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 1000 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(3); - let mut lin = Linear { - direction: Direction::Forward, - params: ReadParams::fast(), - }; - let out = lin.recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - // Batch=32 clean sectors per read: a handful of reads at most, not 1000. - assert!( - h.read_count() <= 5, - "ran {} reads, expected <=5", - h.read_count() - ); - assert!(bad.total_len() > 0, "should not have drained the range"); - } - - #[test] - fn bisect_finds_good_middle_in_mostly_dead_range() { - // 9 sectors, only the middle (sector 4) readable. Bisect probes the - // middle first, recovers it, and the recursive halves' middles are dead. - let dead: Vec = (0..9).filter(|&l| l != 4).collect(); - let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 9 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(10); - let mut bis = Bisect { - params: ReadParams::fast(), - }; - let out = bis.recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert!( - sink.got.contains_key(&(4 * SECTOR)), - "good middle not found" - ); - assert_eq!( - bad.total_len(), - 8 * SECTOR, - "only the middle should recover" - ); - } - - #[test] - fn coordinator_reverse_then_forward_makes_progress_direction_matters() { - // Two dead sectors at opposite ends won't both be cleared by one - // direction alone in this contrived fixture, but the CHAIN clears every - // readable sector regardless of order. Prove the coordinator runs - // handler after handler and drains the readable set. - let dead = [0u32, 15u32]; // ends of a 16-sector section - let (h, disc) = Harness::build(&dead, None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 16 * SECTOR); - let mut handlers: Vec> = vec![ - Box::new(Linear { - direction: Direction::Reverse, - params: ReadParams::deep(), - }), - Box::new(Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - }), - Box::new(Bisect { - params: ReadParams::fast(), - }), - ]; - let deadline_base = (ctx.now)(); - let mut scoreboard = HandlerScoreboard::default(); - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { - deadline_base + Duration::from_secs(30) - }); - assert_eq!(out, HandlerOutcome::Remaining); - // 14 readable sectors recovered, only the two dead ends remain. - assert_eq!(bad.total_len(), 2 * SECTOR); - for &(p, _) in bad.ranges() { - assert!(lba(p) == 0 || lba(p) == 15); - } - } - - #[test] - fn coordinator_completes_when_no_dead_sectors() { - // A clean section drains to Complete on the first handler. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 64 * SECTOR); - let mut handlers: Vec> = vec![Box::new(Linear { - direction: Direction::Forward, - params: ReadParams::fast(), - })]; - let base = (ctx.now)(); - let mut scoreboard = HandlerScoreboard::default(); - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { - base + Duration::from_secs(30) - }); - assert_eq!(out, HandlerOutcome::Complete); - assert!(bad.is_empty()); - } - - #[test] - fn transport_fault_short_circuits() { - // A transport fault mid-range returns TransportFault immediately so the - // caller can un-wedge the drive. - let (h, disc) = Harness::build(&[], Some(5), Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - // Single-sector batches so the transport LBA is hit directly. - let mut bad = SubRanges::from_section(0, 8 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(10); - let mut lin = Linear { - direction: Direction::Forward, - params: ReadParams::fast(), - }; - let out = lin.recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::TransportFault); - } - - #[test] - fn wedged_drive_aborts_fast_instead_of_grinding() { - // Regression for the 2026-07-01 incident: a fast-fail wedge (drive - // returns ILLEGAL REQUEST on every CDB) was classified as an ordinary - // bad sector, so the chain ground a dead drive for 28 min at 0 B/s. - // Now a sustained run of wedge-family senses escalates to TransportFault - // so the pass aborts and the caller spin-cycles. A big section (1000 - // sectors) that is ENTIRELY wedged must bail after ~WEDGE_ABORT_STREAK - // reads, not after reading the whole thing. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.wedge = (0..1000u32).collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 1000 * SECTOR); - // The full tier-0 chain: the wedge streak persists across handlers (only - // `unproductive` resets per handler), so it reaches the abort threshold - // even though each handler yields early on the dead streak. - let mut handlers: Vec> = 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(), - }), - ]; - let mut scoreboard = HandlerScoreboard::default(); - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { - (h.now_fn())() + Duration::from_secs(60) - }); - assert_eq!( - out, - HandlerOutcome::TransportFault, - "a wholly-wedged section must escalate to TransportFault" - ); - // The whole point: it bailed after a short streak, not after grinding all - // 1000 sectors. Generous bound (handlers read in batches) but far below - // the section size. - assert!( - h.read_count() < 100, - "wedge must abort fast; did {} reads on a 1000-sector wedged section", - h.read_count() - ); - } - - #[test] - fn slow_hardware_error_media_does_not_false_trip_wedge_abort() { - // A genuine uncorrectable sector on Hardware-error media reports a - // wedge-FAMILY sense (IllegalRequest here) but comes back SLOW — the drive - // spent real time on ECC recovery before failing. That must NOT count - // toward the wedge abort (which targets the drive's <100ms fast-fail - // rejection). Each read here costs 600ms (> WEDGE_FASTFAIL_MS), so even a - // wholly-"wedge-sense" section never escalates to TransportFault — it just - // leaves the residue bad for the next pass, exactly like ordinary damage. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(600)); - let mut disc = disc; - disc.wedge = (0..1000u32).collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 1000 * SECTOR); - let mut handlers: Vec> = 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(), - }), - ]; - let mut scoreboard = HandlerScoreboard::default(); - // Long per-handler deadline so the deadline (not the wedge) is never the - // reason a handler stops — we're isolating the wedge-escalation decision. - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut scoreboard, |_| { - (h.now_fn())() + Duration::from_secs(3600) - }); - assert_ne!( - out, - HandlerOutcome::TransportFault, - "slow (ECC-recovery) Hardware-error reads must NOT trip the fast-fail wedge abort" - ); - assert_eq!( - ctx.wedge_streak, 0, - "slow wedge-family reads must not accumulate the streak" - ); - } - - #[test] - fn wedge_streak_persists_across_sections_for_tier1() { - // Tier 1 is only TWO handlers, so one wedged section builds at most - // 2 × UNPRODUCTIVE_YIELD = 8 streak — below WEDGE_ABORT_STREAK (16). The - // wedge is caught only because the pass-level wedge_streak PERSISTS across - // sections. Simulate what PatchCtx does: carry wedge_streak in/out of each - // per-section run_handlers call, and assert the abort lands on a LATER - // section, not the first. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.wedge = (0..4000u32).collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut carried = 0u32; // the pass-level wedge_streak - let mut caught_on: Option = None; - for section in 0..6usize { - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: carried, - cur_speed: SPEED_MAX_KBS, - }; - // Distinct 100-sector section per iteration, all within the wedge set. - let pos = (section as u64) * 100 * SECTOR; - let mut bad = SubRanges::from_section(pos, 100 * SECTOR); - // Tier-1 shape: two slow Linear handlers, nothing that reaches 16 alone. - let mut handlers: Vec> = vec![ - Box::new(Linear { - direction: Direction::Reverse, - params: ReadParams::deep(), - }), - Box::new(Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - }), - ]; - let mut sb = HandlerScoreboard::default(); - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut sb, |_| { - (h.now_fn())() + Duration::from_secs(60) - }); - carried = ctx.wedge_streak; - if out == HandlerOutcome::TransportFault { - caught_on = Some(section); - break; - } - } - let caught = caught_on.expect("a two-handler tier must still catch the wedge"); - assert!( - caught >= 1, - "one 2-handler section can't reach the streak alone; the wedge must be \ - caught via cross-section accumulation, not on section 0 (caught on {caught})" - ); - } - - #[test] - fn halt_token_returns_promptly() { - // Halt set before the call: the handler returns Halted on its first - // check, having done no reads. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let halt = AtomicBool::new(true); - let mut ctx = HandlerCtx { - reader: &mut disc, - sink: &mut sink, - now: &now, - halt: Some(&halt), - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - }; - let mut bad = SubRanges::from_section(0, 100 * SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(10); - let mut lin = Linear { - direction: Direction::Forward, - params: ReadParams::fast(), - }; - let out = lin.recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Halted); - assert_eq!(h.read_count(), 0, "halt must precede any read"); - } - - #[test] - fn scorecard_decays_so_a_late_starter_overtakes_an_early_winner() { - // The whole point of the DECAYED rate: the residual hardens mid-pass, so - // leadership must hand off. A cumulative rate would freeze "early" in the - // lead forever; the EWMA re-prices continuously. - let mut sb = HandlerScoreboard::default(); - let dt = Duration::from_secs(1); - - // Round 1 — "early" cleans the easy bulk; "late" finds nothing yet. - sb.record("early", 1_000_000, dt); - sb.record("late", 0, dt); - assert!( - sb.rank("early") > sb.rank("late"), - "early must lead once it's the only one recovering" - ); - - // The bulk is gone. Now "early"'s technique no longer fits the hardened - // residual (barren attempts) while "late"'s specialist starts winning. - for _ in 0..4 { - sb.record("early", 0, dt); - sb.record("late", 1_000_000, dt); - } - assert!( - sb.rank("late") > sb.rank("early"), - "a handler that stops earning must LOSE its lead to a late starter \ - (late={}, early={})", - sb.rank("late"), - sb.rank("early") - ); - - // Calibration invariants preserved: an untried handler still ranks top - // (one-shot calibration), and a handler attempted with no timed read - // (zero elapsed) ranks bottom rather than crowding out proven performers. - assert_eq!(sb.rank("never_tried"), u64::MAX, "untried → top"); - sb.record("idle", 0, Duration::ZERO); - assert_eq!(sb.rank("idle"), 0, "attempted-but-zero-time → bottom"); - } - - /// Build a ctx over `disc` with the fake clock — the common per-test setup. - macro_rules! ctx { - ($h:expr, $disc:expr, $sink:expr, $now:expr) => { - HandlerCtx { - reader: &mut $disc, - sink: &mut $sink, - now: &$now, - halt: None, - decrypt_is_aacs: false, - tick: None, - unproductive: 0, - wedge_streak: 0, - cur_speed: SPEED_MAX_KBS, - } - }; - } - - fn min_deep() -> ReadParams { - ReadParams { - speed: SpeedPref::Min, - fua: false, - timeout: TimeoutPref::Deep, - } - } - - #[test] - fn slow_spin_recovers_a_min_speed_only_sector_that_max_linear_misses() { - // Sector 5 reads ONLY at min spindle speed (weak signal / servo drift): - // a max-speed deep Linear leaves it bad; SlowSpin (Linear pinned to min) - // recovers it. Single-sector residual so Linear reads it directly. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.slow_only = [5u32].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(5 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - // Max-speed deep Linear cannot read a min-only sector. - let out = Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!( - bad.total_len(), - SECTOR, - "max-speed linear must leave it bad" - ); - - // SlowSpin = Linear at min speed — recovers it. - let out = Linear { - direction: Direction::Forward, - params: min_deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!(bad.is_empty(), "SlowSpin must recover the min-only sector"); - assert_eq!(sink.got.get(&(5 * SECTOR)).copied(), Some(SECTOR as usize)); - } - - #[test] - fn speed_sweep_recovers_a_min_speed_only_sector() { - // SpeedSweep sweeps Max→Min per sector, so it reaches the min-only - // sector 7 that a max-only read never gets — proving the sweep actually - // drops the spindle when the fast read fails. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.slow_only = [7u32].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(7 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - let out = SpeedSweep { - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!(bad.is_empty(), "SpeedSweep must reach min and recover it"); - assert_eq!(sink.got.get(&(7 * SECTOR)).copied(), Some(SECTOR as usize)); - // It tried the fast (max) read first, then the min read — 2 reads. - assert_eq!(h.read_count(), 2, "swept max then min"); - } - - fn max_fua_deep() -> ReadParams { - ReadParams { - speed: SpeedPref::Max, - fua: true, - timeout: TimeoutPref::Deep, - } - } - - #[test] - fn fua_retry_recovers_a_stochastic_sector_a_cached_read_keeps_missing() { - // Sector 9 lands only on its 2nd PHYSICAL (FUA) read; a cached (non-FUA) - // re-read never gets it (the cache masks the good re-read). FuaRetry = - // the Linear fwd + rev + Bisect group at FUA params: across its reads the - // sector gets enough physical attempts to land, where cached reads can't. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.fua_need = [(9u32, 2u32)].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(9 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - // Cached (non-FUA) reads keep missing — twice, and the sector stays bad - // (a cached miss never even counts as a physical attempt). - for _ in 0..2 { - let out = Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!(bad.total_len(), SECTOR, "cached read must keep missing"); - } - - // FuaRetry group: Linear fwd (FUA attempt 1) leaves it, Linear rev (FUA - // attempt 2) lands it. - let mut handlers: Vec> = vec![ - Box::new(Linear { - direction: Direction::Forward, - params: max_fua_deep(), - }), - Box::new(Linear { - direction: Direction::Reverse, - params: max_fua_deep(), - }), - Box::new(Bisect { - params: max_fua_deep(), - }), - ]; - let mut sb = HandlerScoreboard::default(); - let out = run_handlers(&mut ctx, &mut handlers, &mut bad, &mut sb, |_| deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!(bad.is_empty(), "FuaRetry must land the stochastic sector"); - assert_eq!(sink.got.get(&(9 * SECTOR)).copied(), Some(SECTOR as usize)); - } - - #[test] - fn slow_fua_recovers_the_hardest_sector_needing_both_min_and_fua() { - // Sector 11 is the hardest case: it reads ONLY at min speed AND ONLY on a - // physical (FUA) read. Neither lever alone works — SlowFua (Linear at - // {min, fua, deep}) is the combination that recovers it. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.slow_only = [11u32].into_iter().collect(); - disc.fua_need = [(11u32, 1u32)].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(11 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - // FUA but max speed → wrong speed, fails. - let out = Linear { - direction: Direction::Forward, - params: max_fua_deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!( - bad.total_len(), - SECTOR, - "max+fua must miss the min-only sector" - ); - - // Min speed but cached (no FUA) → no physical attempt, fails. - let out = Linear { - direction: Direction::Forward, - params: min_deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!( - bad.total_len(), - SECTOR, - "min+cached must miss the FUA-only sector" - ); - - // Both levers: min speed AND FUA → recovers. - let out = Linear { - direction: Direction::Forward, - params: ReadParams { - speed: SpeedPref::Min, - fua: true, - timeout: TimeoutPref::Deep, - }, - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!( - bad.is_empty(), - "SlowFua (min+fua) must recover the hardest sector" - ); - assert_eq!(sink.got.get(&(11 * SECTOR)).copied(), Some(SECTOR as usize)); - } - - #[test] - fn oscillate_recovers_a_direction_dependent_sector_forward_linear_misses() { - // Sector 13 reads ONLY when approached from ABOVE (reverse-into). A plain - // forward Linear (approaches from below) misses it; Oscillate's - // reverse-into pass recovers it. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.dir_reverse_only = [13u32].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(13 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - // Forward Linear approaches from below → misses the reverse-only sector. - let out = Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!(bad.total_len(), SECTOR, "forward linear must miss it"); - - // Oscillate tries forward-into then reverse-into → the reverse-into pass - // approaches from above and lands it. - let out = Oscillate { - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!( - bad.is_empty(), - "Oscillate must recover the direction-dependent sector" - ); - assert_eq!(sink.got.get(&(13 * SECTOR)).copied(), Some(SECTOR as usize)); - } - - #[test] - fn cache_prime_recovers_a_boundary_sector_that_needs_a_warm_channel() { - // Sector 15 reads ONLY when the immediately-preceding sector was just - // read (servo primed) — a boundary the drive can't lock onto from a cold - // seek. A cold Linear read of the island misses it; CachePrime reads the - // preceding good sector first, then lands it warm. - let (h, disc) = Harness::build(&[], None, Duration::from_millis(1)); - let mut disc = disc; - disc.prime_only = [15u32].into_iter().collect(); - let mut sink = RecordSink::default(); - let now = h.now_fn(); - let mut ctx = ctx!(h, disc, sink, now); - let mut bad = SubRanges::from_section(15 * SECTOR, SECTOR); - let deadline = (ctx.now)() + Duration::from_secs(30); - - // Cold Linear read (never touches the preceding sector) → misses it. - let out = Linear { - direction: Direction::Forward, - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Remaining); - assert_eq!( - bad.total_len(), - SECTOR, - "cold linear must miss the boundary sector" - ); - - // CachePrime reads the preceding run first → warm channel → lands it. - let out = CachePrime { - params: ReadParams::deep(), - } - .recover(&mut ctx, &mut bad, deadline); - assert_eq!(out, HandlerOutcome::Complete); - assert!( - bad.is_empty(), - "CachePrime must recover the primed boundary sector" - ); - assert_eq!(sink.got.get(&(15 * SECTOR)).copied(), Some(SECTOR as usize)); - } -} diff --git a/src/disc/sweep.rs b/src/disc/sweep.rs deleted file mode 100644 index 0ac2d1c..0000000 --- a/src/disc/sweep.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! `Disc::sweep`'s consumer-side `Sink`. -//! -//! Background: the original sweep loop runs strictly serialised — -//! SCSI read → decrypt → seek + write → mapfile.record → next iter. -//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and -//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync -//! 5-15 ms) adds another batch's worth of latency. The drive idles -//! during the post-read work; throughput tops out at the *sum* of -//! both costs. -//! -//! A producer/consumer split overlaps the two stages on the generic -//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module -//! is the sweep-specific `Sink` impl; the producer-side state machine -//! (read_error context, decrypt, set_speed, halt) stays in -//! `Disc::sweep` in `disc/mod.rs`. -//! -//! Correctness invariants preserved: -//! - Mapfile is single-writer (consumer-only). No locking. -//! - All `read_error::ReadCtx` state 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. -//! - Only one SCSI command is in flight at a time; error-path timing -//! is identical and no new retry logic is introduced. - -use std::io::{Seek, SeekFrom, Write}; -use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; - -use crate::error::Error; -use crate::io::{Flow, Sink}; - -use super::mapfile::{MapStats, Mapfile, SectorStatus}; - -/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB -/// matches the existing zero_gap chunk size used by the pre-split -/// sweep loop. -const ZERO_CHUNK: usize = 64 * 1024; - -/// Producer → Consumer messages. The consumer applies these in FIFO -/// order; ordering of file writes and mapfile records across items is -/// preserved. -pub(super) enum WorkItem { - /// Successful batch read. Producer has already decrypted `buf` if - /// `opts.decrypt` was set. Consumer writes `buf` at `pos` and - /// records the range as `Finished`. - Good { pos: u64, buf: Vec }, - - /// Bisect inner-loop good single sector (already decrypted by the - /// producer). 2048 bytes. - BisectGood { pos: u64, buf: Box<[u8; 2048]> }, - - /// Bisect inner-loop bad single sector. Consumer writes 2048 - /// zeros at `pos` and records the sector as `NonTrimmed`. - BisectBad { pos: u64 }, - - /// Whole-batch zero-fill (failed batch on `SkipBlock`, or the - /// failed batch portion of `JumpAhead`). Consumer streams zeros - /// across `[pos, pos+len)` and records the range as `NonTrimmed`. - SkipFill { pos: u64, len: u64 }, - - /// Gap fill following a `JumpAhead`. Same effect as `SkipFill`; - /// distinguished only so future logging / instrumentation can - /// tell them apart without parsing a flag. - GapFill { pos: u64, len: u64 }, - - /// Producer wants the latest mapfile stats for the progress - /// callback. Consumer responds on `prog_tx` with a fresh - /// [`ProgressSnapshot`]. Best-effort: if the producer hasn't - /// drained the previous snapshot, the new one is silently - /// dropped — the producer's local cache stays current enough. - StatsRequest, -} - -/// Snapshot the consumer sends back to the producer for the progress -/// callback. -pub(super) struct ProgressSnapshot { - pub stats: MapStats, - pub bad_ranges: Vec<(u64, u64)>, -} - -/// Final summary returned by the consumer thread on shutdown — what -/// `SweepSink::close` produces, surfaced to the producer via -/// `Pipeline::finish`. -pub(super) struct ConsumerSummary { - pub stats: MapStats, -} - -/// Drain any pending progress snapshots from the consumer. Returns -/// the most recent one, if any. The producer caches it and uses it -/// for subsequent progress callbacks until a fresh one arrives. -pub(super) fn try_recv_progress(rx: &Receiver) -> Option { - let mut latest = None; - while let Ok(snap) = rx.try_recv() { - latest = Some(snap); - } - latest -} - -/// `Sink` for sweep. Owns the writeback file + mapfile + -/// progress back-channel. `apply` carries the file-write + -/// mapfile.record per item; `close` drains the writeback pipeline, -/// fsyncs the ISO, and flushes the mapfile. -pub(super) struct SweepSink { - file: crate::io::WritebackFile, - map: Mapfile, - /// `sync_all`-on-failure-is-an-error iff the output is a regular - /// file. `/dev/null` and pipes always fail `sync_all`; that's not - /// a real error. - is_regular: bool, - /// Back-channel for `StatsRequest` responses. The producer caches - /// the latest snapshot and uses it for the progress callback; - /// dropped sends on a full channel are by design. - prog_tx: SyncSender, - /// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held - /// in the sink so each apply call doesn't reallocate. - zero: Box<[u8; ZERO_CHUNK]>, -} - -impl SweepSink { - /// Construct a new `SweepSink` plus the matching progress - /// receiver. Channel depth on the back-channel is `1` — the - /// producer's cache is the source of truth between snapshots. - pub(super) fn new( - file: crate::io::WritebackFile, - map: Mapfile, - is_regular: bool, - ) -> (Self, Receiver) { - let (prog_tx, prog_rx) = sync_channel::(1); - let sink = SweepSink { - file, - map, - is_regular, - prog_tx, - zero: Box::new([0u8; ZERO_CHUNK]), - }; - (sink, prog_rx) - } -} - -impl Sink for SweepSink { - type Output = ConsumerSummary; - - fn apply(&mut self, item: WorkItem) -> Result { - match item { - WorkItem::Good { pos, buf } => { - // Decrypt is on the producer; consumer assumes plaintext. - let len = buf.len() as u64; - self.file.seek(SeekFrom::Start(pos))?; - self.file.write_all(&buf)?; - self.map.record(pos, len, SectorStatus::Finished)?; - } - WorkItem::BisectGood { pos, buf } => { - self.file.seek(SeekFrom::Start(pos))?; - self.file.write_all(&buf[..])?; - self.map.record(pos, 2048, SectorStatus::Finished)?; - } - WorkItem::BisectBad { pos } => { - self.file.seek(SeekFrom::Start(pos))?; - self.file.write_all(&self.zero[..2048])?; - self.map.record(pos, 2048, SectorStatus::NonTrimmed)?; - } - WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => { - self.file.seek(SeekFrom::Start(pos))?; - // Subsequent writes are sequential; `WritebackFile`'s - // seek-elision keeps them on the writeback pipeline path. - let mut filled = 0u64; - while filled < len { - let chunk = (len - filled).min(self.zero.len() as u64) as usize; - self.file.write_all(&self.zero[..chunk])?; - filled += chunk as u64; - } - self.map.record(pos, len, SectorStatus::NonTrimmed)?; - } - WorkItem::StatsRequest => { - let stats = self.map.stats(); - // DAMAGE only — NOT NonTried. NonTried is the unread remainder - // ahead of the sweep head, not damage; including it made the live - // located drilldown (at-risk movie time + range count) treat the - // whole unread disc as confirmed damage, so at sweep start it - // showed ~full-movie at-risk and melted to 0 as the sweep - // progressed. Matches the one-shot progress path, which already - // excludes NonTried. - let bad_ranges = self.map.ranges_with(&[ - SectorStatus::NonTrimmed, - SectorStatus::Unreadable, - SectorStatus::NonScraped, - ]); - // Best-effort: drop on backpressure; producer's cache - // stays current enough. - let _ = self - .prog_tx - .try_send(ProgressSnapshot { stats, bad_ranges }); - } - } - Ok(Flow::Continue) - } - - fn close(mut self) -> Result { - // Drain the writeback pipeline + fsync the ISO, then persist - // any pending mapfile state. Same finalisation order as the - // pre-Pipeline consumer loop. - if let Err(e) = self.file.sync_all() { - if self.is_regular { - return Err(Error::IoError { source: e }); - } - // Non-regular outputs (/dev/null, pipes) always fail - // sync_all; that's not a real error. - } - self.map.flush()?; - - Ok(ConsumerSummary { - stats: self.map.stats(), - }) - } -} diff --git a/src/drive/mod.rs b/src/drive/mod.rs index ed64da5..6fd3742 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -483,7 +483,7 @@ impl Drive { // the stock riplock). A stock-mode drive with no firmware unlocker still // wants max speed. Best-effort: a failure here must NOT fail the rip. if r.is_ok() { - self.set_speed(crate::speed::DriveSpeed::Max.to_kbps()); + self.set_speed(Self::SPEED_MAX_KBPS); // Ask the drive to REPORT recovered/marginal reads rather than // silently commit best-effort data as GOOD (the dirty-disc // "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive @@ -951,6 +951,9 @@ impl Drive { decode_read_capacity(&buf, result.bytes_transferred) } + /// SET CD SPEED "use the drive's maximum" sentinel (0xFFFF KB/s per MMC). + pub const SPEED_MAX_KBPS: u16 = 0xFFFF; + pub fn set_speed(&mut self, speed_kbs: u16) { let cdb = crate::scsi::build_set_cd_speed(speed_kbs); let mut dummy = [0u8; 0]; diff --git a/src/io/mod.rs b/src/io/mod.rs index 5d8d2a3..926539a 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -43,6 +43,5 @@ pub mod pipeline; pub use writeback_file::WritebackFile; pub use pipeline::{ - DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH, - WRITE_THROUGH_DEPTH, + DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH, }; diff --git a/src/io/pipeline.rs b/src/io/pipeline.rs index fa258d1..ec23cfc 100644 --- a/src/io/pipeline.rs +++ b/src/io/pipeline.rs @@ -173,14 +173,9 @@ fn finish_with_grace( /// Default channel depth for callers without a specific reason to /// pick another value. Kept conservative (4) — most callers should -/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead. +/// use WRITE_PIPELINE_DEPTH instead. pub const DEFAULT_PIPELINE_DEPTH: usize = 4; -/// Read pipeline depth. Larger buffer compensates for drive variability -/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when -/// consumer blocks on write. -pub const READ_PIPELINE_DEPTH: usize = 32; - /// Write pipeline depth. Smaller buffer reduces backpressure risk when /// sync_file_range blocks; prevents producer from accumulating too much /// work while consumer waits for NFS to drain. diff --git a/src/lib.rs b/src/lib.rs index 874426d..7400ec9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -126,7 +126,6 @@ pub mod progress; pub mod scsi; pub mod sector; pub mod session; -pub(crate) mod speed; pub(crate) mod udf; pub(crate) mod unlock_bridge; @@ -159,25 +158,26 @@ pub use error::{Error, Result, is_disc_level_no_key, is_halt, is_skippable_title // ─── Cooperative cancellation ─────────────────────────────────────────────── // -// One-bit cooperative cancellation token, shared by every long-running loop -// in libfreemkv (sweep, patch, mux). Clone it cheaply; pass it by value into -// each component; poll `is_cancelled()` inside the loop body. +// One-bit cooperative cancellation token, shared by every long-running loop — +// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the +// freemkv-engine crate. Clone it cheaply; pass it by value into each component; +// poll `is_cancelled()` inside the loop body. pub use halt::Halt; -// Generic bounded producer/consumer primitive used by sweep, patch, and -// mux to overlap reads with writes via a dedicated consumer thread. +// Generic bounded producer/consumer primitive used by the mux pipeline (and, +// via this re-export, by the engine's sweep/patch recovery passes) to overlap +// reads with writes via a dedicated consumer thread. // `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)` // pushes one item with back-pressure; `pipe.finish()` joins the // consumer and surfaces its `close()` output. Callers implement `Sink` // to define per-item behaviour and end-of-stream finalisation. // // `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs; -// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead. +// most should use WRITE_PIPELINE_DEPTH instead. // Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from // `apply` ends the consumer cleanly (still calls `close()`). pub use io::pipeline::{ - DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH, - WRITE_THROUGH_DEPTH, + DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH, }; // ─── Bounded-cache buffered file writer ───────────────────────────────────── @@ -206,10 +206,7 @@ pub use identity::DriveId; // don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)` // accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is // for callers that operate on raw sector buffers (e.g. ISO patching). -pub use decrypt::{ - AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads, - set_decrypt_threads, -}; +pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads}; // ─── Disc structure ───────────────────────────────────────────────────────── // @@ -223,11 +220,10 @@ pub use decrypt::{ // different concepts, the same short name; the trait gets the `Pes` // prefix at the crate root to keep both addressable. pub use disc::{ - AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity, - Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult, - FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, - PatchOutcome, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, SweepOptions, - VideoStream, classify_damage, + AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc, + DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult, + FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, Resolution, + SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream, }; pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply}; @@ -273,11 +269,9 @@ pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report}; pub use mux::build_iso_pipeline; pub use mux::resolve_mux_key_map; pub use mux::select::{PidFilter, StreamSelection}; -pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream}; +pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives}; pub use sector::{ - DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource, - SectorSink, SectorSource, + DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource, }; -pub use speed::DriveSpeed; pub use udf::{UdfFs, read_filesystem}; diff --git a/src/mux/driver.rs b/src/mux/driver.rs index 110778b..f90fa68 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -223,9 +223,11 @@ pub trait MuxEvents: Send + Sync + 'static { fn on_read_error(&self, _lba: u32) {} } -/// A [`MuxEvents`] that ignores everything — for callers that render no -/// progress. -pub struct NoopEvents; +/// A [`MuxEvents`] that ignores everything — test-only (production callers +/// supply their own events sink). +#[cfg(test)] +pub(crate) struct NoopEvents; +#[cfg(test)] impl MuxEvents for NoopEvents {} /// The result of a [`mux_stream`] run. diff --git a/src/mux/mod.rs b/src/mux/mod.rs index 967dca5..16e5604 100644 --- a/src/mux/mod.rs +++ b/src/mux/mod.rs @@ -116,7 +116,7 @@ pub(crate) mod videomap; // direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed, // and no consumer names these types, so they are not public API. pub use disc::DiscStream; -pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream}; +pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream}; pub use m2ts::M2tsStream; pub use mkvstream::MkvStream; pub use mp4::{Mp4FitReport, Mp4SkipReason, fit_report as mp4_fit_report}; diff --git a/src/sector/file.rs b/src/sector/file.rs deleted file mode 100644 index d738808..0000000 --- a/src/sector/file.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! File-backed sector sink — write 2048-byte sectors to an ISO image -//! on disk. -//! -//! The read-side counterpart ([`crate::io::file_sector_source::FileSectorSource`]) -//! lives under `io/` because its internals (read-ahead buffer, per-OS -//! `fadvise`/`F_RDADVISE` hints) are I/O infrastructure rather than -//! sector-trait business logic. Both types remain re-exported at -//! [`crate::sector`] for ergonomic imports. - -use std::fs::OpenOptions; -use std::io::{Seek, SeekFrom, Write}; -use std::path::Path; - -use crate::error::{Error, Result}; - -use super::SectorSink; - -/// SectorSink backed by a file (ISO image). -/// -/// Writes go through [`crate::io::WritebackFile`], which on Linux drives -/// continuous `sync_file_range` + `posix_fadvise(DONTNEED)` to keep -/// the kernel dirty page cache bounded during multi-GB sequential -/// writes. macOS / Windows fall through to a no-op pipeline. -/// -/// `finish` runs `sync_all` before dropping the underlying file. -pub struct FileSectorSink { - inner: crate::io::WritebackFile, -} - -impl FileSectorSink { - /// Create a new ISO file at `path`, truncating any existing - /// file. The file is opened read-write so the same handle can - /// later be reused for verification reads if needed (sweep - /// doesn't, but it costs nothing here). - pub fn create(path: &Path) -> std::io::Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(true) - .open(path)?; - Ok(Self { - inner: crate::io::WritebackFile::new(file)?, - }) - } - - /// Open an existing ISO file for in-place updates (e.g. patch - /// pass writing recovered sectors over zero-filled holes). - /// Does not truncate. - pub fn open(path: &Path) -> std::io::Result { - let file = OpenOptions::new().read(true).write(true).open(path)?; - Ok(Self { - inner: crate::io::WritebackFile::new(file)?, - }) - } -} - -impl SectorSink for FileSectorSink { - fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()> { - // SectorSink's contract requires a 2048-multiple buffer. Enforce - // it in all build modes (a `debug_assert!` is a no-op in release): - // a misaligned buffer would `write_all` partial bytes at - // lba*2048 and silently corrupt the ISO. Current in-tree callers - // always pass aligned buffers; this guards the public trait - // contract against any (including future external) caller. - if buf.len() % 2048 != 0 { - return Err(Error::IoError { - source: std::io::Error::from(std::io::ErrorKind::InvalidInput), - }); - } - let offset = lba as u64 * 2048; - self.inner - .seek(SeekFrom::Start(offset)) - .map_err(|e| Error::IoError { source: e })?; - self.inner - .write_all(buf) - .map_err(|e| Error::IoError { source: e })?; - Ok(()) - } - - fn finish(mut self: Box) -> Result<()> { - self.inner - .sync_all() - .map_err(|e| Error::IoError { source: e })?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::FileSectorSink; - use crate::io::file_sector_source::FileSectorSource; - use crate::sector::{SectorSink, SectorSource}; - use tempfile::tempdir; - - #[test] - fn round_trip_single_sector() { - let dir = tempdir().unwrap(); - let path = dir.path().join("rt.iso"); - - let mut sink = FileSectorSink::create(&path).unwrap(); - // Pre-extend the file to 4 sectors of zeros so we can write - // sector 2 in place. Easiest way: write zeros first. - let zeros = [0u8; 4 * 2048]; - sink.write_sectors(0, &zeros).unwrap(); - - let mut payload = [0u8; 2048]; - for (i, b) in payload.iter_mut().enumerate() { - *b = (i as u8).wrapping_mul(17); - } - sink.write_sectors(2, &payload).unwrap(); - Box::new(sink).finish().unwrap(); - - let mut src = FileSectorSource::open(&path).unwrap(); - assert_eq!(src.capacity_sectors(), 4); - - let mut got = [0u8; 2048]; - let n = src.read_sectors(2, 1, &mut got, false).unwrap(); - assert_eq!(n, 2048); - assert_eq!(got, payload); - - // Sectors 0,1,3 still zero. - let mut z = [0xffu8; 2048]; - src.read_sectors(0, 1, &mut z, false).unwrap(); - assert!(z.iter().all(|b| *b == 0)); - } - - #[test] - fn round_trip_multi_sector() { - let dir = tempdir().unwrap(); - let path = dir.path().join("multi.iso"); - - let mut sink = FileSectorSink::create(&path).unwrap(); - let mut payload = vec![0u8; 8 * 2048]; - for (i, b) in payload.iter_mut().enumerate() { - *b = ((i * 31) ^ (i >> 7)) as u8; - } - sink.write_sectors(0, &payload).unwrap(); - Box::new(sink).finish().unwrap(); - - let mut src = FileSectorSource::open(&path).unwrap(); - assert_eq!(src.capacity_sectors(), 8); - - let mut got = vec![0u8; 8 * 2048]; - let n = src.read_sectors(0, 8, &mut got, false).unwrap(); - assert_eq!(n, 8 * 2048); - assert_eq!(got, payload); - } - - #[test] - fn open_existing_does_not_truncate() { - let dir = tempdir().unwrap(); - let path = dir.path().join("open.iso"); - - // Create with 4 sectors of pattern A. - let mut sink = FileSectorSink::create(&path).unwrap(); - let pat_a = [0xaau8; 4 * 2048]; - sink.write_sectors(0, &pat_a).unwrap(); - Box::new(sink).finish().unwrap(); - - // Reopen and overwrite sector 1 only. - let mut sink = FileSectorSink::open(&path).unwrap(); - let pat_b = [0xbbu8; 2048]; - sink.write_sectors(1, &pat_b).unwrap(); - Box::new(sink).finish().unwrap(); - - let mut src = FileSectorSource::open(&path).unwrap(); - assert_eq!(src.capacity_sectors(), 4); - let mut got = [0u8; 2048]; - - src.read_sectors(0, 1, &mut got, false).unwrap(); - assert_eq!(got, [0xaau8; 2048]); - - src.read_sectors(1, 1, &mut got, false).unwrap(); - assert_eq!(got, [0xbbu8; 2048]); - - src.read_sectors(2, 1, &mut got, false).unwrap(); - assert_eq!(got, [0xaau8; 2048]); - } -} diff --git a/src/sector/mod.rs b/src/sector/mod.rs index fee750f..7d4ef5f 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -1,20 +1,14 @@ -//! Sector-level I/O traits. +//! Sector-level read I/O traits. //! -//! The sector layer is direction-typed: [`SectorSource`] reads -//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls -//! never do both — physical drives are read-only, file-backed -//! ISO images are opened for read OR write at construction time. +//! [`SectorSource`] reads 2048-byte sectors from a disc. //! //! - [`SectorSource`] is implemented by `Drive` (hardware) and //! [`FileSectorSource`] (file-backed). -//! - [`SectorSink`] is implemented by [`FileSectorSink`] -//! (ISO-backed). //! - [`DecryptingSectorSource`] is a decorator that wraps any //! `SectorSource` and applies AACS / CSS in-place decrypt to //! yield plaintext sectors. pub mod decrypting; -pub mod file; pub mod prefetched; use crate::error::Result; @@ -169,27 +163,8 @@ impl SectorSource for &mut (dyn SectorSource + '_) { } } -/// Write 2048-byte sectors to a disc image or composed sink. -/// -/// The terminal [`finish`] takes `Box` so it can run on `dyn -/// SectorSink` and consume the sink (`fsync` + close). -/// -/// [`finish`]: SectorSink::finish -pub trait SectorSink: Send { - /// Write the sectors in `buf` starting at `lba`. `buf.len()` - /// must be a multiple of 2048; the implementation seeks to - /// `lba as u64 * 2048` before writing (the `u64` cast is required — - /// a bare `u32` `lba * 2048` wraps past ~4 GB on UHD-scale images). - fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()>; - - /// Flush, fsync, and close. Consumes the sink. Always called - /// last; subsequent operations are not defined. - fn finish(self: Box) -> Result<()>; -} - pub use crate::io::file_sector_source::FileSectorSource; pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn}; -pub use file::FileSectorSink; pub use prefetched::PrefetchedSectorSource; #[cfg(test)] diff --git a/src/speed.rs b/src/speed.rs deleted file mode 100644 index 3489c71..0000000 --- a/src/speed.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Drive speed constants. - -/// Common optical drive speeds with KB/s values for SET_CD_SPEED. -/// -/// Ordering is by [`to_kbps`](Self::to_kbps) throughput, not declaration -/// order — `PartialOrd`/`Ord` are implemented manually so e.g. -/// `DVD1x < BD1x` (1385 < 4500 KB/s) holds. A naive derive would have -/// ordered by variant position, making the slow DVD speeds sort above the -/// fast BD speeds. `Max` (0xFFFF) sorts highest, as intended. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DriveSpeed { - BD1x, - BD2x, - BD4x, - BD6x, - BD8x, - BD10x, - BD12x, - DVD1x, - DVD2x, - DVD4x, - DVD8x, - DVD16x, - Max, -} - -impl DriveSpeed { - /// Throughput in KB/s for the SET_CD_SPEED CDB. `Max` maps to the - /// 0xFFFF sentinel that tells the drive to use its maximum speed. - pub fn to_kbps(self) -> u16 { - match self { - DriveSpeed::BD1x => 4_500, - DriveSpeed::BD2x => 9_000, - DriveSpeed::BD4x => 18_000, - DriveSpeed::BD6x => 27_000, - DriveSpeed::BD8x => 36_000, - DriveSpeed::BD10x => 45_000, - DriveSpeed::BD12x => 54_000, - DriveSpeed::DVD1x => 1_385, - DriveSpeed::DVD2x => 2_770, - DriveSpeed::DVD4x => 5_540, - DriveSpeed::DVD8x => 11_080, - DriveSpeed::DVD16x => 22_160, - DriveSpeed::Max => 0xFFFF, - } - } -} - -impl PartialOrd for DriveSpeed { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for DriveSpeed { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.to_kbps().cmp(&other.to_kbps()) - } -} - -impl std::fmt::Display for DriveSpeed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // `Max` is the "let the drive pick its maximum" sentinel; printing - // its 0xFFFF KB/s value would read as a real (absurd) throughput. - match self { - DriveSpeed::Max => write!(f, "Max"), - _ => write!(f, "{:?} ({} KB/s)", self, self.to_kbps()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ordering_is_by_throughput_not_declaration() { - assert!(DriveSpeed::DVD1x < DriveSpeed::BD1x); - assert!(DriveSpeed::DVD16x < DriveSpeed::BD8x); - assert!(DriveSpeed::BD12x < DriveSpeed::Max); - let mut v = [DriveSpeed::Max, DriveSpeed::DVD1x, DriveSpeed::BD4x]; - v.sort(); - assert_eq!(v, [DriveSpeed::DVD1x, DriveSpeed::BD4x, DriveSpeed::Max]); - } - - #[test] - fn max_display_omits_sentinel_value() { - assert_eq!(DriveSpeed::Max.to_string(), "Max"); - assert!(DriveSpeed::BD1x.to_string().contains("4500 KB/s")); - } -} diff --git a/tests/integration_progress_and_halt.rs b/tests/integration_progress_and_halt.rs deleted file mode 100644 index f2bf568..0000000 --- a/tests/integration_progress_and_halt.rs +++ /dev/null @@ -1,844 +0,0 @@ -//! Integration tests for progress reporting, halt behavior, drop safety, -//! and the file-backed sector reader round trip. - -use libfreemkv::disc::{CopyOptions, DiscRegion}; -use libfreemkv::error::Result; -use libfreemkv::pes::Stream as PesStream; -use libfreemkv::{ - ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource, - SectorSource, -}; -use std::io::Write; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -const SECTOR_SIZE: usize = 2048; - -// ── helpers ──────────────────────────────────────────────────────────────── - -/// Returns zeroed sectors. Always succeeds. Counts each call. -struct ZeroSectorReader { - capacity: u32, - calls: Arc, -} - -impl ZeroSectorReader { - fn new(capacity: u32) -> Self { - Self { - capacity, - calls: Arc::new(AtomicU64::new(0)), - } - } -} - -impl SectorSource for ZeroSectorReader { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - self.calls.fetch_add(1, Ordering::Relaxed); - let bytes = count as usize * SECTOR_SIZE; - buf[..bytes].fill(0); - Ok(bytes) - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -/// Like ZeroSectorReader but sleeps a configurable duration per call. -/// Used by the halt test so the copy takes >1 s. -struct SlowZeroSectorReader { - capacity: u32, - sleep_per_call: Duration, -} - -impl SlowZeroSectorReader { - fn new(capacity: u32, sleep_per_call: Duration) -> Self { - Self { - capacity, - sleep_per_call, - } - } -} - -impl SectorSource for SlowZeroSectorReader { - fn read_sectors( - &mut self, - _lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - std::thread::sleep(self.sleep_per_call); - let bytes = count as usize * SECTOR_SIZE; - buf[..bytes].fill(0); - Ok(bytes) - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -/// Build a Disc instance with a known capacity, no titles, no encryption. -/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys). -fn synthetic_disc(capacity_sectors: u32) -> Disc { - Disc { - volume_id: String::new(), - meta_title: None, - format: DiscFormat::BluRay, - capacity_sectors, - capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64, - layers: 1, - titles: Vec::new(), - region: DiscRegion::Free, - aacs: None, - css: None, - encrypted: false, - aacs_error: None, - css_error: None, - content_format: ContentFormat::BdTs, - } -} - -/// Build a DiscTitle with a single extent of `sector_count` sectors and no -/// streams (DiscStream still iterates sectors and would emit BytesRead). -fn synthetic_title(sector_count: u32) -> DiscTitle { - DiscTitle { - playlist: String::new(), - playlist_id: 0, - duration_secs: 0.0, - size_bytes: sector_count as u64 * SECTOR_SIZE as u64, - clips: Vec::new(), - streams: Vec::new(), - chapters: Vec::new(), - extents: vec![Extent { - start_lba: 0, - sector_count, - }], - content_format: ContentFormat::BdTs, - codec_privates: Vec::new(), - } -} - -// ── 1. BytesRead events emitted during disc copy ────────────────────────── - -#[test] -fn test_bytes_read_emitted_during_disc_copy() { - // Build a tiny synthetic disc and stream it through DiscStream. - let reader = ZeroSectorReader::new(64); - let title = synthetic_title(64); - let keys = libfreemkv::DecryptKeys::None; - - let mut stream = DiscStream::new( - Box::new(reader), - title, - keys, - 60, - ContentFormat::BdTs, - false, - None, - ) - .unwrap(); - - let count = Arc::new(AtomicU64::new(0)); - let count_cb = count.clone(); - stream.on_event(move |ev| { - if let EventKind::BytesRead { .. } = ev.kind { - count_cb.fetch_add(1, Ordering::Relaxed); - } - }); - - // Drive the stream to EOF. With no streams configured, read() returns - // Ok(None) once all extents are exhausted. - loop { - match stream.read() { - Ok(Some(_frame)) => {} - Ok(None) => break, - Err(e) => panic!("stream read failed: {e:?}"), - } - } - - let n = count.load(Ordering::Relaxed); - assert!( - n > 0, - "expected at least one BytesRead event during disc copy, got {n}" - ); -} - -// ── 2. Disc::copy on_progress callback fires (regression guard) ─────────── - -#[test] -fn test_disc_copy_progress_callback_fires() { - let disc = synthetic_disc(64); - let mut reader = ZeroSectorReader::new(64); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); // we want the path, not the file handle - - let calls = Arc::new(AtomicU64::new(0)); - let last_bytes = Arc::new(AtomicU64::new(0)); - - struct CountingReporter { - calls: Arc, - last_bytes: Arc, - } - impl libfreemkv::progress::Progress for CountingReporter { - fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool { - self.calls.fetch_add(1, Ordering::Relaxed); - self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed); - true - } - } - let reporter = CountingReporter { - calls: calls.clone(), - last_bytes: last_bytes.clone(), - }; - - let opts = CopyOptions { - decrypt: false, - progress: Some(&reporter), - ..Default::default() - }; - - let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok"); - - // Cleanup any sidecar mapfile + ISO before assertions. - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - assert!(result.complete, "copy should be complete"); - let n = calls.load(Ordering::Relaxed); - let last = last_bytes.load(Ordering::Relaxed); - assert!(n > 0, "on_progress should fire at least once, got {n}"); - assert!( - last > 0, - "final progress bytes should be non-zero, got {last}" - ); -} - -// ── 3. Halt aborts disc copy promptly ───────────────────────────────────── - -#[test] -fn test_halt_aborts_disc_copy_promptly() { - // 6000 sectors, 60-sector batches → 100 read_sectors() calls. - // 10 ms sleep per call → ~1 s total without halt. - let capacity_sectors: u32 = 6000; - let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10)); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let halt = Arc::new(AtomicBool::new(false)); - let halt_for_thread = halt.clone(); - let iso_path_for_thread = iso_path.clone(); - - let join = std::thread::spawn(move || { - let opts = CopyOptions { - decrypt: false, - halt: Some(halt_for_thread), - ..Default::default() - }; - let t0 = Instant::now(); - let res = disc.copy(&mut reader, &iso_path_for_thread, &opts); - (res, t0.elapsed()) - }); - - // Let copy run, then halt. - std::thread::sleep(Duration::from_millis(200)); - halt.store(true, Ordering::Relaxed); - - // Bound the join: should exit far before the full 1 s otherwise needed. - let started = Instant::now(); - let mut joined = None; - while started.elapsed() < Duration::from_millis(2000) { - if join.is_finished() { - joined = Some(join.join().expect("thread join")); - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt"); - - // Cleanup - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - let copy_result = result.expect("copy returns Ok with halted=true on halt"); - assert!( - copy_result.halted, - "copy_result.halted should be true after halt" - ); - assert!( - !copy_result.complete, - "copy_result.complete should be false when halted" - ); - assert!( - elapsed < Duration::from_millis(2000), - "copy thread exit elapsed {elapsed:?} exceeded 2s" - ); -} - -// ── 4. DiscStream Drop does not panic or block ──────────────────────────── - -#[test] -fn test_drop_impls_do_not_panic_or_block() { - let reader = ZeroSectorReader::new(64); - let title = synthetic_title(64); - let keys = libfreemkv::DecryptKeys::None; - let stream = DiscStream::new( - Box::new(reader), - title, - keys, - 60, - ContentFormat::BdTs, - false, - None, - ) - .unwrap(); - - // Drop on a worker thread; main thread enforces the timeout. - let handle = std::thread::spawn(move || { - drop(stream); - }); - - let started = Instant::now(); - while started.elapsed() < Duration::from_millis(100) { - if handle.is_finished() { - handle.join().expect("drop thread join"); - return; - } - std::thread::sleep(Duration::from_millis(5)); - } - panic!("DiscStream drop did not complete within 100ms"); -} - -// ── 5. FileSectorSource round trip ──────────────────────────────────────── - -#[test] -fn test_file_sector_reader_round_trip() { - // Build 8 sectors of pseudo-random bytes (sector-aligned). - const N_SECTORS: usize = 8; - let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE]; - for (i, b) in data.iter_mut().enumerate() { - // Cheap PRNG: just a multiplicative pattern, deterministic for asserts. - *b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8; - } - - let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - tmp.write_all(&data).expect("write data"); - tmp.flush().expect("flush"); - - let path = tmp.path().to_path_buf(); - let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource"); - - assert_eq!( - fsr.capacity_sectors(), - N_SECTORS as u32, - "capacity mismatch" - ); - - // Read each sector individually and compare. - let mut buf = vec![0u8; SECTOR_SIZE]; - for lba in 0..N_SECTORS as u32 { - let n = fsr - .read_sectors(lba, 1, &mut buf, false) - .expect("read_sectors"); - assert_eq!(n, SECTOR_SIZE); - let off = lba as usize * SECTOR_SIZE; - assert_eq!( - &buf[..], - &data[off..off + SECTOR_SIZE], - "sector {lba} mismatch" - ); - } - - // Read all sectors at once and compare. - let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE]; - let n = fsr - .read_sectors(0, N_SECTORS as u16, &mut all, false) - .expect("read all sectors"); - assert_eq!(n, N_SECTORS * SECTOR_SIZE); - assert_eq!(all, data, "bulk read mismatch"); -} - -// ── 6. Pass 1 sweeps the entire disc even when every read fails ─────────── -// -// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc -// regardless of how many reads fail. The only legitimate early exit is the -// halt flag. With `skip_on_error` and a reader that returns -// Err for every read, Pass 1 must: -// - mark every sector NonTrimmed (so Pass 2 can retry them) -// - return cleanly (no panic, no hang) -// - bytes_good = 0 -// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile -// accounting; see disc/mapfile.rs::stats) -// - bytes_unreadable = 0 (only Pass 2 marks Unreadable) -// - complete = false (work remains for Pass 2) -// - halted = false (no user stop) -// - ISO file is `total_bytes` size on disk (sparse zeros) - -/// Reader that returns Err for every read. Optionally signals a halt -/// flag on the first read so tests can exercise the halt-during-skip-forward -/// path deterministically (no wallclock dependency). -struct FailingSectorReader { - capacity: u32, - /// If set, signals halt on the first `read_sectors` call. Cleared after - /// the first signal so subsequent reads are plain Err. - halt_on_first_read: Option>, -} - -impl FailingSectorReader { - fn new(capacity: u32) -> Self { - Self { - capacity, - halt_on_first_read: None, - } - } - - fn with_halt_on_first_read(capacity: u32, halt: Arc) -> Self { - Self { - capacity, - halt_on_first_read: Some(halt), - } - } -} - -impl SectorSource for FailingSectorReader { - fn read_sectors( - &mut self, - _lba: u32, - _count: u16, - _buf: &mut [u8], - _recovery: bool, - ) -> Result { - if let Some(h) = self.halt_on_first_read.take() { - h.store(true, Ordering::Relaxed); - } - // Model what a real damaged-disc read returns: CHECK CONDITION + - // MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR, - // ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must - // engage on this — `Error::DiscRead` is libfreemkv's own - // post-classification signal, not what a real reader emits. - Err(libfreemkv::error::Error::ScsiError { - opcode: libfreemkv::scsi::SCSI_READ_10, - status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION, - sense: Some(libfreemkv::ScsiSense { - sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x05, - }), - }) - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -#[test] -fn test_disc_copy_completes_full_disc_with_failing_reader() { - // 1024 sectors = 2 MB. Reader fails every read. With skip_on_error + - // skip_on_error, Pass 1 must mark every sector NonTrimmed and return - // cleanly — no bail, no hang. - let capacity_sectors: u32 = 1024; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - let mut reader = FailingSectorReader::new(capacity_sectors); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - - ..Default::default() - }; - - let t0 = Instant::now(); - let result = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok"); - let elapsed = t0.elapsed(); - - // Cleanup - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - // Hard bound — Pass 1 must NOT infinite-loop on a fully-failing - // reader. The threshold accommodates the 2026-05-10 wedge- - // avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed - // batch). With batch=32 and 1024 sectors that's up to ~5 batch - // failures + a few damage-jump pauses before fast-trigger jumps - // us past end-of-disc — well-bounded total, ~20-30 s typical. - // The point of this test is "finishes cleanly, not infinitely", - // not "completes in milliseconds." - assert!( - elapsed < Duration::from_secs(60), - "Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)" - ); - - // Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of - // read outcomes. - assert_eq!( - result.bytes_total, total_bytes, - "bytes_total must match disc capacity" - ); - assert_eq!( - result.bytes_good, 0, - "no reads succeeded, bytes_good must be 0" - ); - assert_eq!( - result.bytes_unreadable, 0, - "Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does" - ); - assert_eq!( - result.bytes_pending, total_bytes, - "every sector must be NonTrimmed → counted as pending. \ - Got bytes_pending={} of total {}", - result.bytes_pending, total_bytes - ); - assert!( - !result.complete, - "complete=false because NonTrimmed regions remain (work for Pass 2)" - ); - assert!(!result.halted, "no halt was set; halted must be false"); - - // ISO file should be the full disc size on disk (sparse zeros where - // reads failed). - // Note: tempfile was dropped above; the file may or may not still exist - // depending on cleanup ordering. We only assert what we can observe in - // the CopyResult. -} - -// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─ -// -// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1. -// Even when every read is failing (skip-forward path), a halt must be -// honored within a small bounded time. -// -// Deterministic fixture: the reader signals halt on its FIRST read. The -// inner copy loop's halt check fires on the next iteration, breaking out -// of 'outer. This avoids any wallclock race on fast CI runners (where a -// 2 GB synthetic disc can sweep skip-forward in <100 ms). - -#[test] -fn test_disc_copy_halts_promptly_on_failing_reader() { - let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc - - let halt = Arc::new(AtomicBool::new(false)); - let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone()); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - - halt: Some(halt), - ..Default::default() - }; - - let t0 = Instant::now(); - let result = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok on halt"); - let elapsed = t0.elapsed(); - - // Cleanup - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - assert!( - elapsed < Duration::from_secs(2), - "halt must return within 2 s; took {elapsed:?}" - ); - assert!(result.halted, "result.halted must be true"); - assert!( - !result.complete, - "halted run cannot be complete (bytes_pending > 0 expected)" - ); - assert!( - result.bytes_pending > 0, - "halt fired before sweep completed; bytes_pending must be > 0" - ); -} - -// ── 8. Hysteresis recovers data the drive can read individually ────────── -// -// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked -// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every -// multi-sector read fails produces all NonTrimmed output with zero bytes_good. - -struct BlockSizeFailingReader { - capacity: u32, -} - -impl SectorSource for BlockSizeFailingReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - if count == 1 { - for chunk in buf.chunks_mut(SECTOR_SIZE) { - chunk.fill((lba & 0xff) as u8); - } - Ok(buf.len()) - } else { - Err(libfreemkv::error::Error::ScsiError { - opcode: libfreemkv::scsi::SCSI_READ_10, - status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION, - sense: Some(libfreemkv::ScsiSense { - sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }), - }) - } - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -#[test] -fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() { - let capacity_sectors: u32 = 256; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - let mut reader = BlockSizeFailingReader { - capacity: capacity_sectors, - }; - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - - ..Default::default() - }; - - let result = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok"); - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - // Pass 1's job is "fast and accurate, get the most data in the - // shortest time." It no longer bisects on marginal media — that's - // Pass N's purpose-built role. So a BlockSizeFailingReader that - // fails on multi-sector reads and succeeds on single-sector - // results in: every batch fails → SkipBlock → whole 32-sector - // ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and - // recovers via single-sector reads with proper recovery semantics. - // - // Pass 1 alone: - assert_eq!( - result.bytes_good, 0, - "Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit" - ); - assert_eq!( - result.bytes_pending, total_bytes, - "every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N" - ); - assert!( - !result.complete, - "complete=false because NonTrimmed regions remain (Pass N's work)" - ); -} - -// ── 9. PassProgress carries separate unreadable vs pending byte counts ───── -// -// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass — -// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator -// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry -// pass completes. This test was rewritten from its pre-design-call shape (which -// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant: -// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes -// get more shots at them. - -#[test] -fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() { - let capacity_sectors: u32 = 128; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - let mut reader = FailingSectorReader::new(capacity_sectors); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().expect("tempfile create"); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - ..Default::default() - }; - - let pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok"); - - assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors"); - assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable"); - assert_eq!( - pass1.bytes_pending, total_bytes, - "pass1: all sectors NonTrimmed" - ); - - let last_unreadable = Arc::new(AtomicU64::new(0)); - let last_pending = Arc::new(AtomicU64::new(0)); - let last_good = Arc::new(AtomicU64::new(0)); - let last_dur = Arc::new(AtomicU64::new(0)); - - struct SnapshotReporter { - unreadable: Arc, - pending: Arc, - good: Arc, - dur: Arc, - } - impl libfreemkv::progress::Progress for SnapshotReporter { - fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool { - self.unreadable - .store(p.bytes_unreadable_total, Ordering::Relaxed); - self.pending.store(p.bytes_pending_total, Ordering::Relaxed); - self.good.store(p.bytes_good_total, Ordering::Relaxed); - if let Some(d) = p.disc_duration_secs { - self.dur.store((d * 1000.0) as u64, Ordering::Relaxed); - } - true - } - } - let reporter = SnapshotReporter { - unreadable: last_unreadable.clone(), - pending: last_pending.clone(), - good: last_good.clone(), - dur: last_dur.clone(), - }; - - let pass2_opts = CopyOptions { - decrypt: false, - multipass: true, - progress: Some(&reporter), - ..Default::default() - }; - - let pass2 = disc - .copy(&mut reader, &iso_path, &pass2_opts) - .expect("pass2 ok"); - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path)); - - assert_eq!( - pass2.bytes_good, 0, - "pass2: still no good sectors (reader always fails)" - ); - // 2026-05-11 design: pass-level retries do NOT promote failed bytes - // to Unreadable. Failed bytes stay NonTrimmed (pending) so a later - // pass can retry. End-of-recovery promotion is an orchestrator - // concern (autorip), not the patch loop's. - assert_eq!( - pass2.bytes_unreadable, 0, - "pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass" - ); - // bytes_pending stays at total_bytes because everything still - // failed and nothing got recovered or promoted out of pending. - assert_eq!( - pass2.bytes_pending, total_bytes, - "pass2: failed bytes remain NonTrimmed for the next pass to retry" - ); - - let observed_unreadable = last_unreadable.load(Ordering::Relaxed); - let observed_pending = last_pending.load(Ordering::Relaxed); - assert_eq!( - observed_unreadable, 0, - "progress should report zero confirmed-unreadable mid-pass under the new design" - ); - assert!( - observed_pending > 0, - "progress should report pending bytes as the reader keeps failing" - ); - - // Video damage time: unreadable / total * duration - // With no titles on synthetic disc, disc_duration_secs = None - assert_eq!( - last_dur.load(Ordering::Relaxed), - 0, - "synthetic disc has no titles, duration should be None/0" - ); -} - -// ── 10. Damage time calculation (unit test) ──────────────────────────────── -// -// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration -// This mirrors the CLI's print_disc_progress logic. - -#[test] -fn test_damage_time_calculation() { - // 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable - let disc_bytes: u64 = 78_800_000_000; - let duration_secs: f64 = 9900.0; - - let cases: Vec<(u64, &str)> = vec![ - (74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible - (10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s - (100 * 1024 * 1024, "~13s"), // 100 MB → ~13s - (1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s - ]; - - for (bad_bytes, label) in cases { - let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs; - match label { - "~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"), - "~1.3s" => assert!( - (damage_secs - 1.3).abs() < 0.2, - "{label}: {damage_secs:.2}s" - ), - "~13s" => assert!( - (damage_secs - 13.0).abs() < 1.0, - "{label}: {damage_secs:.1}s" - ), - "~134s" => assert!( - (damage_secs - 134.0).abs() < 2.0, - "{label}: {damage_secs:.0}s" - ), - _ => {} - } - } - - // 0.25s threshold: how many bad bytes = 0.25s of damage? - let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64; - assert!( - threshold_bytes > 0, - "0.25s damage threshold should be > 0 bytes" - ); - // At 9900s / 78.8 GB ≈ 0.25s = ~2 MB - let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0); - assert!( - (expected_mb - 2.0).abs() < 0.5, - "0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)" - ); -} diff --git a/tests/pass_n_size_aware_skip.rs b/tests/pass_n_size_aware_skip.rs deleted file mode 100644 index 549dd37..0000000 --- a/tests/pass_n_size_aware_skip.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! Pass N (Disc::patch) size-aware-skip targeted tests. -//! -//! The user's failure mode (2026-05-07): "what if we have a 100 sector zone -//! and its really 2 25 sector zones and we keep jumping over the good in -//! the middle." Today's pre-fix patch escalates skip-distance based on -//! `consecutive_skips_without_recovery` with hardcoded 32 → 4096 sector -//! caps. A 100-sector bad range whose actual layout is 25 bad + 50 good + -//! 25 bad would have the patch skip 32-4096 sectors after a couple of -//! failures, leaping over the entire range AND the good middle. -//! -//! The fix: cap each skip at `range_remaining/4`. These tests exercise -//! that boundary. - -use libfreemkv::disc::CopyOptions; -use libfreemkv::disc::DiscRegion; -use libfreemkv::disc::PatchOptions; -use libfreemkv::disc::mapfile::{Mapfile, SectorStatus}; -use libfreemkv::error::Result; -use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource}; -use std::collections::HashSet; -use std::sync::{Arc, Mutex}; - -const SECTOR_SIZE: usize = 2048; - -/// Reader where you specify exactly which LBAs return Err. Everything else -/// returns Ok with the LBA encoded in each byte for verification. -struct PatternedSectorReader { - capacity: u32, - bad_lbas: HashSet, - /// Trace every read so tests can assert what was actually attempted. - trace: Arc>>, -} - -type ReadTrace = Arc>>; - -impl PatternedSectorReader { - fn new(capacity: u32, bad_lbas: HashSet) -> (Self, ReadTrace) { - let trace = Arc::new(Mutex::new(Vec::new())); - ( - Self { - capacity, - bad_lbas, - trace: trace.clone(), - }, - trace, - ) - } -} - -impl SectorSource for PatternedSectorReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - self.trace.lock().unwrap().push((lba, count)); - // Whole-batch fails if ANY sector in the batch is bad. (Models a - // real drive: a multi-sector READ aborts on the first ECC failure.) - for offset in 0..count as u32 { - if self.bad_lbas.contains(&(lba + offset)) { - return Err(libfreemkv::error::Error::ScsiError { - opcode: libfreemkv::scsi::SCSI_READ_10, - status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION, - sense: Some(libfreemkv::ScsiSense { - sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }), - }); - } - } - // Fill each sector with ITS OWN LBA byte, not the starting LBA's - // byte. This matches real drive behavior: a multi-sector READ - // returns per-sector-correct data. Pre-0.18.13 only single-sector - // reads were exercised by patch tests, so the cheaper "fill the - // whole batch with one byte" worked; adaptive batching needs the - // per-sector pattern to verify correct positioning. - for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() { - chunk.fill(((lba + i as u32) & 0xff) as u8); - } - Ok(buf.len()) - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -fn synthetic_disc(capacity_sectors: u32) -> Disc { - Disc { - volume_id: String::new(), - meta_title: None, - format: DiscFormat::BluRay, - capacity_sectors, - capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64, - layers: 1, - titles: Vec::new(), - region: DiscRegion::Free, - aacs: None, - css: None, - encrypted: false, - aacs_error: None, - css_error: None, - content_format: ContentFormat::BdTs, - } -} - -/// Pre-populate a mapfile with one large NonTrimmed range so patch's work- -/// list has something to do. Caller pre-allocates the ISO at `total_bytes` -/// so seeks don't fail. -fn prep_iso_and_mapfile( - iso_path: &std::path::Path, - total_bytes: u64, - finished_ranges: &[(u64, u64)], - nontrimmed_ranges: &[(u64, u64)], -) { - use std::fs::OpenOptions; - use std::io::{Seek, SeekFrom, Write}; - let mut f = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(iso_path) - .unwrap(); - f.set_len(total_bytes).unwrap(); - f.seek(SeekFrom::Start(0)).unwrap(); - f.write_all(&[]).unwrap(); - - let map_path = libfreemkv::disc::mapfile_path_for(iso_path); - let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap(); - for &(pos, size) in finished_ranges { - mf.record(pos, size, SectorStatus::Finished).unwrap(); - } - for &(pos, size) in nontrimmed_ranges { - mf.record(pos, size, SectorStatus::NonTrimmed).unwrap(); - } -} - -/// THE critical test. A 100-sector "bad" range hides 50 good sectors in -/// the middle (LBAs 125-174). Pre-fix patch would skip-escalate at 32+ -/// sectors and leap over the whole range. Post-fix: skip is capped at -/// range_remaining/4 (=25 sectors initially), which forces convergence. -#[test] -fn patch_recovers_good_middle_of_a_bad_range() { - let capacity_sectors: u32 = 1024; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - // Bad range layout: LBAs 100-124 bad, 125-174 GOOD, 175-199 bad. - let mut bad_lbas = HashSet::new(); - for lba in 100..125 { - bad_lbas.insert(lba); - } - for lba in 175..200 { - bad_lbas.insert(lba); - } - - let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - // Pre-populate: 0..100 already Finished from an imagined Pass 1, - // 100..200 NonTrimmed (the range we want patch to retry), - // 200..1024 already Finished. - let finished = [ - (0, 100 * 2048), - (200 * 2048, (capacity_sectors as u64 - 200) * 2048), - ]; - let nontrimmed = [(100 * 2048, 100 * 2048)]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - // Run patch. - // disc.copy() with multipass=true auto-dispatches to patch when the - // mapfile already covers the disc and has retryable ranges. - let opts = CopyOptions { - decrypt: false, - multipass: true, - ..Default::default() - }; - let pr = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok"); - - // Re-load mapfile and inspect. - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let map = Mapfile::load(&map_path).unwrap(); - - // The good middle (125..175) MUST end up Finished. If size-aware skip - // is not enabled, patch would skip 32+ sectors after a few failures - // and leap clean over LBA 125 → middle stays NonTrimmed. - let finished_ranges = map.ranges_with(&[SectorStatus::Finished]); - let total_finished_in_middle: u64 = finished_ranges - .iter() - .map(|&(pos, sz)| { - let start = pos.max(125 * 2048); - let end = (pos + sz).min(175 * 2048); - end.saturating_sub(start) - }) - .sum(); - - // Allow 2 sectors (4 KB) of boundary slop — patch's bisection may - // not converge exactly on the good/bad boundary in a single pass, - // and that's acceptable. The pre-fix behaviour would have left the - // entire good middle as NonTrimmed (~0 bytes recovered). - let good_middle_bytes: u64 = 50 * 2048; - let min_acceptable: u64 = good_middle_bytes - 2 * 2048; - - // Cleanup before assertions - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - - assert!( - total_finished_in_middle >= min_acceptable, - "size-aware skip should have discovered most of the 50 good sectors in the middle. \ - Recovered {} of {} good middle bytes (min acceptable {}). bytes_good={} bytes_total={}", - total_finished_in_middle, - good_middle_bytes, - min_acceptable, - pr.bytes_good, - pr.bytes_total, - ); -} - -/// Regression: `PatchOptions::block_sectors == Some(0)` must not -/// busy-spin. `block_sectors` is a public `Option` field; a zero -/// value would compute a zero-length read every iteration, never -/// advance `block_end`, and burn a CPU core until the per-range -/// watchdog fired (up to 30 min on a large range). The entry-point -/// `.max(1)` clamp turns Some(0) into a single-sector batch so the -/// range recovers and the call returns promptly. -#[test] -fn patch_block_sectors_zero_does_not_busy_spin() { - let capacity_sectors: u32 = 256; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - // Small NonTrimmed range that is entirely readable (no bad LBAs), so - // single-sector patch reads recover it immediately. Without the - // clamp the loop would never progress regardless of readability. - let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, HashSet::new()); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let finished = [ - (0, 100 * 2048), - (110 * 2048, (capacity_sectors as u64 - 110) * 2048), - ]; - let nontrimmed = [(100 * 2048, 10 * 2048)]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - // A halt watchdog bounds the run: the inner loop polls `halt` every - // iteration, so even a busy-spin regression breaks out within the - // window instead of hanging the test binary. With the clamp the run - // finishes long before the watchdog fires; without it the watchdog - // trips and the bytes_good assertion below fails loudly. - let halt = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let halt_for_watchdog = halt.clone(); - let watchdog = std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(20)); - halt_for_watchdog.store(true, std::sync::atomic::Ordering::Relaxed); - }); - - let opts = PatchOptions { - decrypt: false, - block_sectors: Some(0), - full_recovery: false, - reverse: false, - wedged_threshold: 0, - progress: None, - halt: Some(halt.clone()), - key_fetch: None, - }; - - let outcome = disc.patch(&mut reader, &iso_path, &opts); - // Stop the watchdog regardless of outcome. - halt.store(true, std::sync::atomic::Ordering::Relaxed); - let _ = watchdog.join(); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - - let outcome = outcome.expect("patch returns Ok"); - assert!( - !outcome.halted, - "patch with block_sectors=Some(0) must complete on its own \ - (clamped to a 1-sector batch), not be cut off by the watchdog" - ); - let bytes_good = outcome.bytes_good; - // The 10-sector NonTrimmed range was fully readable; clamped to a - // 1-sector batch it must recover. Initial good = 100 + (256-110) = - // 246 sectors; after patch the 10-sector range is also Finished. - let initial_good_sectors: u64 = 100 + (capacity_sectors as u64 - 110); - assert!( - bytes_good >= (initial_good_sectors + 10) * 2048, - "block_sectors=Some(0) clamped to 1 should recover the readable range; \ - bytes_good={bytes_good}" - ); -} - -/// A second test: a bad range that's actually 4 small bad sub-zones -/// separated by good sectors. Demonstrates the bisection behaviour -/// converges when zones are non-uniform. -#[test] -fn patch_recovers_multiple_good_middles() { - let capacity_sectors: u32 = 2048; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - // Bad pattern: 1000-1024 bad, 1025-1099 good, 1100-1124 bad, - // 1125-1199 good, 1200-1224 bad, 1225-1299 good. - let mut bad_lbas = HashSet::new(); - for lba in 1000..1025 { - bad_lbas.insert(lba); - } - for lba in 1100..1125 { - bad_lbas.insert(lba); - } - for lba in 1200..1225 { - bad_lbas.insert(lba); - } - let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let finished = [ - (0, 1000 * 2048), - (1300 * 2048, (capacity_sectors as u64 - 1300) * 2048), - ]; - let nontrimmed = [(1000 * 2048, 300 * 2048)]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - ..Default::default() - }; - let pr = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok"); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let map = Mapfile::load(&map_path).unwrap(); - let finished_ranges = map.ranges_with(&[SectorStatus::Finished]); - let recovered: u64 = finished_ranges - .iter() - .map(|&(pos, sz)| { - let start = pos.max(1000 * 2048); - let end = (pos + sz).min(1300 * 2048); - end.saturating_sub(start) - }) - .sum(); - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - - // Three good middles of 75 sectors each = 225 good sectors in the - // bad range. Total bad = 75. So we want at least most of 225 sectors - // (= 460800 bytes) to be Finished after patch. - let target = 200 * 2048; // be generous — anything over 200 sectors is convincing - assert!( - recovered >= target, - "size-aware skip should find most of the 3 good middles. \ - Recovered {} bytes; expected ≥ {}. bytes_good={} bytes_total={}", - recovered, - target, - pr.bytes_good, - pr.bytes_total, - ); -} - -/// 0.18 Pass N pipeline split: exercises the new producer/consumer -/// path end-to-end on a synthetic patterned reader. Bad range layout -/// is small (5 bad LBAs surrounded by good middle) so the producer -/// emits a mix of `Recovered` and `NonTrimmed` items and the consumer -/// thread must apply both kinds. Verifies: -/// -/// - `bytes_good` advances (good sectors flow producer→consumer→file -/// →mapfile with the data preserved). -/// - The recovered LBAs end up Finished; the bad LBAs end up NonTrimmed -/// (NOT Unreadable — promotion to Unreadable is the orchestrator's job -/// after the final pass). -/// - Bytes written at the recovered offsets match what the producer -/// read from the patterned source (proves the channel hand-off -/// didn't drop or reorder buffers, and the consumer's seek+write -/// landed at the right offsets). -#[test] -fn patch_pipeline_split_recovers_and_records_correctly() { - let capacity_sectors: u32 = 512; - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - - // Layout: LBAs 200-204 inclusive are bad (5 sectors), 205-249 good. - // The pre-existing range is LBAs 200-249 NonTrimmed (100 KB). - let mut bad_lbas = HashSet::new(); - for lba in 200..205 { - bad_lbas.insert(lba); - } - - let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas.clone()); - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - let finished = [ - (0, 200 * 2048), - (250 * 2048, (capacity_sectors as u64 - 250) * 2048), - ]; - let nontrimmed = [(200 * 2048, 50 * 2048)]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - ..Default::default() - }; - let pr = disc - .copy(&mut reader, &iso_path, &opts) - .expect("copy returns Ok"); - - // Bytes_good_total should advance — the good LBAs in the bad range - // (205-249, 45 sectors) are all reachable via per-sector retry. - // Initial bytes_good = 200 * 2048 + (512-250) * 2048 = 462 sectors. - // After patch, bytes_good should be ≥ 462 + 45 = 507 sectors worth. - let initial_good_sectors: u64 = 200 + (capacity_sectors as u64 - 250); - let min_expected_good_bytes = (initial_good_sectors + 30) * 2048; - assert!( - pr.bytes_good >= min_expected_good_bytes, - "patch should have recovered most good LBAs in the bad range via the pipeline. \ - bytes_good={} (expected ≥ {}); bytes_total={}", - pr.bytes_good, - min_expected_good_bytes, - pr.bytes_total, - ); - - // Verify the mapfile records: every good LBA is Finished, every - // bad LBA is NonTrimmed (not Finished). - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let map = Mapfile::load(&map_path).unwrap(); - let finished_ranges = map.ranges_with(&[SectorStatus::Finished]); - let in_finished = |lba: u32| -> bool { - let pos = lba as u64 * 2048; - finished_ranges - .iter() - .any(|&(p, sz)| pos >= p && pos < p + sz) - }; - - for lba in 205..250 { - assert!( - in_finished(lba), - "good LBA {lba} should be Finished after pipeline patch run" - ); - } - for lba in 200..205 { - assert!( - !in_finished(lba), - "bad LBA {lba} should NOT be Finished after pipeline patch run" - ); - } - - // Verify the consumer wrote the producer's bytes at the right - // offsets. PatternedSectorReader fills each sector with `(lba & 0xff) - // as u8` — picking LBA 220 (well inside the recovered region) gives - // a clean signature byte to check. - use std::io::{Read, Seek, SeekFrom}; - let mut iso = std::fs::File::open(&iso_path).unwrap(); - iso.seek(SeekFrom::Start(220 * 2048)).unwrap(); - let mut sector = [0u8; 2048]; - iso.read_exact(&mut sector).unwrap(); - let expected_byte = (220u32 & 0xff) as u8; - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - - assert!( - sector.iter().all(|&b| b == expected_byte), - "consumer should have written PatternedSectorReader's pattern \ - (byte {expected_byte:#x} for LBA 220) to the recovered offset; \ - got first 8 bytes = {:?}", - §or[..8] - ); -} diff --git a/tests/passn_handler_ab.rs b/tests/passn_handler_ab.rs deleted file mode 100644 index 59686b6..0000000 --- a/tests/passn_handler_ab.rs +++ /dev/null @@ -1,1072 +0,0 @@ -//! Pass-N (`Disc::patch`) read-error handler — A/B golden fixture. -//! -//! Background (2026-05-13, v0.20.8 release bundle planning): -//! -//! `libfreemkv::disc::read_error::handle_read_error` is supposed to be -//! the single source of truth for sector-read error → recovery action -//! decisions. Pass 1 sweep routes through it. Pass N patch's -//! `handle_read_failure` (in `disc/patch.rs`) does NOT — historically -//! MEDIUM_ERROR / NOT_READY get inline handling with their own thresholds -//! (`PASSN_DAMAGE_THRESHOLD_PCT=6` vs the sweep's `12`), their own -//! damage_window (state.damage_window, separate from ReadCtx.damage_window), -//! and their own skip logic (`compute_damage_skip`, which runs AFTER -//! the failure handler and has a size-aware `range_remaining/4` cap -//! that `handle_read_error::JumpAhead` does not know about). -//! -//! This file is the A/B fixture for that unification. It pins the -//! CURRENT (pre-unification) end-to-end behavior of `Disc::patch` for -//! eight canonical damage profiles against a synthetic -//! `ScriptedSectorReader`. Each profile asserts the exact observable -//! outcome — final mapfile byte counts and outer-loop counters — so any -//! attempt to refactor the failure path either preserves the goldens or -//! the test fails loudly. -//! -//! The prompt called for "exact sequence of `ReadAction` enums per -//! LBA"; that framing doesn't fit the current architecture because -//! `handle_read_failure` produces `FailureAction`, not `ReadAction`, -//! and interleaves with `compute_damage_skip` + cursor management in -//! the outer loop. The observable contract — what `Disc::patch` does -//! to the mapfile and how many reads it performs — is the equivalent -//! invariant, captured end-to-end. -//! -//! Why we expect divergence under naïve unification (see final report -//! of the 0.20.8 unification attempt): the patch loop's skip semantics -//! live in `compute_damage_skip` POST-failure-handler, with a size-aware -//! cap that `handle_read_error` knows nothing about; routing through -//! `handle_read_error` would invert that cursor flow. The fixture stays -//! checked in regardless — it documents the contract for the next -//! refactor attempt. - -use libfreemkv::ContentFormat; -use libfreemkv::Disc; -use libfreemkv::DiscFormat; -use libfreemkv::disc::CopyOptions; -use libfreemkv::disc::DiscRegion; -use libfreemkv::disc::mapfile::{Mapfile, SectorStatus}; -use libfreemkv::error::{Error, Result}; -use libfreemkv::scsi; -use libfreemkv::{ScsiSense, SectorSource}; -use std::sync::{Arc, Mutex}; - -const SECTOR_SIZE: usize = 2048; - -/// Per-attempt result the script can emit. `Ok` returns a deterministic -/// per-sector byte pattern (LBA mod 256 in each sector). `Err` returns -/// the SCSI sense triple supplied — the patch failure path inspects -/// `scsi_sense().sense_key` to classify (MEDIUM, NOT_READY, -/// HARDWARE, ILLEGAL_REQUEST, ABORTED_COMMAND). -#[derive(Debug, Clone, Copy)] -enum ScriptStep { - Ok, - Err { sense_key: u8, asc: u8, ascq: u8 }, -} - -/// A scripted reader. For each (lba, count) read attempt, picks the -/// step at `attempt_idx[lba]`, advances the index. If no script entry -/// exists for an LBA, defaults to `Ok` so we don't need to script -/// every sector of large ranges. -/// -/// "Batch fails if ANY sector in the batch is bad" — matches real -/// drive behavior (`pass_n_size_aware_skip.rs` uses the same model). -/// For batched reads we synthesize an Err with the FIRST scripted -/// failure in the batch. -struct ScriptedSectorReader { - capacity: u32, - /// Per-LBA script of (step, then next step on retry, …). When - /// retries exhaust the script, the LAST step repeats forever. - script: std::collections::HashMap>, - /// Per-LBA index into its script vec. Bumps on each read attempt - /// at that LBA. - attempt_idx: Mutex>, - /// Full read trace: every (lba, count, result_was_ok) tuple in - /// call order. Lets the test assert that adaptive-batch dropped - /// to count=1, bisection happened, etc. - trace: Arc>>, -} - -/// A `ScriptedSectorReader` plus the handle recording its `(lba, count, ok)` trace. -type ScriptedHarness = (ScriptedSectorReader, Arc>>); - -impl ScriptedSectorReader { - fn new(capacity: u32) -> ScriptedHarness { - let trace = Arc::new(Mutex::new(Vec::new())); - ( - Self { - capacity, - script: std::collections::HashMap::new(), - attempt_idx: Mutex::new(std::collections::HashMap::new()), - trace: trace.clone(), - }, - trace, - ) - } - - /// Set a single-step script for `lba`: every attempt yields `step`. - fn always(&mut self, lba: u32, step: ScriptStep) { - self.script.insert(lba, vec![step]); - } - - /// Set a multi-step script for `lba`: first attempt yields - /// `steps[0]`, second `steps[1]`, … on retry the last step repeats. - #[allow(dead_code)] - fn sequence(&mut self, lba: u32, steps: Vec) { - self.script.insert(lba, steps); - } - - fn step_for(&self, lba: u32) -> ScriptStep { - let v = match self.script.get(&lba) { - Some(v) => v, - None => return ScriptStep::Ok, - }; - let mut idx = self.attempt_idx.lock().unwrap(); - let i = idx.entry(lba).or_insert(0); - let step = v[(*i).min(v.len() - 1)]; - *i += 1; - step - } -} - -impl SectorSource for ScriptedSectorReader { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> Result { - // Look at every sector in the batch — first failure determines - // the outcome. - let mut failure: Option<(u8, u8, u8)> = None; - for offset in 0..count as u32 { - match self.step_for(lba + offset) { - ScriptStep::Ok => {} - ScriptStep::Err { - sense_key, - asc, - ascq, - } => { - failure = Some((sense_key, asc, ascq)); - break; - } - } - } - let ok = failure.is_none(); - self.trace.lock().unwrap().push((lba, count, ok)); - if let Some((sense_key, asc, ascq)) = failure { - return Err(Error::ScsiError { - opcode: scsi::SCSI_READ_10, - status: scsi::SCSI_STATUS_CHECK_CONDITION, - sense: Some(ScsiSense { - sense_key, - asc, - ascq, - }), - }); - } - // Per-sector LBA byte pattern. - for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() { - chunk.fill(((lba + i as u32) & 0xff) as u8); - } - Ok(buf.len()) - } - - fn capacity_sectors(&self) -> u32 { - self.capacity - } -} - -fn synthetic_disc(capacity_sectors: u32) -> Disc { - Disc { - volume_id: String::new(), - meta_title: None, - format: DiscFormat::BluRay, - capacity_sectors, - capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64, - layers: 1, - titles: Vec::new(), - region: DiscRegion::Free, - aacs: None, - css: None, - encrypted: false, - aacs_error: None, - css_error: None, - content_format: ContentFormat::BdTs, - } -} - -fn prep_iso_and_mapfile( - iso_path: &std::path::Path, - total_bytes: u64, - finished_ranges: &[(u64, u64)], - nontrimmed_ranges: &[(u64, u64)], -) { - use std::fs::OpenOptions; - use std::io::{Seek, SeekFrom, Write}; - let mut f = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(iso_path) - .unwrap(); - f.set_len(total_bytes).unwrap(); - f.seek(SeekFrom::Start(0)).unwrap(); - f.write_all(&[]).unwrap(); - - let map_path = libfreemkv::disc::mapfile_path_for(iso_path); - let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap(); - for &(pos, size) in finished_ranges { - mf.record(pos, size, SectorStatus::Finished).unwrap(); - } - for &(pos, size) in nontrimmed_ranges { - mf.record(pos, size, SectorStatus::NonTrimmed).unwrap(); - } -} - -/// Observable outcome of a patch run. Goldens for each profile pin -/// these exact values. -#[derive(Debug, PartialEq, Eq)] -struct Golden { - /// `bytes_good` at end of patch. - bytes_good: u64, - /// `bytes_unreadable` at end. - bytes_unreadable: u64, - /// `bytes_pending` (NonTrimmed) at end. - bytes_pending: u64, - /// Sanity bound on trace length — patch makes a finite number of - /// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus - /// retries. Asserted as an UPPER bound only (so any reduction in - /// retries via future tuning doesn't fail the test spuriously). - max_reads: usize, -} - -/// Common helper: prep ISO + mapfile, run `disc.copy(multipass)`, -/// return (PatchOutcome ↔ CopyResult, final-map stats, trace length). -fn run_profile( - profile_name: &str, - capacity_sectors: u32, - nontrimmed: &[(u64, u64)], - finished: &[(u64, u64)], - scripted: ScriptedSectorReader, - trace: Arc>>, -) -> ( - libfreemkv::disc::CopyResult, - libfreemkv::disc::mapfile::MapStats, - usize, -) { - let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; - let disc = synthetic_disc(capacity_sectors); - - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - - prep_iso_and_mapfile(&iso_path, total_bytes, finished, nontrimmed); - - let opts = CopyOptions { - decrypt: false, - multipass: true, - ..Default::default() - }; - - let mut reader = scripted; - let pr = disc - .copy(&mut reader, &iso_path, &opts) - .unwrap_or_else(|e| panic!("[{profile_name}] disc.copy returned Err: {e:?}")); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let map = Mapfile::load(&map_path).unwrap(); - let stats = map.stats(); - - let trace_len = trace.lock().unwrap().len(); - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - - (pr, stats, trace_len) -} - -// ─────────────────────────── Profile 1: CLEAN ──────────────────────────── -// -// The NonTrimmed range has zero scripted failures — every read succeeds. -// Patch should march through the range and mark it Finished. Validates -// the happy-path side of the failure-handler dispatch (it shouldn't -// fire at all). - -#[test] -fn profile_01_clean_all_recoverable() { - let capacity_sectors: u32 = 256; - let (reader, trace) = ScriptedSectorReader::new(capacity_sectors); - // No scripted errors → all reads succeed. - - let nontrimmed = [(100 * 2048, 16 * 2048)]; // 16-sector NonTrimmed range - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "01_clean", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - let expected = Golden { - bytes_good: capacity_sectors as u64 * 2048, - bytes_unreadable: 0, - bytes_pending: 0, - max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8. - }; - assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good"); - assert_eq!( - stats.bytes_unreadable, expected.bytes_unreadable, - "01_clean bytes_unreadable" - ); - assert_eq!( - stats.bytes_pending, expected.bytes_pending, - "01_clean bytes_pending" - ); - assert!(!pr.halted, "01_clean halted"); - assert!( - trace_len <= expected.max_reads, - "01_clean trace_len={trace_len} exceeds bound {}", - expected.max_reads - ); -} - -// ─────────────────────────── Profile 2: ALL MEDIUM ─────────────────────── -// -// Every LBA in the NonTrimmed range returns MEDIUM_ERROR every attempt. -// Adaptive-batch drops to count=1 on first batch failure, then each -// single-sector read fails → consecutive_failures climbs, damage_window -// fills, compute_damage_skip fires, MAX_SKIPS_PER_RANGE caps the work, -// remaining bytes stay NonTrimmed (NEVER marked Unreadable inside a -// single pass — 2026-05-11 design call). - -#[test] -fn profile_02_all_medium_error() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - for lba in 100..116 { - reader.always( - lba, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - } - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "02_all_medium", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: the 16-sector bad range stays NonTrimmed (bytes_pending). - // Pre-2026-05-11 patch would mark Unreadable here; current code - // preserves NonTrimmed so subsequent passes get another shot. - assert_eq!( - stats.bytes_good, - (capacity_sectors as u64 - 16) * 2048, - "02_all_medium bytes_good" - ); - assert_eq!( - stats.bytes_unreadable, 0, - "02_all_medium bytes_unreadable (must NOT be marked terminal in one pass)" - ); - assert_eq!( - stats.bytes_pending, - 16 * 2048, - "02_all_medium bytes_pending (NonTrimmed retained across passes)" - ); - assert!(!pr.halted, "02_all_medium halted"); - // Upper bound: every sector probed individually + a few batch-drop - // and skip-escalation attempts, PLUS scatter-recovery on each hard - // single sector (up to SCATTER_MAX_ATTEMPTS fresh tries, each a - // recalibration read + a re-read = +6 reads/sector). Still strictly - // bounded — the guard exists to catch an UNBOUNDED retry loop, which - // would be in the hundreds. - assert!( - trace_len <= 200, - "02_all_medium trace_len={trace_len} exceeds 200" - ); -} - -// ───────────────────── Profile 3: ALTERNATING GOOD/BAD ─────────────────── -// -// LBAs 100, 102, 104, ... bad; odd LBAs good. Validates that good -// sectors interleaved with bad get recovered individually after the -// adaptive split (batch-fail → count=1 → per-sector probe). - -#[test] -fn profile_03_alternating_good_bad() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - for lba in (100..116).step_by(2) { - reader.always( - lba, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - } - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "03_alternating", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: 8 good sectors interleaved should mostly be Finished; - // 8 bad stay NonTrimmed. Allow 2 sectors of slop for the actual - // bisect cursor advance — converging on alternating bad/good in - // a single pass isn't always exact at boundaries with the - // size-aware skip cap. - let good_total = stats.bytes_good; - let baseline_good = (capacity_sectors as u64 - 16) * 2048; - let middle_recovered = good_total - baseline_good; - assert!( - middle_recovered >= 6 * 2048, - "03_alternating recovered only {middle_recovered} bytes of 8 good sectors" - ); - assert!( - middle_recovered <= 9 * 2048, - "03_alternating recovered MORE than scripted good sectors: {middle_recovered}" - ); - assert_eq!(stats.bytes_unreadable, 0, "03_alternating bytes_unreadable"); - // Remaining must be NonTrimmed (pending), not lost. - assert!( - stats.bytes_pending > 0, - "03_alternating expected NonTrimmed remainder, got bytes_pending=0" - ); - assert!(!pr.halted, "03_alternating halted"); - // Efficiency guard (catches runaway, not the tier-2 roster). The 8 - // permanently-bad sectors are now additionally probed by the tier-2 - // marginal specialists (SlowSpin/FuaRetry/SlowFua/CachePrime/Oscillate/ - // SpeedSweep) before being left NonTrimmed — bounded, finite extra reads. - assert!( - trace_len <= 220, - "03_alternating trace_len={trace_len} exceeds 220" - ); -} - -// ───────────────────── Profile 4: EDGE-BAD (size-aware-skip canon) ─────── -// -// Bad at start (100..104), good middle (104..112), bad at end (112..116). -// This is the size-aware-skip canonical case. The middle good sectors -// MUST be recovered — pre-fix patch would skip-escalate across the -// whole range and miss them. - -#[test] -fn profile_04_edge_bad_good_middle() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - for lba in 100..104 { - reader.always( - lba, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - } - for lba in 112..116 { - reader.always( - lba, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - } - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "04_edge_bad", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: the 8 good middle sectors should land Finished (allowing - // 2 sectors of bisection slop at boundaries). - let middle_recovered = stats.bytes_good - (capacity_sectors as u64 - 16) * 2048; - assert!( - middle_recovered >= 6 * 2048, - "04_edge_bad recovered only {middle_recovered} bytes of 8 good middle sectors" - ); - assert_eq!(stats.bytes_unreadable, 0, "04_edge_bad bytes_unreadable"); - assert!( - stats.bytes_pending > 0, - "04_edge_bad bytes_pending expected > 0" - ); - assert!(!pr.halted, "04_edge_bad halted"); - assert!( - trace_len <= 120, - "04_edge_bad trace_len={trace_len} exceeds 120" - ); -} - -// ───────────────────── Profile 5: SINGLE BAD SECTOR ────────────────────── -// -// 1 bad sector in the middle of an otherwise good 16-sector NonTrimmed -// range. Validates the common "stochastic miss in Pass 1, easily picked -// up in Pass N" scenario. - -#[test] -fn profile_05_single_bad_sector() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - reader.always( - 108, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "05_single_bad", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: 15 of 16 sectors recovered. 1 sector stays NonTrimmed - // (NOT Unreadable — same multi-pass tolerance principle). - assert_eq!( - stats.bytes_good, - (capacity_sectors as u64 - 1) * 2048, - "05_single_bad bytes_good" - ); - assert_eq!(stats.bytes_unreadable, 0, "05_single_bad bytes_unreadable"); - assert_eq!(stats.bytes_pending, 2048, "05_single_bad bytes_pending"); - assert!(!pr.halted, "05_single_bad halted"); - assert!( - trace_len <= 80, - "05_single_bad trace_len={trace_len} exceeds 80" - ); -} - -// ───────────────────── Profile 6: DEEP PIT ─────────────────────────────── -// -// A contiguous 8-sector bad pit in the middle of a wider 24-sector -// NonTrimmed range. Tests the damage-window threshold + size-aware-skip -// converging on the actual pit boundaries instead of bailing on -// MAX_SKIPS_PER_RANGE. - -#[test] -fn profile_06_deep_pit() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - for lba in 108..116 { - reader.always( - lba, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ); - } - - // 24 sectors NonTrimmed: 100..108 good, 108..116 BAD, 116..124 good. - let nontrimmed = [(100 * 2048, 24 * 2048)]; - let finished = [ - (0, 100 * 2048), - (124 * 2048, (capacity_sectors as u64 - 124) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "06_deep_pit", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: 16 good (8 on each side of the pit) recovered, 8 bad - // stay NonTrimmed. - let recovered_in_range = stats.bytes_good - (capacity_sectors as u64 - 24) * 2048; - assert!( - recovered_in_range >= 14 * 2048, - "06_deep_pit recovered only {recovered_in_range} bytes of 16 good sectors" - ); - assert_eq!(stats.bytes_unreadable, 0, "06_deep_pit bytes_unreadable"); - assert!( - stats.bytes_pending > 0, - "06_deep_pit bytes_pending expected > 0" - ); - assert!(!pr.halted, "06_deep_pit halted"); - // Bounded as in profile 02: the deep pit's hard single sectors each get - // scatter-recovery (up to SCATTER_MAX_ATTEMPTS recalibrate + re-read - // tries) on top of the baseline probe/skip walk. Still bounded — a - // runaway loop would be in the hundreds. - assert!( - trace_len <= 180, - "06_deep_pit trace_len={trace_len} exceeds 180" - ); -} - -// ───────────────────── Profile 7: MEDIUM-THEN-GOOD ─────────────────────── -// -// First N attempts at each bad LBA fail with MEDIUM_ERROR, then succeed. -// Tests whether patch's retry semantics revisit failed sectors. Current -// patch dispatches NonTrimmed on first failure and ADVANCES the cursor -// — it does NOT retry the same LBA inside one pass for MEDIUM_ERROR -// (only NOT_READY retries in-place). So the goldens here are: bad -// sectors stay NonTrimmed in this pass (the recovery would happen in a -// subsequent pass, which this single-pass fixture does not run). - -#[test] -fn profile_07_medium_then_good() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - // Sectors 105..110: fail twice, then succeed. - for lba in 105..110 { - reader.sequence( - lba, - vec![ - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ScriptStep::Ok, - ], - ); - } - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "07_medium_then_good", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN (handler-chain engine): with the script "fail, fail, ok" per bad - // sector, each bad sector needs three reads to recover. The chain re-reads a - // sector across successive handlers — linear reverse/forward (each narrows a - // failed batch to per-sector reads) then bisect — so every sector in the - // cluster is read enough times to consume its two failing steps and reach - // the Ok step WITHIN one pass. So all 256 sectors recover here; none defer. - // (The old batch-halving loop reached only 254; the chain is strictly - // better because more handlers re-touch each sector.) - assert_eq!( - stats.bytes_good, - 256 * 2048, - "07_medium_then_good bytes_good (handler chain re-reads each sector \ - across handlers, consuming the fail,fail,ok script for the whole cluster)" - ); - assert_eq!( - stats.bytes_unreadable, 0, - "07_medium_then_good bytes_unreadable (NonTrimmed, never terminal in one pass)" - ); - assert_eq!( - stats.bytes_pending, 0, - "07_medium_then_good bytes_pending (whole cluster recovered in one pass)" - ); - assert!(!pr.halted, "07_medium_then_good halted"); - assert!( - trace_len <= 100, - "07_medium_then_good trace_len={trace_len} exceeds 100" - ); -} - -// ───────────────────── Profile 8: BATCHED-FAIL ONLY ────────────────────── -// -// LBA 108 fails on BATCH reads (any batch including it) but succeeds -// individually. Models a marginal sector that the drive can ECC-recover -// when read alone but not at multi-sector throughput. Validates that -// adaptive batch's drop-to-count=1 retries the same starting position -// and rescues the data. -// -// Implementation note: the scripted reader marks the entire batch failed -// on any failed sector. We can't easily differentiate "single vs batch" -// without bigger plumbing — so this profile uses a script that fails -// once then succeeds on retry at the same LBA, simulating "drive -// recovered after retry." - -#[test] -fn profile_08_batch_fail_singles_ok() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - // Sector 108: fail on first call (which is the batch read), succeed - // on second call (the drop-to-count=1 retry at the same position). - reader.sequence( - 108, - vec![ - ScriptStep::Err { - sense_key: scsi::SENSE_KEY_MEDIUM_ERROR, - asc: 0x11, - ascq: 0x00, - }, - ScriptStep::Ok, - ], - ); - - let nontrimmed = [(100 * 2048, 16 * 2048)]; - let finished = [ - (0, 100 * 2048), - (116 * 2048, (capacity_sectors as u64 - 116) * 2048), - ]; - - let (pr, stats, trace_len) = run_profile( - "08_batch_fail", - capacity_sectors, - &nontrimmed, - &finished, - reader, - trace, - ); - - // GOLDEN: the second attempt succeeds → all 16 sectors recovered. - assert_eq!( - stats.bytes_good, - capacity_sectors as u64 * 2048, - "08_batch_fail bytes_good — second attempt should recover" - ); - assert_eq!(stats.bytes_unreadable, 0, "08_batch_fail bytes_unreadable"); - assert_eq!(stats.bytes_pending, 0, "08_batch_fail bytes_pending"); - assert!(!pr.halted, "08_batch_fail halted"); - assert!( - trace_len <= 80, - "08_batch_fail trace_len={trace_len} exceeds 80" - ); -} - -// ───────────────────────────────────────────────────────────────────────── -// -// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover, -// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND. -// -// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile` -// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY / -// wedge cooldowns via `sleep_secs_or_halt`). The patch handler chain itself -// uses an injectable deadline clock (`Instant::now` in production) and never -// `thread::sleep`s, so these paths run at full speed with no wall-time cost — -// the earlier "sleeps aren't injectable" suppression only ever applied to the -// copy/sweep driver, not to patch. -// -// The load-bearing invariant asserted across every PERSISTENT failure sense is -// the recovery contract: a patch pass NEVER promotes a sector to Unreadable -// (the orchestrator does that only after the final pass) and NEVER silently -// drops bytes — a still-bad sector stays NonTrimmed (pending), so -// good + pending always conserves the total. Exact good/pending splits are -// left loose so wedge-skip tuning can't spuriously fail these. - -/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192) -/// range) patch pass with the given failure step and return the final map -/// stats. 256-sector synthetic disc; everything outside the range is Finished. -/// -/// TODO(coverage gap): these HARDWARE_ERROR / ILLEGAL_REQUEST cases assert only -/// the persistent-sense RECOVERY CONTRACT (never Unreadable, byte conservation, -/// dead sector stays pending) — they do NOT exercise the patch WEDGE-EXIT path. -/// A single dead sector in one range structurally cannot reach either exit: -/// `WEDGE_ABORT_THRESHOLD=16` needs 16 CONSECUTIVE wedge-family senses within a -/// range, and the `wedged_threshold=50` exit additionally needs `range_idx > 0` -/// (a prior range already processed). No test anywhere asserts -/// `PatchOutcome::wedged_exit == true`. A real wedge-exit fixture (a first -/// throwaway range, then a second range of >=16 sectors that ALL always-fail -/// with HARDWARE_ERROR, in reverse mode) is a separate, larger synthetic build; -/// left out here rather than bent into this shared single-sector helper. -fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats { - let capacity_sectors: u32 = 256; - let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors); - reader.always(130, step); - - let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; - let disc = synthetic_disc(capacity_sectors); - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - let nontrimmed = [(128 * 2048, 64 * 2048)]; - let finished = [ - (0, 128 * 2048), - (192 * 2048, (capacity_sectors as u64 - 192) * 2048), - ]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - let opts = libfreemkv::disc::PatchOptions { - decrypt: false, - block_sectors: Some(32), - full_recovery: true, - reverse: true, - wedged_threshold: 50, - progress: None, - halt: None, - key_fetch: None, - }; - disc.patch(&mut reader, &iso_path, &opts) - .expect("patch must not error on a per-sector failure sense"); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let stats = Mapfile::load(&map_path).unwrap().stats(); - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); - stats -} - -/// A persistent sense that never clears must obey the pass contract: nothing -/// Unreadable, nothing lost (good + pending == total), and at least the dead -/// sector left pending. -fn assert_persistent_sense_contract(step: ScriptStep, label: &str) { - let stats = single_dead_sector_patch_stats(step); - let total = 256u64 * 2048; - assert_eq!( - stats.bytes_unreadable, 0, - "{label}: a patch pass must NEVER mark Unreadable" - ); - assert_eq!( - stats.bytes_good + stats.bytes_pending, - total, - "{label}: conservation — no byte may be silently dropped" - ); - assert!( - stats.bytes_pending >= 2048, - "{label}: the always-dead sector must remain pending (NonTrimmed)" - ); -} - -#[test] -fn patch_persistent_hardware_error_conserves_and_never_unreadable() { - // HARDWARE_ERROR (sense_key=0x04) — wedge family. - assert_persistent_sense_contract( - ScriptStep::Err { - sense_key: 0x04, - asc: 0x11, - ascq: 0x00, - }, - "HARDWARE_ERROR", - ); -} - -#[test] -fn patch_persistent_illegal_request_conserves_and_never_unreadable() { - // ILLEGAL_REQUEST (sense_key=0x05) — wedge family. - assert_persistent_sense_contract( - ScriptStep::Err { - sense_key: 0x05, - asc: 0x21, - ascq: 0x00, - }, - "ILLEGAL_REQUEST", - ); -} - -#[test] -fn patch_persistent_aborted_command_conserves_and_never_unreadable() { - // ABORTED_COMMAND (sense_key=0x0B). - assert_persistent_sense_contract( - ScriptStep::Err { - sense_key: 0x0B, - asc: 0x00, - ascq: 0x00, - }, - "ABORTED_COMMAND", - ); -} - -#[test] -fn patch_not_ready_then_recovers_fully() { - // NOT_READY (sense_key=0x02, asc=0x04) that clears after two attempts must - // recover the sector in-pass — no residual loss, no Unreadable, no hang. - let capacity_sectors: u32 = 256; - let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors); - reader.sequence( - 130, - vec![ - ScriptStep::Err { - sense_key: 0x02, - asc: 0x04, - ascq: 0x00, - }, - ScriptStep::Err { - sense_key: 0x02, - asc: 0x04, - ascq: 0x00, - }, - ScriptStep::Ok, - ], - ); - - let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; - let disc = synthetic_disc(capacity_sectors); - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - let nontrimmed = [(128 * 2048, 64 * 2048)]; - let finished = [ - (0, 128 * 2048), - (192 * 2048, (capacity_sectors as u64 - 192) * 2048), - ]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - let opts = libfreemkv::disc::PatchOptions { - decrypt: false, - block_sectors: Some(32), - full_recovery: true, - reverse: true, - wedged_threshold: 50, - progress: None, - halt: None, - key_fetch: None, - }; - disc.patch(&mut reader, &iso_path, &opts) - .expect("patch must not error on a transient NOT_READY"); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let stats = Mapfile::load(&map_path).unwrap().stats(); - assert_eq!( - stats.bytes_unreadable, 0, - "NOT_READY recovery must not mark Unreadable" - ); - assert_eq!( - stats.bytes_pending, 0, - "a NOT_READY that clears must leave nothing pending" - ); - assert_eq!( - stats.bytes_good, - capacity_sectors as u64 * 2048, - "every sector recovers once NOT_READY clears" - ); - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); -} - -// ──────── Handler chain recovers re-readable sectors inside a bad block ──────── -// -// A bad range holds one genuinely-dead sector surrounded by readable ones. The -// handler chain's linear pass narrows a failed batch to per-sector reads, so it -// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed — -// strictly better than the old fast-capture path, which left the whole failed -// 32-block untouched. (The old `fast_capture` knob was removed: the handler -// chain supersedes it. The breadth-first "fast on all ranges, then escalate" -// ORDERING it once provided is a scheduling concern for the handler scheduler, -// tracked separately.) -// -// The load-bearing invariant is unchanged: NO data is dropped. A still-bad -// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable. - -#[test] -fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() { - let capacity_sectors: u32 = 256; - let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors); - // One bad sector at LBA 130 — inside the LOW 32-sector block of the range. - reader.always( - 130, - ScriptStep::Err { - sense_key: 3, - asc: 0x11, - ascq: 0x05, - }, - ); - - let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64; - let disc = synthetic_disc(capacity_sectors); - let tmp = tempfile::NamedTempFile::new().unwrap(); - let iso_path = tmp.path().to_path_buf(); - drop(tmp); - // 64-sector NonTrimmed range [128,192); everything else already Finished. - let nontrimmed = [(128 * 2048, 64 * 2048)]; - let finished = [ - (0, 128 * 2048), - (192 * 2048, (capacity_sectors as u64 - 192) * 2048), - ]; - prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed); - - let opts = libfreemkv::disc::PatchOptions { - decrypt: false, - block_sectors: Some(32), - full_recovery: true, - reverse: true, - wedged_threshold: 50, - progress: None, - halt: None, - key_fetch: None, - }; - disc.patch(&mut reader, &iso_path, &opts) - .expect("handler-chain patch must not error"); - - let map_path = libfreemkv::disc::mapfile_path_for(&iso_path); - let stats = Mapfile::load(&map_path).unwrap().stats(); - - // The clean sectors of [128,192) all recover; only the one always-dead - // sector (LBA 130) stays NonTrimmed — NOT Unreadable. The chain narrows the - // failed batch to per-sector reads, so 63 of the 64 range sectors come back. - // Conservation: 255 good + 1 still-pending = the full 256, nothing lost. - assert_eq!( - stats.bytes_unreadable, 0, - "recovery must never mark Unreadable in a pass" - ); - assert_eq!( - stats.bytes_pending, 2048, - "only the single always-dead sector (LBA 130) stays NonTrimmed" - ); - assert_eq!( - stats.bytes_good, - 255 * 2048, - "every sector except the one dead LBA is recovered" - ); - - let _ = trace; // read trace retained by the fixture; no ordering assertion here - - let _ = std::fs::remove_file(&iso_path); - let _ = std::fs::remove_file(&map_path); -}