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 {
fn from_map(map: &Mapfile) -> Self {
Self {
stats: map.stats(),
bad_ranges: map.ranges_with(&[
SectorStatus::NonTrimmed,
SectorStatus::Unreadable,
SectorStatus::NonScraped,
SectorStatus::NonTried,
]),
}
}
}
pub(super) struct PatchSummary {
pub stats: MapStats,
}
pub(super) struct PatchSink {
file: crate::io::WritebackFile,
map: Mapfile,
is_regular: bool,
shared: Arc<Mutex<SharedPatchState>>,
}
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,
},
shared_clone,
))
}
fn republish(&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();
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();
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 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 base = PASSN_SKIP_SECTORS_BASE as i64;
let escalation = (idx * 3) as i64;
let shifted = if escalation < 64 {
base << escalation
} else {
base
};
shifted.min(PASSN_SKIP_SECTORS_CAP as i64) as u64
}
pub(super) fn send_or_abort(
pipe: &Pipeline<PatchItem, PatchSummary>,
item: PatchItem,
) -> Result<()> {
pipe.send(item).map_err(|_| Error::IoError {
source: std::io::Error::other("patch consumer terminated unexpectedly"),
})
}
#[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 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),
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;
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;
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 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_failures += 1;
state.consecutive_good_since_skip = 0;
state.consecutive_singles_ok = 0;
state.unreadable_count += 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"
);
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 {
tracing::info!(
target: "freemkv::disc",
phase = "patch_not_ready_retry",
lba,
consecutive_failures = state.consecutive_failures,
err_asc = sense.map(|s| s.asc as u32).unwrap_or(0),
"NOT_READY with ASC=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"
);
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
state.damage_window.push(false);
if state.damage_window.len() > PASSN_DAMAGE_WINDOW {
state.damage_window.remove(0);
}
return Ok(FailureAction::ContinueInner);
}
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: [u64; 3] = [0, skip_sectors_for_probe(1), skip_sectors_for_probe(2)];
let mut probes_ok = 0;
for (probe_idx, &offset) in probe_offsets.iter().enumerate() {
if offset >= block_bytes || (offset == 0 && state.consecutive_failures < 5) {
continue;
}
let probe_pos = pos + offset;
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"
);
std::thread::sleep(std::time::Duration::from_secs(pause_secs));
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 {
if state.range_start.elapsed().as_secs() > frame.range_budget_secs {
tracing::warn!(
target: "freemkv::disc",
phase = "patch_range_timeout",
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 timeout - moving to next range"
);
return true;
}
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",
);
let unmarked_bytes = frame.block_end.saturating_sub(frame.range_pos);
if opts.reverse {
send_or_abort(
pipe,
PatchItem::NonTrimmed {
pos: frame.range_pos,
len: unmarked_bytes,
},
)?;
} else {
let remaining_start = frame.range_pos + (frame.end - frame.block_end);
if remaining_start < frame.end {
send_or_abort(
pipe,
PatchItem::NonTrimmed {
pos: remaining_start,
len: frame.end - remaining_start,
},
)?;
}
}
Ok(())
}
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 << state.consecutive_skips_without_recovery)
.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 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)?;
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 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);
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 = state.bytes_good_before;
state.skip_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 =
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()?;
Ok(build_outcome(
&state,
&summary,
path,
total_bytes,
bad_ranges.len(),
opts.wedged_threshold,
))
}
}