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};
pub(super) enum PatchItem {
Recovered { pos: u64, buf: Vec<u8> },
#[allow(dead_code)]
Unreadable { pos: u64, len: u64 },
NonTrimmed { pos: u64, len: u64 },
}
pub(super) struct SharedPatchState {
pub stats: MapStats,
pub bad_ranges: Vec<(u64, u64)>,
}
impl SharedPatchState {
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,
SectorStatus::NonTried,
]);
bad_ranges.truncate(Self::MAX_BAD_RANGES);
Self {
stats: map.stats(),
bad_ranges,
}
}
}
pub(super) struct PatchSummary {
pub stats: MapStats,
}
pub(super) struct PatchSink {
file: crate::io::WritebackFile,
map: Mapfile,
is_regular: bool,
shared: Arc<Mutex<SharedPatchState>>,
last_republish: Option<std::time::Instant>,
}
const REPUBLISH_CADENCE: std::time::Duration = std::time::Duration::from_millis(250);
impl PatchSink {
pub(super) fn new(
path: &std::path::Path,
map: Mapfile,
is_regular: bool,
) -> Result<(Self, Arc<Mutex<SharedPatchState>>)> {
let file =
crate::io::WritebackFile::open(path).map_err(|e| Error::IoError { source: e })?;
let shared = Arc::new(Mutex::new(SharedPatchState::from_map(&map)));
let shared_clone = shared.clone();
Ok((
Self {
file,
map,
is_regular,
shared,
last_republish: None,
},
shared_clone,
))
}
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) {
let mut guard = self
.shared
.lock()
.expect("PatchSink shared state mutex poisoned");
*guard = SharedPatchState::from_map(&self.map);
}
}
impl Sink<PatchItem> for PatchSink {
type Output = PatchSummary;
fn apply(&mut self, item: PatchItem) -> std::result::Result<Flow, Error> {
match item {
PatchItem::Recovered { pos, buf } => {
let len = buf.len() as u64;
self.file
.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
self.file
.write_all(&buf)
.map_err(|e| Error::IoError { source: e })?;
self.map
.record(pos, len, SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
}
PatchItem::Unreadable { pos, len } => {
self.map
.record(pos, len, SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
}
PatchItem::NonTrimmed { pos, len } => {
self.map
.record(pos, len, SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
}
}
self.republish(false);
Ok(Flow::Continue)
}
fn close(mut self) -> std::result::Result<Self::Output, Error> {
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 })?;
self.republish(true);
Ok(PatchSummary {
stats: self.map.stats(),
})
}
}
use super::{Disc, DiscTitle, PatchOptions, PatchOutcome, bytes_bad_in_title};
use crate::io::pipeline::Pipeline;
use crate::sector::SectorSource;
const NOT_READY_MAX_RETRIES_PER_LBA: u32 = 3;
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;
const ADAPTIVE_UPSCALE_THRESHOLD: u32 = 16;
const WEDGE_FAMILY_COOLDOWN_SECS: u64 = 30;
const WEDGE_ABORT_THRESHOLD: u32 = 16;
const STALL_SECS: u64 = 3600;
const SECONDS_PER_SECTOR: u64 = 25;
const RANGE_BUDGET_CAP_SECS: u64 = 1800;
const MAX_SKIPS_PER_RANGE: u32 = 10;
const PASSN_DAMAGE_WINDOW: usize = 16;
const PASSN_DAMAGE_THRESHOLD_PCT: usize = crate::disc::read_error::PATCH_DAMAGE_THRESHOLD_PCT;
const PASSN_SKIP_SECTORS_BASE: u64 = 32;
const PASSN_SKIP_SECTORS_CAP: u64 = 4096;
const PASSN_ESCALATION_RESET_GOOD: u32 = 4;
const CACHE_PRIME_SECTORS: u32 = 3;
pub(super) fn skip_sectors_for_probe(idx: usize) -> u64 {
let escalation = (idx.saturating_mul(3)).min(u32::MAX as usize) as u32;
PASSN_SKIP_SECTORS_BASE
.checked_shl(escalation)
.unwrap_or(PASSN_SKIP_SECTORS_CAP)
.min(PASSN_SKIP_SECTORS_CAP)
}
pub(super) fn send_or_abort(
pipe: &Pipeline<PatchItem, PatchSummary>,
item: PatchItem,
) -> Result<()> {
pipe.send(item).map_err(|_| Error::PipelineConsumerGone)
}
#[allow(clippy::type_complexity)]
pub(super) fn compute_initial_state(
path: &std::path::Path,
opts: &PatchOptions,
mapfile_path: &std::path::Path,
) -> Result<(
Mapfile,
MapStats,
Vec<mapfile::MapEntry>,
u64,
Vec<(u64, u64)>,
u64,
bool,
)> {
let map = mapfile::Mapfile::load(mapfile_path).map_err(|e| Error::IoError { source: e })?;
let total_bytes = map.total_size();
let initial_stats = map.stats();
let initial_entries: Vec<_> = map.entries().to_vec();
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();
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
Ok((
map,
initial_stats,
initial_entries,
total_bytes,
bad_ranges,
work_total,
is_regular,
))
}
pub(super) fn prime_cache<R: SectorSource + ?Sized>(reader: &mut R, lba: u32, count: u16) {
if !(lba >= CACHE_PRIME_SECTORS && count == 1) {
return;
}
let mut prime_buf = [0u8; 2048];
for i in 0..CACHE_PRIME_SECTORS {
let prime_lba = lba - CACHE_PRIME_SECTORS + i;
let _ = reader.read_sectors(prime_lba, 1, &mut prime_buf[..], false);
}
}
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"
);
}
}
}
#[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",
blocks_attempted = state.blocks_attempted,
blocks_read_ok = state.blocks_read_ok,
blocks_read_failed = state.blocks_read_failed,
unreadable_count = state.unreadable_count,
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,
blocks_attempted: state.blocks_attempted,
blocks_read_ok: state.blocks_read_ok,
blocks_read_failed: state.blocks_read_failed,
wedged_exit: state.wedged_exit,
wedged_threshold,
}
}
pub(super) struct PatchLoopState {
pub halted: bool,
pub wedged_exit: bool,
pub blocks_attempted: u64,
pub blocks_read_ok: u64,
pub blocks_read_failed: u64,
pub unreadable_count: u64,
pub wedge_count: u32,
pub work_done: u64,
pub consecutive_failures: u64,
pub consecutive_skips_without_recovery: u32,
pub consecutive_good_since_skip: u32,
pub last_skip_from: Option<u64>,
pub skip_count: u32,
pub damage_window: Vec<bool>,
pub not_ready_retries_per_lba: u32,
pub not_ready_lba: Option<u32>,
pub bytes_good_last: u64,
pub stall_start: std::time::Instant,
pub range_start: std::time::Instant,
pub range_bytes_good: u64,
pub current_batch: u16,
pub consecutive_singles_ok: u32,
pub bytes_good_before: u64,
pub bytes_good_start: u64,
#[allow(dead_code)]
pub total_bytes: u64,
pub initial_batch: u16,
pub recovery: bool,
pub work_total: u64,
}
impl PatchLoopState {
pub(super) fn new(
bytes_good_before: u64,
total_bytes: u64,
initial_batch: u16,
recovery: bool,
work_total: u64,
) -> Self {
let now = std::time::Instant::now();
Self {
halted: false,
wedged_exit: false,
blocks_attempted: 0,
blocks_read_ok: 0,
blocks_read_failed: 0,
unreadable_count: 0,
wedge_count: 0,
work_done: 0,
consecutive_failures: 0,
consecutive_skips_without_recovery: 0,
consecutive_good_since_skip: 0,
last_skip_from: None,
skip_count: 0,
damage_window: Vec::with_capacity(PASSN_DAMAGE_WINDOW),
not_ready_retries_per_lba: 0,
not_ready_lba: None,
bytes_good_last: bytes_good_before,
stall_start: now,
range_start: now,
range_bytes_good: bytes_good_before,
current_batch: initial_batch,
consecutive_singles_ok: 0,
bytes_good_before,
bytes_good_start: bytes_good_before,
total_bytes,
initial_batch,
recovery,
work_total,
}
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_read_success<R: SectorSource + ?Sized>(
state: &mut PatchLoopState,
frame: &RangeFrame,
opts: &PatchOptions,
lba: u32,
count: u16,
pos: u64,
block_bytes: u64,
bytes: usize,
buf: &mut [u8],
read_duration_ms: u128,
pipe: &Pipeline<PatchItem, PatchSummary>,
shared: &Mutex<SharedPatchState>,
reader: &mut R,
) -> Result<OuterAction> {
state.blocks_read_ok += 1;
state.consecutive_failures = 0;
state.consecutive_good_since_skip += 1;
state.wedge_count = 0;
state.not_ready_retries_per_lba = 0;
state.not_ready_lba = None;
if state.consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD {
state.consecutive_skips_without_recovery = 0;
}
if count == 1 && state.current_batch < state.initial_batch {
state.consecutive_singles_ok += 1;
if state.consecutive_singles_ok >= ADAPTIVE_UPSCALE_THRESHOLD {
tracing::info!(
target: "freemkv::disc",
phase = "patch_adaptive_upscale",
from = state.current_batch,
to = state.initial_batch,
consecutive_singles_ok = state.consecutive_singles_ok,
lba,
"adaptive batching: drive stable, climbing back to initial_batch"
);
state.current_batch = state.initial_batch;
state.consecutive_singles_ok = 0;
}
}
state.damage_window.push(true);
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
state.damage_window.remove(0);
}
tracing::info!(
target: "freemkv::disc",
phase = "patch_read_ok",
lba,
count,
bytes,
blocks_read_ok = state.blocks_read_ok,
consecutive_failures = state.consecutive_failures,
read_duration_ms,
range_idx = frame.range_idx,
pos,
"Read succeeded"
);
let write_start = std::time::Instant::now();
tracing::debug!(
target: "freemkv::disc",
phase = "patch_write_start",
pos,
bytes,
"Starting ISO write"
);
send_or_abort(
pipe,
PatchItem::Recovered {
pos,
buf: buf[..bytes].to_vec(),
},
)?;
let write_duration_ms = write_start.elapsed().as_millis();
tracing::info!(
target: "freemkv::disc",
phase = "patch_write_ok",
pos,
bytes,
write_duration_ms,
"ISO write succeeded"
);
tracing::info!(
target: "freemkv::disc",
phase = "patch_mapfile_record_ok",
pos,
block_bytes,
"Mapfile record dispatched"
);
let bytes_good_now = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
g.stats.bytes_good
};
if bytes_good_now > state.bytes_good_last {
state.stall_start = std::time::Instant::now();
state.bytes_good_last = bytes_good_now;
}
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_stall",
elapsed_secs = state.stall_start.elapsed().as_secs(),
bytes_good = bytes_good_now,
bytes_good_start = state.bytes_good_start,
"Patch stalled - no recovery for {}s, exiting pass",
STALL_SECS
);
state.wedged_exit = true;
return Ok(OuterAction::Break);
}
if let Some(skip_from) = state.last_skip_from.take() {
let backtrack_start = frame.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 {
if let Some(h) = &opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
return Err(crate::error::Error::Halted);
}
}
let span =
(backtrack_end - bt_pos).min(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], state.recovery) {
Ok(_) => {
state.blocks_read_ok += 1;
send_or_abort(
pipe,
PatchItem::Recovered {
pos: bt_pos,
buf: buf[..bt_bytes].to_vec(),
},
)?;
}
Err(_err) => {
state.blocks_read_failed += 1;
state.damage_window.push(false);
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
state.damage_window.remove(0);
}
state.consecutive_failures += 1;
send_or_abort(
pipe,
PatchItem::NonTrimmed {
pos: bt_pos,
len: span,
},
)?;
tracing::info!(
target: "freemkv::disc",
phase = "patch_backtrack_stop",
lba = bt_lba,
"backtrack hit damage; stopping"
);
break;
}
}
state.work_done = state.work_done.saturating_add(span);
bt_pos += span;
}
}
}
Ok(OuterAction::Continue)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_read_failure<R: SectorSource + ?Sized>(
state: &mut PatchLoopState,
frame: &RangeFrame,
opts: &PatchOptions,
err: &Error,
lba: u32,
count: u16,
pos: u64,
block_bytes: u64,
bytes: usize,
read_duration_ms: u128,
pipe: &Pipeline<PatchItem, PatchSummary>,
shared: &Mutex<SharedPatchState>,
reader: &mut R,
) -> Result<FailureAction> {
if err.is_scsi_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_transport_failure",
lba,
count,
"transport failure (bridge crash) during patch — aborting pass"
);
state.wedged_exit = true;
return Ok(FailureAction::BreakOuter);
}
if count > 1 {
tracing::info!(
target: "freemkv::disc",
phase = "patch_adaptive_split",
lba,
count,
from_batch = state.current_batch,
err_code = err.code(),
"adaptive batching: batch read failed, dropping to count=1 to probe individually"
);
state.current_batch = 1;
state.consecutive_singles_ok = 0;
return Ok(FailureAction::ContinueInner);
}
state.blocks_read_failed += 1;
state.consecutive_good_since_skip = 0;
state.consecutive_singles_ok = 0;
state.unreadable_count += 1;
if state.not_ready_lba != Some(lba) {
state.not_ready_retries_per_lba = 0;
state.not_ready_lba = Some(lba);
}
let sense = err.scsi_sense();
let is_not_ready_retryable = sense
.map(|s| s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04))
.unwrap_or(false);
if !is_not_ready_retryable {
state.consecutive_failures += 1;
}
tracing::warn!(
target: "freemkv::disc",
phase = "patch_read_err",
lba,
count,
bytes,
blocks_read_failed = state.blocks_read_failed,
consecutive_failures = state.consecutive_failures,
read_duration_ms,
error_code = err.code(),
range_idx = frame.range_idx,
pos,
"Read failed"
);
if is_not_ready_retryable {
if state.not_ready_retries_per_lba < NOT_READY_MAX_RETRIES_PER_LBA {
state.not_ready_retries_per_lba += 1;
tracing::info!(
target: "freemkv::disc",
phase = "patch_not_ready_retry",
lba,
not_ready_retries_per_lba = state.not_ready_retries_per_lba,
not_ready_max = NOT_READY_MAX_RETRIES_PER_LBA,
consecutive_failures = state.consecutive_failures,
err_asc = sense.map(|s| s.asc as u32).unwrap_or(0),
"NOT_READY with ASC in 0x02/0x03/0x04; pausing for drive recovery before retry"
);
let pause_secs = 15u64;
tracing::debug!(
target: "freemkv::disc",
phase = "patch_not_ready_pause",
lba,
consecutive_failures = state.consecutive_failures,
pause_secs,
"Waiting for drive to become ready"
);
super::sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
let bytes_good_now = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
g.stats.bytes_good
};
if bytes_good_now > state.bytes_good_last {
state.stall_start = std::time::Instant::now();
state.bytes_good_last = bytes_good_now;
}
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_stall",
elapsed_secs = state.stall_start.elapsed().as_secs(),
bytes_good = bytes_good_now,
bytes_good_start = state.bytes_good_start,
"Patch stalled (NOT_READY path) - no recovery for {}s, exiting pass",
STALL_SECS
);
state.wedged_exit = true;
return Ok(FailureAction::BreakOuter);
}
state.damage_window.push(false);
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
state.damage_window.remove(0);
}
return Ok(FailureAction::ContinueInner);
}
tracing::warn!(
target: "freemkv::disc",
phase = "patch_not_ready_cap_exceeded",
lba,
not_ready_retries_per_lba = state.not_ready_retries_per_lba,
not_ready_max = NOT_READY_MAX_RETRIES_PER_LBA,
"NOT_READY cap exceeded for this LBA; falling through to normal failure handling"
);
state.consecutive_failures += 1;
}
send_or_abort(
pipe,
PatchItem::NonTrimmed {
pos,
len: block_bytes,
},
)?;
state.damage_window.push(false);
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
state.damage_window.remove(0);
}
let bytes_good_now = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
g.stats.bytes_good
};
if bytes_good_now > state.bytes_good_last {
state.stall_start = std::time::Instant::now();
state.bytes_good_last = bytes_good_now;
}
if state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_stall",
elapsed_secs = state.stall_start.elapsed().as_secs(),
consecutive_failures = state.consecutive_failures,
bytes_good = bytes_good_now,
bytes_good_start = state.bytes_good_start,
"Patch stalled - no recovery for {}s, exiting pass",
STALL_SECS
);
state.wedged_exit = true;
return Ok(FailureAction::BreakOuter);
}
if state.consecutive_failures % 10 == 0 || state.consecutive_failures >= opts.wedged_threshold {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_failure_count",
lba,
consecutive_failures = state.consecutive_failures,
wedged_threshold = opts.wedged_threshold,
"Failure count"
);
}
if state.consecutive_failures >= 3 && state.consecutive_failures % 5 == 0 {
let probe_offsets_sectors: [u64; 3] =
[0, skip_sectors_for_probe(1), skip_sectors_for_probe(2)];
let mut probes_ok = 0;
for (probe_idx, &offset_sectors) in probe_offsets_sectors.iter().enumerate() {
if let Some(h) = &opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
return Err(crate::error::Error::Halted);
}
}
let offset = offset_sectors.saturating_mul(2048);
let probe_pos = pos.saturating_add(offset);
if probe_pos >= frame.end || (offset == 0 && state.consecutive_failures < 5) {
continue;
}
let probe_lba = (probe_pos / 2048) as u32;
let probe_count = 1u16;
let mut probe_buf = [0u8; 2048];
match reader.read_sectors(probe_lba, probe_count, &mut probe_buf[..], state.recovery) {
Ok(_) => {
probes_ok += 1;
tracing::debug!(
target: "freemkv::disc",
phase = "patch_probe_ok",
lba = probe_lba,
offset_from_current = offset,
probe_idx,
"Probe read succeeded — drive responsive"
);
}
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "patch_probe_err",
lba = probe_lba,
offset_from_current = offset,
probe_idx,
"Probe read failed"
);
}
}
}
if probes_ok > 0 {
tracing::info!(
target: "freemkv::disc",
phase = "patch_drive_responsive",
consecutive_failures = state.consecutive_failures,
probes_ok,
total_probes = 3,
lba,
range_idx = frame.range_idx,
"Drive responsive — bad sector cluster, not wedged"
);
} else if probes_ok == 0 && state.consecutive_failures >= 10 {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_zone_fully_bad",
consecutive_failures = state.consecutive_failures,
lba,
range_idx = frame.range_idx,
"patch zone fully bad (10+ failures, all probes failed); \
not a wedge unless read_error.rs's wedge_transition also fires"
);
}
}
let is_wedge_family = err
.scsi_sense()
.map(|s| {
s.sense_key == crate::scsi::SENSE_KEY_HARDWARE_ERROR
|| s.sense_key == crate::scsi::SENSE_KEY_ILLEGAL_REQUEST
})
.unwrap_or(false);
let pause_secs = if is_wedge_family {
state.wedge_count += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "patch_wedge_family",
lba,
wedge_count = state.wedge_count,
wedge_abort_threshold = WEDGE_ABORT_THRESHOLD,
sense_key = err.scsi_sense().map(|s| s.sense_key as u32).unwrap_or(0),
"HARDWARE_ERROR / ILLEGAL_REQUEST sense — wedge family, applying long cooldown"
);
if state.wedge_count >= WEDGE_ABORT_THRESHOLD {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_wedge_abort",
wedge_count = state.wedge_count,
WEDGE_ABORT_THRESHOLD,
"Drive appears wedged ({} consecutive wedge-family senses); aborting pass for autorip eject+reload",
state.wedge_count
);
state.wedged_exit = true;
return Ok(FailureAction::BreakOuter);
}
WEDGE_FAMILY_COOLDOWN_SECS
} else if err.is_bridge_degradation() {
tracing::debug!(
target: "freemkv::disc",
phase = "patch_bridge_degradation",
lba,
consecutive_failures = state.consecutive_failures,
error = %err,
"bridge degradation; cooling down"
);
BRIDGE_DEGRADATION_PAUSE_SECS
} else if state.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
CONSECUTIVE_FAIL_LONG_PAUSE
} else {
POST_FAILURE_PAUSE_SECS
};
if !is_wedge_family {
state.wedge_count = 0;
}
tracing::debug!(
target: "freemkv::disc",
phase = "patch_post_failure_pause",
lba,
consecutive_failures = state.consecutive_failures,
pause_secs,
"breathing room after failure"
);
super::sleep_secs_or_halt(pause_secs, opts.halt.as_ref());
Ok(FailureAction::Continue)
}
pub(super) enum OuterAction {
Continue,
Break,
}
pub(super) enum FailureAction {
Continue,
ContinueInner,
BreakOuter,
}
pub(super) struct RangeFrame {
pub range_idx: usize,
pub range_pos: u64,
#[allow(dead_code)]
pub range_size: u64,
pub end: u64,
pub block_end: u64,
pub range_budget_secs: u64,
pub range_sectors: u64,
}
pub(super) fn check_range_watchdog(
state: &mut PatchLoopState,
frame: &RangeFrame,
shared: &Mutex<SharedPatchState>,
) -> bool {
let bytes_good_now = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
g.stats.bytes_good
};
if bytes_good_now > state.range_bytes_good {
state.range_bytes_good = bytes_good_now;
state.range_start = std::time::Instant::now();
}
if state.range_start.elapsed().as_secs() >= frame.range_budget_secs {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_range_stall",
range_lba = frame.range_pos / 2048,
range_sectors = frame.range_sectors,
elapsed_secs = state.range_start.elapsed().as_secs(),
budget_secs = frame.range_budget_secs,
bytes_recovered = state.range_bytes_good.saturating_sub(state.bytes_good_before),
"Range stalled - moving to next range"
);
return true;
}
false
}
pub(super) fn handle_skip_limit(
state: &PatchLoopState,
frame: &RangeFrame,
opts: &PatchOptions,
pipe: &Pipeline<PatchItem, PatchSummary>,
) -> Result<()> {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_skip_limit",
range_lba = frame.range_pos / 2048,
skip_count = state.skip_count,
"Skip limit reached - leaving remaining bytes NonTrimmed for next pass",
);
if let Some((pos, len)) =
skip_limit_remainder(opts.reverse, frame.range_pos, frame.end, frame.block_end)
{
send_or_abort(pipe, PatchItem::NonTrimmed { pos, len })?;
}
Ok(())
}
fn skip_limit_remainder(
reverse: bool,
range_pos: u64,
end: u64,
block_end: u64,
) -> Option<(u64, u64)> {
if reverse {
let len = block_end.saturating_sub(range_pos);
(len > 0).then_some((range_pos, len))
} else {
let len = end.saturating_sub(block_end);
(len > 0).then_some((block_end, len))
}
}
pub(super) fn compute_damage_skip(
state: &mut PatchLoopState,
frame: &mut RangeFrame,
opts: &PatchOptions,
lba: u32,
_block_bytes: u64,
) -> bool {
let bad_count = state.damage_window.iter().filter(|&&b| !b).count();
if !(state.damage_window.len() >= PASSN_DAMAGE_WINDOW
&& bad_count * 100 / state.damage_window.len() >= PASSN_DAMAGE_THRESHOLD_PCT)
{
return false;
}
let range_remaining_bytes = if opts.reverse {
frame.block_end.saturating_sub(frame.range_pos)
} else {
frame.end.saturating_sub(frame.block_end)
};
let range_remaining_sectors = range_remaining_bytes / 2048;
let range_quarter = (range_remaining_sectors / 4).max(1);
let escalated = PASSN_SKIP_SECTORS_BASE
.checked_shl(state.consecutive_skips_without_recovery)
.unwrap_or(PASSN_SKIP_SECTORS_CAP)
.min(PASSN_SKIP_SECTORS_CAP);
let skip_sectors = escalated.min(range_quarter);
let skip_bytes = skip_sectors * 2048;
let new_block_end = if opts.reverse {
frame
.block_end
.saturating_sub(skip_bytes)
.max(frame.range_pos)
} else {
(frame.block_end + skip_bytes).min(frame.end)
};
if new_block_end == frame.block_end {
return false;
}
tracing::info!(
target: "freemkv::disc",
phase = "patch_damage_skip",
from_lba = lba,
skip_sectors,
escalation = state.consecutive_skips_without_recovery,
bad_pct = bad_count * 100 / state.damage_window.len(),
"damage cluster detected; skipping within range"
);
let gap_bytes = if opts.reverse {
frame.block_end.saturating_sub(new_block_end)
} else {
new_block_end.saturating_sub(frame.block_end)
};
state.work_done = state.work_done.saturating_add(gap_bytes);
state.last_skip_from = Some(frame.block_end);
frame.block_end = new_block_end;
state.consecutive_skips_without_recovery += 1;
state.skip_count += 1;
true
}
impl Disc {
pub(super) fn report_patch_progress(
&self,
state: &PatchLoopState,
opts: &PatchOptions,
total_bytes: u64,
shared: &Mutex<SharedPatchState>,
) -> bool {
let Some(reporter) = opts.progress else {
return false;
};
let (s, bad_ranges_now) = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
(g.stats, g.bad_ranges.clone())
};
let kind = if state.initial_batch == 1 {
crate::progress::PassKind::Scrape {
reverse: opts.reverse,
}
} else {
crate::progress::PassKind::Trim {
reverse: opts.reverse,
}
};
let main_title_bad = self
.titles
.first()
.map(|t| bytes_bad_in_title(t, &bad_ranges_now))
.unwrap_or(0);
let main_title = self.titles.first();
let pp = crate::progress::PassProgress {
kind,
work_done: state.work_done,
work_total: state.work_total,
bytes_good_total: s.bytes_good,
bytes_unreadable_total: s.bytes_unreadable,
bytes_pending_total: s.bytes_pending,
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),
};
!reporter.report(&pp)
}
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,
Err(_) => return 0,
};
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)
}
pub fn patch(
&self,
reader: &mut dyn SectorSource,
path: &std::path::Path,
opts: &PatchOptions,
) -> Result<PatchOutcome> {
use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH};
use crate::sector::{DecryptingSectorSource, SectorSource};
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;
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
let decrypt_is_aacs = matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. });
let mut reader = DecryptingSectorSource::new(reader, keys);
let reader = &mut reader;
let (sink, shared) = PatchSink::new(path, map, is_regular)?;
let pipe = Pipeline::<PatchItem, _>::spawn(WRITE_THROUGH_DEPTH, sink)?;
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"
);
}
let initial_batch = opts.block_sectors.unwrap_or(1).max(1);
let recovery = opts.full_recovery;
let mut state = PatchLoopState::new(
bytes_good_before,
total_bytes,
initial_batch,
recovery,
work_total,
);
let mut buf = vec![0u8; initial_batch as usize * 2048];
reader.set_speed(0x0000);
log_patch_start_snapshot(&initial_entries, &initial_stats, bytes_good_before);
tracing::info!(
target: "freemkv::disc",
phase = "patch_bad_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"
);
'outer: for (range_idx, (range_pos, range_size)) in bad_ranges.iter().enumerate() {
tracing::info!(
target: "freemkv::disc",
phase = "patch_range_start",
range_index = range_idx,
num_total_ranges = bad_ranges.len(),
range_lba = *range_pos / 2048,
range_size_mb = *range_size as f64 / 1_048_576.0,
"Starting patch range"
);
let end = *range_pos + *range_size;
let range_sectors = *range_size / 2048;
let range_budget_secs = (range_sectors * SECONDS_PER_SECTOR).min(RANGE_BUDGET_CAP_SECS);
let mut frame = RangeFrame {
range_idx,
range_pos: *range_pos,
range_size: *range_size,
end,
block_end: if opts.reverse { end } else { *range_pos },
range_budget_secs,
range_sectors,
};
state.damage_window.clear();
state.consecutive_skips_without_recovery = 0;
state.consecutive_good_since_skip = 0;
state.range_start = std::time::Instant::now();
state.range_bytes_good = {
let g = shared
.lock()
.expect("PatchSink shared state mutex poisoned");
g.stats.bytes_good
};
state.skip_count = 0;
state.wedge_count = 0;
state.consecutive_failures = 0;
tracing::debug!(
target: "freemkv::disc",
phase = "patch_range_budget",
range_lba = *range_pos / 2048,
range_sectors,
range_budget_secs,
"Per-range time budget computed"
);
loop {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
state.halted = true;
break 'outer;
}
}
if check_range_watchdog(&mut state, &frame, &shared) {
break;
}
if state.skip_count >= MAX_SKIPS_PER_RANGE {
handle_skip_limit(&state, &frame, opts, &pipe)?;
break;
}
let (pos, block_bytes) = if opts.reverse {
if frame.block_end <= frame.range_pos {
break;
}
let span =
(frame.block_end - frame.range_pos).min(state.current_batch as u64 * 2048);
(frame.block_end - span, span)
} else {
if frame.block_end >= frame.end {
break;
}
let span = (frame.end - frame.block_end).min(state.current_batch as u64 * 2048);
(frame.block_end, span)
};
let lba = (pos / 2048) as u32;
let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048;
state.blocks_attempted += 1;
tracing::debug!(
target: "freemkv::disc",
phase = "patch_read_start",
lba,
count,
bytes,
attempt_num = state.blocks_attempted,
range_index = range_idx,
pos_byte = pos,
"Starting sector read"
);
prime_cache(reader, lba, count);
let read_start = std::time::Instant::now();
let read_result = 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; 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];
match reader.read_sectors(
aligned_lba,
aligned_count as u16,
&mut scratch,
state.recovery,
) {
Ok(_) => {
buf[..bytes]
.copy_from_slice(&scratch[head * 2048..head * 2048 + bytes]);
Ok(bytes)
}
Err(e) => Err(e),
}
} else {
reader.read_sectors(lba, count, &mut buf[..bytes], state.recovery)
};
let read_duration_ms = read_start.elapsed().as_millis();
match read_result {
Ok(_) => {
match handle_read_success(
&mut state,
&frame,
opts,
lba,
count,
pos,
block_bytes,
bytes,
&mut buf,
read_duration_ms,
&pipe,
&shared,
reader,
)? {
OuterAction::Break => break 'outer,
OuterAction::Continue => {}
}
}
Err(err) => {
match handle_read_failure(
&mut state,
&frame,
opts,
&err,
lba,
count,
pos,
block_bytes,
bytes,
read_duration_ms,
&pipe,
&shared,
reader,
)? {
FailureAction::Continue => {}
FailureAction::ContinueInner => continue,
FailureAction::BreakOuter => break 'outer,
}
}
}
let did_skip = compute_damage_skip(&mut state, &mut frame, opts, lba, block_bytes);
if !did_skip {
if opts.reverse {
frame.block_end = frame.block_end.saturating_sub(block_bytes);
} else {
frame.block_end += block_bytes;
}
}
if opts.wedged_threshold > 0 && state.consecutive_failures >= opts.wedged_threshold
{
let multi_range_attempted = frame.range_idx > 0;
if multi_range_attempted {
tracing::info!(
target: "freemkv::disc",
phase = "patch_wedged_exit",
consecutive_failures = state.consecutive_failures,
blocks_read_failed = state.blocks_read_failed,
blocks_read_ok = state.blocks_read_ok,
range_index = frame.range_idx,
total_ranges = bad_ranges.len(),
"Disc::patch giving up — drive appears wedged after multiple ranges"
);
state.wedged_exit = true;
break 'outer;
}
}
state.work_done = state.work_done.saturating_add(block_bytes);
if self.report_patch_progress(&state, opts, total_bytes, &shared) {
state.halted = true;
break 'outer;
}
}
}
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::*;
#[test]
fn skip_sectors_for_probe_does_not_overflow_for_large_idx() {
for idx in [0usize, 1, 2, 20, 21, 100, usize::MAX] {
let v = skip_sectors_for_probe(idx);
assert!(
v <= PASSN_SKIP_SECTORS_CAP,
"idx {idx}: {v} exceeds cap {PASSN_SKIP_SECTORS_CAP}"
);
}
assert_eq!(skip_sectors_for_probe(0), PASSN_SKIP_SECTORS_BASE);
assert_eq!(skip_sectors_for_probe(1), PASSN_SKIP_SECTORS_BASE << 3);
}
#[test]
fn skip_limit_remainder_forward_does_not_overlap_recovered_region() {
let r = skip_limit_remainder(false, 1000, 2000, 1700);
assert_eq!(r, Some((1700, 300)));
assert!(r.unwrap().0 >= 1700, "must not overlap recovered region");
}
#[test]
fn skip_limit_remainder_forward_none_when_fully_attempted() {
assert_eq!(skip_limit_remainder(false, 1000, 2000, 2000), None);
}
#[test]
fn skip_limit_remainder_reverse_marks_low_unattempted_region() {
let r = skip_limit_remainder(true, 1000, 2000, 1300);
assert_eq!(r, Some((1000, 300)));
assert_eq!(skip_limit_remainder(true, 1000, 2000, 1000), None);
}
fn opts_with_reverse(reverse: bool) -> crate::disc::PatchOptions<'static> {
crate::disc::PatchOptions {
decrypt: false,
block_sectors: Some(1),
full_recovery: false,
reverse,
wedged_threshold: 50,
progress: None,
halt: None,
}
}
fn state_with_window(bad: usize, escalation: u32) -> PatchLoopState {
let mut s = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
s.damage_window.clear();
for i in 0..PASSN_DAMAGE_WINDOW {
s.damage_window.push(i >= bad); }
s.consecutive_skips_without_recovery = escalation;
s
}
#[test]
fn damage_skip_forward_advances_cursor_toward_end_and_stays_in_range() {
let mut state = state_with_window(4, 0);
let opts = opts_with_reverse(false);
let mut frame = RangeFrame {
range_idx: 0,
range_pos: 0,
range_size: 80 * 1024,
end: 80 * 1024,
block_end: 20 * 1024, range_budget_secs: 1,
range_sectors: 40,
};
let before = frame.block_end;
let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048);
assert!(did, "threshold crossed: a skip must apply");
assert!(
frame.block_end > before,
"forward skip must advance the cursor UP (toward end): {} !> {}",
frame.block_end,
before
);
assert!(
frame.block_end <= frame.end,
"forward cursor {} overshot range end {}",
frame.block_end,
frame.end
);
}
#[test]
fn damage_skip_reverse_moves_cursor_toward_range_start_and_stays_in_range() {
let mut state = state_with_window(4, 0);
let opts = opts_with_reverse(true);
let range_pos = 40 * 1024;
let mut frame = RangeFrame {
range_idx: 0,
range_pos,
range_size: 80 * 1024,
end: range_pos + 80 * 1024,
block_end: range_pos + 60 * 1024, range_budget_secs: 1,
range_sectors: 40,
};
let before = frame.block_end;
let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048);
assert!(did, "threshold crossed: a skip must apply");
assert!(
frame.block_end < before,
"reverse skip must move the cursor DOWN (toward range_pos): {} !< {}",
frame.block_end,
before
);
assert!(
frame.block_end >= frame.range_pos,
"reverse cursor {} descended below range_pos {}",
frame.block_end,
frame.range_pos
);
}
#[test]
fn damage_skip_caps_at_one_quarter_of_remaining_range() {
let mut state = state_with_window(4, 10);
let opts = opts_with_reverse(false);
let mut frame = RangeFrame {
range_idx: 0,
range_pos: 0,
range_size: 80 * 1024,
end: 80 * 1024,
block_end: 0,
range_budget_secs: 1,
range_sectors: 40,
};
let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048);
assert!(did, "threshold crossed: a skip must apply");
let quarter_bytes = (40u64 / 4) * 2048; assert!(
frame.block_end <= quarter_bytes,
"skip advanced cursor to {} bytes, exceeding the 1/4 cap of {} bytes",
frame.block_end,
quarter_bytes
);
assert!(frame.block_end > 0, "a real skip must advance the cursor");
}
#[test]
fn damage_skip_below_threshold_does_not_skip_or_mutate_state() {
let mut state = state_with_window( 0, 0);
let opts = opts_with_reverse(false);
let work_before = state.work_done;
let skips_before = state.skip_count;
let mut frame = RangeFrame {
range_idx: 0,
range_pos: 0,
range_size: 80 * 1024,
end: 80 * 1024,
block_end: 4096,
range_budget_secs: 1,
range_sectors: 40,
};
let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048);
assert!(!did, "clean window must not trigger a damage skip");
assert_eq!(frame.block_end, 4096, "cursor must not move on a no-op");
assert_eq!(
state.work_done, work_before,
"no-op must not charge work_done"
);
assert_eq!(
state.skip_count, skips_before,
"no-op must not bump skip_count"
);
}
#[test]
fn damage_skip_work_done_equals_actual_gap_skipped() {
let mut state = state_with_window( 4, 0);
let opts = opts_with_reverse(true);
let end = 64 * 1024;
let mut frame = RangeFrame {
range_idx: 0,
range_pos: 0,
range_size: end,
end,
block_end: end,
range_budget_secs: 1,
range_sectors: 32,
};
let before = frame.block_end;
let work_before = state.work_done;
let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048);
assert!(did, "threshold crossed: a skip must apply");
let expected_gap = 8 * 2048u64; let actual_gap = before - frame.block_end; assert_eq!(
actual_gap, expected_gap,
"reverse skip should move the cursor down by the quarter-cap gap"
);
assert_eq!(
state.work_done - work_before,
actual_gap,
"work_done must grow by exactly the gap skipped"
);
assert_eq!(state.skip_count, 1, "exactly one skip must be recorded");
}
#[test]
fn fix3_not_ready_does_not_count_toward_consecutive_failures() {
let not_ready_sense = crate::scsi::ScsiSense {
sense_key: 0x02,
asc: 0x04,
ascq: 0x00,
};
let is_not_ready_retryable = {
let s = ¬_ready_sense;
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
};
assert!(
is_not_ready_retryable,
"sense_key=0x02 asc=0x04 must be classified as retryable NOT_READY"
);
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
let failures_before = state.consecutive_failures;
if !is_not_ready_retryable {
state.consecutive_failures += 1;
}
assert_eq!(
state.consecutive_failures, failures_before,
"NOT_READY retry must not increment consecutive_failures"
);
let medium_err_sense = crate::scsi::ScsiSense {
sense_key: 0x03,
asc: 0x11,
ascq: 0x00,
};
let is_not_ready_medium = {
let s = &medium_err_sense;
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
};
assert!(!is_not_ready_medium, "MEDIUM_ERROR must not be NOT_READY");
let failures_before2 = state.consecutive_failures;
if !is_not_ready_medium {
state.consecutive_failures += 1;
}
assert_eq!(
state.consecutive_failures,
failures_before2 + 1,
"non-NOT_READY error must increment consecutive_failures"
);
}
#[test]
fn fix3_not_ready_asc_coverage() {
let check = |sense_key: u8, asc: u8| -> bool {
let s = crate::scsi::ScsiSense {
sense_key,
asc,
ascq: 0,
};
s.sense_key == 0x02 && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04)
};
assert!(check(0x02, 0x02), "ASC 0x02 must be retryable");
assert!(check(0x02, 0x03), "ASC 0x03 must be retryable");
assert!(check(0x02, 0x04), "ASC 0x04 must be retryable");
assert!(
!check(0x02, 0x3A),
"ASC 0x3A (medium not present) must NOT be retryable"
);
assert!(
!check(0x03, 0x04),
"sense_key != 0x02 must not be retryable"
);
}
#[test]
fn fix1_and_fix2_not_ready_stall_guard_and_halt_responsiveness() {
use std::sync::{Arc, atomic::AtomicBool};
let halt = Arc::new(AtomicBool::new(true)); let start = std::time::Instant::now();
super::super::sleep_secs_or_halt(15, Some(&halt));
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(500),
"sleep_secs_or_halt with pre-set halt must return immediately, \
elapsed={elapsed:?}"
);
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
state.stall_start = std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(STALL_SECS + 10))
.unwrap_or(state.stall_start);
let bytes_good_now = state.bytes_good_last; let stall_fires = state.stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS);
assert!(
stall_fires,
"stall guard must fire when stall_start is older than STALL_SECS \
and bytes_good has not advanced (bytes_good_now={bytes_good_now})"
);
state.wedged_exit = true; assert!(
state.wedged_exit,
"wedged_exit must be set when the NOT_READY stall guard fires"
);
}
#[test]
fn fix4_range_watchdog_does_not_spuriously_reset_after_prior_range_recovery() {
use std::sync::{Arc, Mutex};
let current_bytes_good: u64 = 1024 * 1024; let shared = Arc::new(Mutex::new(SharedPatchState {
stats: MapStats {
bytes_total: 0,
bytes_good: current_bytes_good,
bytes_pending: 0,
bytes_unreadable: 0,
bytes_retryable: 0,
bytes_nontried: 0,
num_bad_ranges: 0,
main_lost_ms: 0.0,
},
bad_ranges: vec![],
}));
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
state.range_bytes_good = current_bytes_good; let original_range_start = state.range_start;
let frame = RangeFrame {
range_idx: 1,
range_pos: 0,
range_size: 2048,
end: 2048,
block_end: 2048,
range_budget_secs: 9999,
range_sectors: 1,
};
let timed_out = check_range_watchdog(&mut state, &frame, &*shared);
assert!(!timed_out, "range must not time out immediately");
assert_eq!(
state.range_bytes_good, current_bytes_good,
"range_bytes_good must stay at the current value (no new recovery yet)"
);
let drift = state
.range_start
.checked_duration_since(original_range_start)
.unwrap_or_default();
assert!(
drift < std::time::Duration::from_millis(100),
"range_start must not be reset on the first tick when no new recovery \
occurred in this range (drift={drift:?})"
);
}
#[test]
fn not_ready_per_lba_cap_stops_retrying_and_resets_on_new_lba() {
let lba_a: u32 = 100;
let lba_b: u32 = 200;
let simulate = |state: &mut PatchLoopState, lba: u32| -> bool {
if state.not_ready_lba != Some(lba) {
state.not_ready_retries_per_lba = 0;
state.not_ready_lba = Some(lba);
}
let is_not_ready = true; if is_not_ready {
if state.not_ready_retries_per_lba < NOT_READY_MAX_RETRIES_PER_LBA {
state.not_ready_retries_per_lba += 1;
return true; }
state.consecutive_failures += 1;
}
false };
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
for i in 1..=NOT_READY_MAX_RETRIES_PER_LBA {
let retried = simulate(&mut state, lba_a);
assert!(
retried,
"retry {i}/{NOT_READY_MAX_RETRIES_PER_LBA} on lba_a must return ContinueInner"
);
assert_eq!(
state.not_ready_retries_per_lba, i,
"counter must be {i} after {i} retries"
);
assert_eq!(
state.consecutive_failures, 0,
"consecutive_failures must stay 0 during retries"
);
}
let retried = simulate(&mut state, lba_a);
assert!(
!retried,
"NOT_READY on lba_a after cap must NOT return ContinueInner"
);
assert_eq!(
state.consecutive_failures, 1,
"consecutive_failures must be incremented when cap is exceeded"
);
let retried = simulate(&mut state, lba_b);
assert!(
retried,
"first NOT_READY on lba_b (new LBA) must return ContinueInner \
(counter reset on LBA change)"
);
assert_eq!(
state.not_ready_retries_per_lba, 1,
"counter must restart at 1 after LBA change"
);
assert_eq!(
state.consecutive_failures, 1,
"consecutive_failures must not change on a successful NOT_READY retry after LBA change"
);
}
#[test]
fn fix5_probe_loop_honors_halt_token() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
let consecutive_failures: u64 = 5;
assert!(
consecutive_failures >= 3 && consecutive_failures % 5 == 0,
"probe block gate must be entered at consecutive_failures=5"
);
let halt = Arc::new(AtomicBool::new(true));
let detected = halt.load(Ordering::Relaxed);
assert!(
detected,
"Relaxed load of pre-set AtomicBool must return true — \
the halt check in the probe loop relies on this"
);
assert!(
consecutive_failures >= 5,
"zero-offset probe guard requires consecutive_failures >= 5; \
halt check must fire before this gate is even evaluated"
);
}
#[test]
fn wedge_count_resets_on_success_prevents_premature_abort() {
let wedge_failure = |state: &mut PatchLoopState| {
state.wedge_count += 1;
};
let success = |state: &mut PatchLoopState| {
state.wedge_count = 0;
};
{
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
for _ in 0..10 {
wedge_failure(&mut state);
}
assert_eq!(
state.wedge_count, 10,
"wedge_count must be 10 after 10 consecutive wedge failures"
);
success(&mut state);
assert_eq!(
state.wedge_count, 0,
"wedge_count must reset to 0 on a successful read"
);
for _ in 0..10 {
wedge_failure(&mut state);
}
assert_eq!(
state.wedge_count, 10,
"wedge_count must restart at 10 after reset + 10 more failures"
);
assert!(
state.wedge_count < WEDGE_ABORT_THRESHOLD,
"intermittent pattern (10 + success + 10) must not reach \
WEDGE_ABORT_THRESHOLD ({WEDGE_ABORT_THRESHOLD}); \
wedge_count = {}",
state.wedge_count
);
}
{
let mut state = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40);
for _ in 0..WEDGE_ABORT_THRESHOLD {
wedge_failure(&mut state);
}
assert!(
state.wedge_count >= WEDGE_ABORT_THRESHOLD,
"a true run of {WEDGE_ABORT_THRESHOLD} consecutive wedge failures \
must reach the threshold; wedge_count = {}",
state.wedge_count
);
}
}
#[test]
fn transport_failure_is_recognised_for_patch_abort() {
use crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE;
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"
);
let tf_raw = Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(tf_raw.is_scsi_transport_failure());
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"
);
}
}