v0.16.2: sticky escalation in patch, title-aware damage display, PASS1/PASSN constant naming

This commit is contained in:
MattJackson
2026-04-30 19:50:08 -07:00
parent 03db038dd9
commit 4863e9c545
10 changed files with 734 additions and 123 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.16.1" version = "0.16.2"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+1 -5
View File
@@ -316,11 +316,7 @@ impl Mapfile {
{ {
let file = std::fs::File::create(&tmp)?; let file = std::fs::File::create(&tmp)?;
let mut w = std::io::BufWriter::new(file); let mut w = std::io::BufWriter::new(file);
writeln!( writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
w,
"# Rescue Logfile. Created by {}",
self.version
)?;
writeln!(w, "# Current pos / status / pass / pass_time")?; writeln!(w, "# Current pos / status / pass / pass_time")?;
writeln!(w, "0x000000000 ? 1 0")?; writeln!(w, "0x000000000 ? 1 0")?;
writeln!(w, "# pos size status")?; writeln!(w, "# pos size status")?;
+545 -97
View File
@@ -1235,8 +1235,8 @@ impl Disc {
if opts.multipass { if opts.multipass {
let mf_path = self.mapfile_for(path); let mf_path = self.mapfile_for(path);
if mf_path.exists() { if mf_path.exists() {
let map = mapfile::Mapfile::load(&mf_path) let map =
.map_err(|e| Error::IoError { source: e })?; mapfile::Mapfile::load(&mf_path).map_err(|e| Error::IoError { source: e })?;
let stats = map.stats(); let stats = map.stats();
let disc_size = self.capacity_bytes; let disc_size = self.capacity_bytes;
let covers_disc = map.total_size() == disc_size; let covers_disc = map.total_size() == disc_size;
@@ -1317,8 +1317,7 @@ impl Disc {
bytes_unreadable: pr.bytes_unreadable, bytes_unreadable: pr.bytes_unreadable,
bytes_pending: pr.bytes_pending, bytes_pending: pr.bytes_pending,
recovered_this_pass: pr.bytes_recovered_this_pass, recovered_this_pass: pr.bytes_recovered_this_pass,
complete: pr.bytes_pending == 0 && pr.bytes_unreadable > 0 complete: pr.bytes_pending == 0,
|| pr.bytes_pending == 0 && pr.bytes_unreadable == 0,
halted: pr.halted, halted: pr.halted,
}) })
} }
@@ -1343,13 +1342,19 @@ impl Disc {
if !opts.resume { if !opts.resume {
let _ = std::fs::remove_file(&mapfile_path); let _ = std::fs::remove_file(&mapfile_path);
} }
let mut map = let mut map = mapfile::Mapfile::open_or_create(
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, concat!("libfreemkv v", env!("CARGO_PKG_VERSION"))) &mapfile_path,
.map_err(|e| Error::IoError { source: e })?; total_bytes,
concat!("libfreemkv v", env!("CARGO_PKG_VERSION")),
)
.map_err(|e| Error::IoError { source: e })?;
// ISO file: if resuming and mapfile has Finished ranges, open existing; // ISO file: if resuming and mapfile has Finished ranges, open existing;
// otherwise create fresh and pre-size to total_bytes (sparse holes for // otherwise create fresh and pre-size to total_bytes (sparse holes for
// non-tried regions). // non-tried regions).
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
let file = if opts.resume let file = if opts.resume
&& std::fs::metadata(path) && std::fs::metadata(path)
.map(|m| m.len() > 0) .map(|m| m.len() > 0)
@@ -1361,8 +1366,10 @@ impl Disc {
.map_err(|e| Error::IoError { source: e })? .map_err(|e| Error::IoError { source: e })?
} else { } else {
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?; let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
f.set_len(total_bytes) if is_regular {
.map_err(|e| Error::IoError { source: e })?; f.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
}
f f
}; };
@@ -1386,10 +1393,11 @@ impl Disc {
let mut bridge_degradation_count: u32 = 0; let mut bridge_degradation_count: u32 = 0;
const BRIDGE_DEGRADATION_MAX: u32 = 5; const BRIDGE_DEGRADATION_MAX: u32 = 5;
const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10; const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10;
const DAMAGE_WINDOW: usize = 16; const PASS1_DAMAGE_WINDOW: usize = 16;
const DAMAGE_THRESHOLD_PCT: usize = 12; const PASS1_DAMAGE_THRESHOLD_PCT: usize = 12;
const JUMP_SECTORS_FACTOR: u64 = 256; const PASS1_JUMP_SECTORS_FACTOR: u64 = 256;
let mut damage_window: Vec<bool> = Vec::with_capacity(DAMAGE_WINDOW); const PASS1_ESCALATION_RESET_GOOD: u64 = PASS1_DAMAGE_WINDOW as u64;
let mut damage_window: Vec<bool> = Vec::with_capacity(PASS1_DAMAGE_WINDOW);
let mut jump_multiplier: u64 = 1; let mut jump_multiplier: u64 = 1;
let mut consecutive_good: u64 = 0; let mut consecutive_good: u64 = 0;
let mut in_damage_zone = false; let mut in_damage_zone = false;
@@ -1457,11 +1465,11 @@ impl Disc {
if read_result.is_ok() { if read_result.is_ok() {
read_ok_count += 1; read_ok_count += 1;
damage_window.push(true); damage_window.push(true);
if damage_window.len() > DAMAGE_WINDOW { if damage_window.len() > PASS1_DAMAGE_WINDOW {
damage_window.remove(0); damage_window.remove(0);
} }
consecutive_good += 1; consecutive_good += 1;
if consecutive_good >= DAMAGE_WINDOW as u64 { if consecutive_good >= PASS1_ESCALATION_RESET_GOOD {
jump_multiplier = 1; jump_multiplier = 1;
if in_damage_zone { if in_damage_zone {
in_damage_zone = false; in_damage_zone = false;
@@ -1528,7 +1536,9 @@ impl Disc {
error = %err, error = %err,
"bridge degradation; cooling down 10s" "bridge degradation; cooling down 10s"
); );
std::thread::sleep(std::time::Duration::from_secs(BRIDGE_DEGRADATION_COOLDOWN_SECS)); std::thread::sleep(std::time::Duration::from_secs(
BRIDGE_DEGRADATION_COOLDOWN_SECS,
));
continue; continue;
} }
tracing::warn!( tracing::warn!(
@@ -1544,7 +1554,9 @@ impl Disc {
let asc = sense.map(|s| s.asc).unwrap_or(0); let asc = sense.map(|s| s.asc).unwrap_or(0);
let ascq = sense.map(|s| s.ascq).unwrap_or(0); let ascq = sense.map(|s| s.ascq).unwrap_or(0);
if sense_key == crate::scsi::SENSE_KEY_NOT_READY && not_ready_retries < NOT_READY_MAX_RETRIES { if sense_key == crate::scsi::SENSE_KEY_NOT_READY
&& not_ready_retries < NOT_READY_MAX_RETRIES
{
not_ready_retries += 1; not_ready_retries += 1;
tracing::warn!( tracing::warn!(
target: "freemkv::disc", target: "freemkv::disc",
@@ -1582,15 +1594,16 @@ impl Disc {
bytes_done = bytes_done.saturating_add(block_bytes); bytes_done = bytes_done.saturating_add(block_bytes);
damage_window.push(false); damage_window.push(false);
if damage_window.len() > DAMAGE_WINDOW { if damage_window.len() > PASS1_DAMAGE_WINDOW {
damage_window.remove(0); damage_window.remove(0);
} }
let bad_count = damage_window.iter().filter(|&&b| !b).count(); let bad_count = damage_window.iter().filter(|&&b| !b).count();
if damage_window.len() >= DAMAGE_WINDOW if damage_window.len() >= PASS1_DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= DAMAGE_THRESHOLD_PCT && bad_count * 100 / damage_window.len() >= PASS1_DAMAGE_THRESHOLD_PCT
{ {
let jump_sectors = JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier; let jump_sectors =
PASS1_JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier;
let jump_lba = ((pos / 2048) + jump_sectors) as u32; let jump_lba = ((pos / 2048) + jump_sectors) as u32;
let region_end_lba = (region_end / 2048) as u32; let region_end_lba = (region_end / 2048) as u32;
if jump_lba < region_end_lba { if jump_lba < region_end_lba {
@@ -1670,18 +1683,47 @@ impl Disc {
work_done: pos, work_done: pos,
work_total: total_bytes, work_total: total_bytes,
bytes_good_total: stats.bytes_good, bytes_good_total: stats.bytes_good,
bytes_bad_total: stats.bytes_unreadable + stats.bytes_retryable, bytes_unreadable_total: stats.bytes_unreadable,
bytes_pending_total: stats.bytes_pending,
bytes_total_disc: total_bytes, bytes_total_disc: total_bytes,
disc_duration_secs: self.titles.first().map(|t| t.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
}); });
} }
} }
} }
file.sync_all().map_err(|e| Error::IoError { source: e })?; tracing::debug!(
let stats = map.stats();
tracing::trace!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "copy_done", phase = "sweep_sync",
file_len = file.metadata().map(|m| m.len()).unwrap_or(0),
"sweep: calling sync_all"
);
if let Err(e) = file.sync_all() {
if is_regular {
tracing::warn!(
target: "freemkv::disc",
phase = "sweep_sync_failed",
error = %e,
os_error = e.raw_os_error(),
error_kind = ?e.kind(),
"sweep: sync_all failed"
);
return Err(Error::IoError { source: e });
}
tracing::debug!(
target: "freemkv::disc",
phase = "sweep_sync_skipped",
error = %e,
"sweep: sync_all failed for non-regular file; ignoring"
);
}
let stats = map.stats();
tracing::debug!(
target: "freemkv::disc",
phase = "sweep_done",
iter_count, iter_count,
read_ok_count, read_ok_count,
read_err_count, read_err_count,
@@ -1689,7 +1731,7 @@ impl Disc {
bytes_pending = stats.bytes_pending, bytes_pending = stats.bytes_pending,
halted = halt_requested, halted = halt_requested,
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64, copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
"Disc::copy returning" "Disc::sweep returning"
); );
Ok(CopyResult { Ok(CopyResult {
bytes_total: total_bytes, bytes_total: total_bytes,
@@ -1741,6 +1783,7 @@ pub(crate) struct PatchOpts<'a> {
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>, pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
} }
#[allow(dead_code)]
pub(crate) struct PatchOutcome { pub(crate) struct PatchOutcome {
pub bytes_total: u64, pub bytes_total: u64,
pub bytes_good: u64, pub bytes_good: u64,
@@ -1763,9 +1806,18 @@ pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
impl Disc { impl Disc {
fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf { fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
if path.as_os_str() == "/dev/null" { if path.as_os_str() == "/dev/null" {
let name: String = self.meta_title.as_deref().unwrap_or(&self.volume_id) let name: String = self
.meta_title
.as_deref()
.unwrap_or(&self.volume_id)
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) .map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect(); .collect();
std::path::PathBuf::from(format!("/tmp/{name}.mapfile")) std::path::PathBuf::from(format!("/tmp/{name}.mapfile"))
} else { } else {
@@ -1783,6 +1835,11 @@ impl Disc {
) -> Result<PatchOutcome> { ) -> Result<PatchOutcome> {
use std::io::{Seek, SeekFrom, Write}; use std::io::{Seek, SeekFrom, Write};
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 10;
const POST_FAILURE_PAUSE_SECS: u64 = 1;
const CONSECUTIVE_FAIL_LONG_PAUSE: u64 = 5;
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
let mapfile_path = self.mapfile_for(path); let mapfile_path = self.mapfile_for(path);
let mut map = let mut map =
mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?; mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?;
@@ -1793,6 +1850,9 @@ impl Disc {
crate::decrypt::DecryptKeys::None crate::decrypt::DecryptKeys::None
}; };
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
let mut file = std::fs::OpenOptions::new() let mut file = std::fs::OpenOptions::new()
.write(true) .write(true)
.open(path) .open(path)
@@ -1811,26 +1871,30 @@ impl Disc {
let mut unreadable_count: u64 = 0; let mut unreadable_count: u64 = 0;
let mut buf = vec![0u8; block_sectors as usize * 2048]; let mut buf = vec![0u8; block_sectors as usize * 2048];
// Collect bad ranges up front. Iterating while mutating is fragile; const PASSN_DAMAGE_WINDOW: usize = 8;
// each recorded change is persisted, so resume works even if we crash const PASSN_DAMAGE_THRESHOLD_PCT: usize = 25;
// mid-loop. const PASSN_SKIP_SECTORS_BASE: u64 = 64;
const PASSN_SKIP_SECTORS_CAP: u64 = 4096;
const PASSN_ESCALATION_RESET_GOOD: u32 = 4;
let mut damage_window: Vec<bool> = Vec::with_capacity(PASSN_DAMAGE_WINDOW);
let mut consecutive_skips_without_recovery: u32;
let mut consecutive_good_since_skip: u32;
let mut last_skip_from: Option<u64> = None;
reader.set_speed(0x0000);
let mut bad_ranges = map.ranges_with(&[ let mut bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTried, mapfile::SectorStatus::NonTried,
mapfile::SectorStatus::NonTrimmed, mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped, mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable, mapfile::SectorStatus::Unreadable,
]); ]);
// Reverse mode: walk ranges from highest LBA to lowest.
if opts.reverse { if opts.reverse {
bad_ranges.reverse(); bad_ranges.reverse();
} }
// work_total = sum of all bad-range bytes. on_progress's third arg
// is this value; the second arg (work_done) is incremented per block
// attempted. UI consumers (autorip) compute pass_progress_pct =
// work_done / work_total — true 0..100% per pass per RIP_DESIGN.md §16.
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
let mut work_done: u64 = 0; let mut work_done: u64 = 0;
tracing::trace!( tracing::info!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_start", phase = "patch_start",
block_sectors, block_sectors,
@@ -1844,10 +1908,10 @@ impl Disc {
'outer: for (range_pos, range_size) in bad_ranges { 'outer: for (range_pos, range_size) in bad_ranges {
let end = range_pos + range_size; let end = range_pos + range_size;
// In reverse mode, walk this range from end - block_bytes back to range_pos.
// Each iteration emits the block ending at `block_end` (so reads land on
// increasing LBAs internally; we just choose blocks back-to-front).
let mut block_end = if opts.reverse { end } else { range_pos }; let mut block_end = if opts.reverse { end } else { range_pos };
damage_window.clear();
consecutive_skips_without_recovery = 0;
consecutive_good_since_skip = 0;
loop { loop {
if let Some(ref h) = opts.halt { if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) { if h.load(std::sync::atomic::Ordering::Relaxed) {
@@ -1855,7 +1919,6 @@ impl Disc {
break 'outer; break 'outer;
} }
} }
// Compute block boundaries based on direction.
let (pos, block_bytes) = if opts.reverse { let (pos, block_bytes) = if opts.reverse {
if block_end <= range_pos { if block_end <= range_pos {
break; break;
@@ -1873,66 +1936,203 @@ impl Disc {
let count = (block_bytes / 2048) as u16; let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048; let bytes = count as usize * 2048;
blocks_attempted += 1; blocks_attempted += 1;
let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery); let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery);
// 0.13.23: parity with Disc::copy — bail immediately on a
// non-marginal SCSI sense (transport failure, HARDWARE match read_result {
// ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY, Ok(_) => {
// ILLEGAL REQUEST, kernel IoError). The wedged_threshold blocks_read_ok += 1;
// counter is a defense-in-depth backstop for ~50 consecutive_failures = 0;
// consecutive marginal failures, but a single consecutive_good_since_skip += 1;
// non-marginal error already proves the drive can't if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
// produce data this pass — don't waste 50× the timeout consecutive_skips_without_recovery = 0;
// budget proving it again. }
if let Err(ref e) = read_result { damage_window.push(true);
if !e.is_marginal_read() { if damage_window.len() > PASSN_DAMAGE_WINDOW {
let err = read_result.err().unwrap(); damage_window.remove(0);
tracing::trace!( }
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
if let Some(skip_from) = last_skip_from.take() {
let backtrack_start = block_end;
let backtrack_end = skip_from;
if opts.reverse && backtrack_start < backtrack_end {
tracing::info!(
target: "freemkv::disc",
phase = "patch_backtrack_start",
from_lba = pos,
to_lba = backtrack_end / 2048,
"recovered after skip; backtracking into gap"
);
let mut bt_pos = backtrack_start;
while bt_pos < backtrack_end {
let span =
(backtrack_end - bt_pos).min(block_sectors as u64 * 2048);
let bt_lba = (bt_pos / 2048) as u32;
let bt_count = (span / 2048) as u16;
let bt_bytes = bt_count as usize * 2048;
match reader.read_sectors(
bt_lba,
bt_count,
&mut buf[..bt_bytes],
recovery,
) {
Ok(_) => {
blocks_read_ok += 1;
if opts.decrypt {
crate::decrypt::decrypt_sectors(
&mut buf[..bt_bytes],
&keys,
0,
)?;
}
file.seek(SeekFrom::Start(bt_pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bt_bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(
bt_pos,
span,
mapfile::SectorStatus::Finished,
)
.map_err(|e| Error::IoError { source: e })?;
}
Err(err) => {
if err.is_scsi_transport_failure() {
return Err(err);
}
blocks_read_failed += 1;
map.record(
bt_pos,
span,
mapfile::SectorStatus::Unreadable,
)
.map_err(|e| Error::IoError { source: e })?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_backtrack_stop",
lba = bt_lba,
"backtrack hit damage; stopping"
);
break;
}
}
work_done = work_done.saturating_add(span);
bt_pos += span;
}
}
}
}
Err(err) => {
if err.is_scsi_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_transport_failure",
lba,
error = %err,
"transport failure (bridge crash); aborting pass"
);
return Err(err);
}
blocks_read_failed += 1;
consecutive_failures += 1;
consecutive_good_since_skip = 0;
unreadable_count += 1;
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
damage_window.push(false);
if damage_window.len() > PASSN_DAMAGE_WINDOW {
damage_window.remove(0);
}
let pause_secs = if err.is_bridge_degradation() {
tracing::debug!(
target: "freemkv::disc",
phase = "patch_bridge_degradation",
lba,
consecutive_failures,
error = %err,
"bridge degradation; cooling down"
);
BRIDGE_DEGRADATION_PAUSE_SECS
} else if consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE
} else {
POST_FAILURE_PAUSE_SECS
};
tracing::debug!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_bail", phase = "patch_post_failure_pause",
lba, lba,
error = %err, consecutive_failures,
"patch read failed with non-marginal sense; bailing" pause_secs,
"breathing room after failure"
); );
return Err(err); std::thread::sleep(std::time::Duration::from_secs(pause_secs));
}
}
let bad_count = damage_window.iter().filter(|&&b| !b).count();
let mut did_skip = false;
if damage_window.len() >= PASSN_DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= PASSN_DAMAGE_THRESHOLD_PCT
{
let skip_sectors = (PASSN_SKIP_SECTORS_BASE
<< consecutive_skips_without_recovery)
.min(PASSN_SKIP_SECTORS_CAP);
let skip_bytes = skip_sectors * 2048;
let new_block_end = if opts.reverse {
block_end.saturating_sub(skip_bytes).max(range_pos)
} else {
(block_end + skip_bytes).min(end)
};
if new_block_end != block_end {
tracing::info!(
target: "freemkv::disc",
phase = "patch_damage_skip",
from_lba = lba,
skip_sectors,
escalation = consecutive_skips_without_recovery,
bad_pct = bad_count * 100 / damage_window.len(),
"damage cluster detected; skipping within range"
);
let gap_bytes = if opts.reverse {
block_end.saturating_sub(new_block_end)
} else {
new_block_end.saturating_sub(block_end)
};
work_done = work_done.saturating_add(gap_bytes);
last_skip_from = Some(block_end);
block_end = new_block_end;
consecutive_skips_without_recovery += 1;
did_skip = true;
}
}
if !did_skip {
if opts.reverse {
block_end = block_end.saturating_sub(block_bytes);
} else {
block_end += block_bytes;
} }
} }
let read_ok = read_result.is_ok();
if read_ok {
blocks_read_ok += 1;
consecutive_failures = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
} else {
blocks_read_failed += 1;
consecutive_failures += 1;
unreadable_count += 1;
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
}
// Advance block_end in chosen direction.
if opts.reverse {
block_end = block_end.saturating_sub(block_bytes);
} else {
block_end += block_bytes;
}
// Wedged-drive early-exit: many consecutive failures with zero
// recovered bytes this pass means the drive is stuck and won't
// produce data this pass. Save the wallclock budget for productive
// grinding; future passes (with smaller block size, reverse, or
// after settle) may still recover.
if opts.wedged_threshold > 0 if opts.wedged_threshold > 0
&& consecutive_failures >= opts.wedged_threshold && consecutive_failures >= opts.wedged_threshold
&& blocks_read_ok == 0 && blocks_read_ok == 0
{ {
tracing::trace!( tracing::info!(
target: "freemkv::disc", target: "freemkv::disc",
phase = "patch_wedged_exit", phase = "patch_wedged_exit",
consecutive_failures, consecutive_failures,
@@ -1943,10 +2143,6 @@ impl Disc {
break 'outer; break 'outer;
} }
// Track work done in this pass for the per-pass progress bar.
// Each block iterated counts as work, regardless of read
// outcome — a failed retry is still progress through the
// bad-range walk.
work_done = work_done.saturating_add(block_bytes); work_done = work_done.saturating_add(block_bytes);
if let Some(reporter) = opts.progress { if let Some(reporter) = opts.progress {
@@ -1965,14 +2161,44 @@ impl Disc {
work_done, work_done,
work_total, work_total,
bytes_good_total: s.bytes_good, bytes_good_total: s.bytes_good,
bytes_bad_total: s.bytes_unreadable + s.bytes_retryable, bytes_unreadable_total: s.bytes_unreadable,
bytes_pending_total: s.bytes_pending,
bytes_total_disc: total_bytes, bytes_total_disc: total_bytes,
disc_duration_secs: self.titles.first().map(|t| t.duration_secs),
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
}); });
} }
} }
} }
file.sync_all().map_err(|e| Error::IoError { source: e })?; tracing::debug!(
target: "freemkv::disc",
phase = "patch_sync",
path = %path.display(),
is_regular,
"patch: calling sync_all"
);
if let Err(e) = file.sync_all() {
if 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"
);
}
let stats = map.stats(); let stats = map.stats();
tracing::info!( tracing::info!(
target: "freemkv::disc", target: "freemkv::disc",
@@ -2240,4 +2466,226 @@ mod tests {
t.duration_secs = 24.0 * 3600.0; t.duration_secs = 24.0 * 3600.0;
assert_eq!(t.duration_display(), "24h 00m"); assert_eq!(t.duration_display(), "24h 00m");
} }
struct MockReader {
total_sectors: u32,
bad_sectors: std::collections::HashSet<u32>,
}
impl crate::sector::SectorReader for MockReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
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(&self) -> u32 {
self.total_sectors
}
}
fn make_test_disc(sectors: u32, name: &str) -> Disc {
Disc {
volume_id: name.into(),
meta_title: Some(name.into()),
format: DiscFormat::Uhd,
capacity_sectors: sectors,
capacity_bytes: sectors as u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
content_format: ContentFormat::BdTs,
}
}
#[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<u32> = [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,
};
let result = disc.copy(&mut reader, &iso_path, &opts);
assert!(
result.is_ok(),
"sweep to regular file should succeed: {:?}",
result.err()
);
}
#[test]
fn sweep_to_dev_null_real() {
let _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T2.mapfile"));
let sectors: u32 = 1000;
let bad: std::collections::HashSet<u32> = [500u32, 501, 502].into_iter().collect();
let mut reader = MockReader {
total_sectors: sectors,
bad_sectors: bad,
};
let disc = make_test_disc(sectors, "T2");
let opts = CopyOptions {
decrypt: false,
multipass: true,
progress: None,
halt: 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()
);
}
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 _cleanup = CleanupGuard(std::path::PathBuf::from("/tmp/T3.mapfile"));
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 opts = CopyOptions {
decrypt: false,
multipass: false,
progress: None,
halt: 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);
}
#[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<u32> = [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,
};
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,
};
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<u32> = [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,
};
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,
};
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()
);
}
} }
+1 -1
View File
@@ -543,7 +543,7 @@ impl Error {
/// classified as fatal; treat as recoverable /// classified as fatal; treat as recoverable
/// ///
/// Returns `false` for transport failures (no sense data delivered), /// Returns `false` for transport failures (no sense data delivered),
/// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY, ILLEGAL /// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, ILLEGAL
/// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant. /// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant.
/// Caller-agnostic predicate — describes a property of the *error*, /// Caller-agnostic predicate — describes a property of the *error*,
/// not what one specific call site should do with it. Used by /// not what one specific call site should do with it. Used by
+12 -9
View File
@@ -39,19 +39,22 @@ pub enum PassKind {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct PassProgress { pub struct PassProgress {
pub kind: PassKind, pub kind: PassKind,
/// Bytes processed in this pass so far. Monotonically non-decreasing.
pub work_done: u64, pub work_done: u64,
/// Total bytes this pass will process. Constant for the duration of
/// the pass.
pub work_total: u64, pub work_total: u64,
/// Cumulative bytes confirmed clean (`Finished` mapfile state) across
/// every pass run on this rip. Doesn't change across pass boundaries.
pub bytes_good_total: u64, pub bytes_good_total: u64,
/// Cumulative bytes marked bad (`NonTrimmed` + `NonScraped` + pub bytes_unreadable_total: u64,
/// `Unreadable`) across every pass run on this rip. pub bytes_pending_total: u64,
pub bytes_bad_total: u64,
/// Total disc capacity in bytes. Constant.
pub bytes_total_disc: u64, pub bytes_total_disc: u64,
pub disc_duration_secs: Option<f64>,
/// How many bytes of the worst-case damage (unreadable + pending) fall
/// within the main title's extents. Zero means none of the damage
/// affects the main movie — it's all in extras/menus.
pub bytes_bad_in_main_title: u64,
/// Main title duration in seconds. Same as disc_duration_secs when the
/// disc has one dominant title, but separate so consumers can show both.
pub main_title_duration_secs: Option<f64>,
/// Main title size in bytes (sum of extent sizes).
pub main_title_size_bytes: Option<u64>,
} }
/// A consumer of pipeline progress events. Library code calls /// A consumer of pipeline progress events. Library code calls
+1 -2
View File
@@ -158,8 +158,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) }; let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) };
let mut out = Vec::new(); let mut out = Vec::new();
for i in 0..(count as usize).min(buf.len()) { for info in buf.iter().take((count as usize).min(buf.len())) {
let info = &buf[i];
let bsd_name = cstr_to_str(&info.bsd_name); let bsd_name = cstr_to_str(&info.bsd_name);
if bsd_name.is_empty() { if bsd_name.is_empty() {
continue; continue;
+5 -2
View File
@@ -145,12 +145,14 @@ impl ScsiSense {
/// (or a brief retry) sometimes succeeds: /// (or a brief retry) sometimes succeeds:
/// ///
/// - `MEDIUM ERROR` (3) — canonical bad-sector signal /// - `MEDIUM ERROR` (3) — canonical bad-sector signal
/// - `NOT READY` (2) — on many drives (notably BU40N), this is the
/// dominant response for unreadable sectors (ASC 04/3E, 04/01, etc.)
/// - `ABORTED COMMAND` (B) — transient; retry usually works /// - `ABORTED COMMAND` (B) — transient; retry usually works
/// - `RECOVERED ERROR` (1) / `NO SENSE` (0) — drive is healthy and /// - `RECOVERED ERROR` (1) / `NO SENSE` (0) — drive is healthy and
/// either recovered the data or has no specific fault to report /// either recovered the data or has no specific fault to report
/// ///
/// `false` for HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, NOT /// `false` for HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION,
/// READY, ILLEGAL REQUEST, BLANK CHECK, and any unknown key. Used /// ILLEGAL REQUEST, BLANK CHECK, and any unknown key. Used
/// by [`Error::is_marginal_read`] / `Disc::copy`'s hysteresis /// by [`Error::is_marginal_read`] / `Disc::copy`'s hysteresis
/// dispatch. /// dispatch.
pub fn is_marginal(&self) -> bool { pub fn is_marginal(&self) -> bool {
@@ -158,6 +160,7 @@ impl ScsiSense {
self.sense_key, self.sense_key,
SENSE_KEY_NO_SENSE SENSE_KEY_NO_SENSE
| SENSE_KEY_RECOVERED_ERROR | SENSE_KEY_RECOVERED_ERROR
| SENSE_KEY_NOT_READY
| SENSE_KEY_MEDIUM_ERROR | SENSE_KEY_MEDIUM_ERROR
| SENSE_KEY_ABORTED_COMMAND | SENSE_KEY_ABORTED_COMMAND
) )
+162 -1
View File
@@ -593,7 +593,7 @@ impl SectorReader for BlockSizeFailingReader {
#[test] #[test]
fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() { fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
let capacity_sectors: u32 = 256; let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64; let _total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = BlockSizeFailingReader { let mut reader = BlockSizeFailingReader {
capacity: capacity_sectors, capacity: capacity_sectors,
@@ -634,3 +634,164 @@ fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
"complete=false when sectors remain NonTrimmed" "complete=false when sectors remain NonTrimmed"
); );
} }
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
//
// The video-damage-time display needs bytes_unreadable_total (confirmed dead)
// separate from bytes_pending_total (might still recover). This test verifies
// that a Pass 2 with some confirmed failures produces correct field values.
#[test]
fn test_pass_progress_separates_unreadable_from_pending() {
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<AtomicU64>,
pending: Arc<AtomicU64>,
good: Arc<AtomicU64>,
dur: Arc<AtomicU64>,
}
impl libfreemkv::progress::Progress for SnapshotReporter {
fn report(&self, p: &libfreemkv::progress::PassProgress) {
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);
}
}
}
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)"
);
assert!(
pass2.bytes_unreadable > 0,
"pass2: some sectors confirmed unreadable"
);
assert!(
pass2.bytes_pending < pass1.bytes_pending,
"pass2: fewer pending sectors than pass1"
);
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
let observed_pending = last_pending.load(Ordering::Relaxed);
assert!(
observed_unreadable > 0,
"progress should report confirmed unreadable bytes"
);
assert!(
observed_pending == 0 || observed_pending < total_bytes,
"pending should shrink as sectors are confirmed unreadable"
);
// 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)"
);
}
+4 -1
View File
@@ -203,7 +203,10 @@ fn test_check_cond_not_ready_predicate() {
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense"); let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_not_ready(), "sense_key 2 ⇒ is_not_ready"); assert!(sense.is_not_ready(), "sense_key 2 ⇒ is_not_ready");
assert!(!err.is_marginal_read(), "NOT READY is not marginal"); assert!(
err.is_marginal_read(),
"NOT READY is marginal (BU40N returns this for bad sectors)"
);
} }
// ── 5. CHECK CONDITION + MEDIUM ERROR (canonical marginal-read) ─────────── // ── 5. CHECK CONDITION + MEDIUM ERROR (canonical marginal-read) ───────────
+2 -4
View File
@@ -8,16 +8,14 @@
//! //!
//! These tests require a real /dev/sg* device and are therefore #[ignore]. //! These tests require a real /dev/sg* device and are therefore #[ignore].
use libfreemkv::scsi::{DataDirection, SCSI_STATUS_TRANSPORT_FAILURE};
use std::path::Path; use std::path::Path;
use std::time::Duration;
#[test] #[test]
#[ignore] #[ignore]
fn test_sgio_transport_timeout_does_not_kill_transport() { fn test_sgio_transport_timeout_does_not_kill_transport() {
let device = "/dev/sg2"; let device = "/dev/sg2";
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string()); let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
let path = Path::new(&device); let _path = Path::new(&device);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -90,7 +88,7 @@ fn test_sgio_transport_timeout_does_not_kill_transport() {
fn test_drive_read_per_cdb_timeout_bounds_call() { fn test_drive_read_per_cdb_timeout_bounds_call() {
let device = "/dev/sg2"; let device = "/dev/sg2";
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string()); let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
let path = Path::new(&device); let _path = Path::new(&device);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {