v0.13.12 — Fix 1+2+4 + cross-platform SCSI parity (RIP_DESIGN.md §6, §7, §15.1)

Fix 1: delete stall guard from Disc::copy. Pass 1 must sweep end-to-end
per ddrescue model (RIP_DESIGN.md §2.1, §3, §9). The v0.13.9 guard at
disc/mod.rs broke Pass 1 at 30% on Dune 2 with 56 GB still NonTried.
Removed stall_secs field, narrative comment in scsi/linux.rs, and the
broken regression test. Replaced with test_disc_copy_completes_full_disc_
with_failing_reader and test_disc_copy_halts_promptly_on_failing_reader.

Fix 2: async SCSI transport recovery. Added Arc<AtomicI32> fd_recovery
on SgIoTransport. On poll timeout: spawn close + spawn open in
background, return Err immediately. Top of execute() swaps fd from
recovery atomic. Main thread never blocked beyond ~1.5s poll budget
(was up to ~60s per timeout because kernel serialized main-thread
open() against in-flight close()). Drop drains pending recovery fd.

§15.1 cross-platform parity: Windows + macOS now have the same
observable recovery contract. SptiTransport gets try_recover()
(synchronous CloseHandle + CreateFileW; Windows close is fast, no
in-flight CDB drain like Linux). MacScsiTransport gets try_recover()
(release IOKit interface + reacquire via new acquire_device_iface()
helper); stores bsd_name for re-resolution. Drop guards null'd-out
interfaces. Stripped English error strings ("try as root" / "run as
administrator") on Linux + Windows. Fixed Windows TimeOutValue
ms→s ceiling so 1500ms gets 2s (was 1s; broke Drive::read fast path).

Fix 4: instrument Disc::patch arms. PatchResult exposes
blocks_attempted, blocks_read_ok, blocks_read_failed so the v0.13.11
mystery (Dune 2 Pass 2 recovered 0 bytes in 100 min) is diagnosable
from the live device log without re-instrumenting from outside.

Cleanup: honor PatchOptions::full_recovery (was read into _ and
ignored; now routed to read_sectors recovery arg). Updated
CopyOptions::batch_sectors doc to describe the actual production
path (sysfs detect_max_batch_sectors, typically 60 sectors / ~120 KB
on BU40N) rather than the test-only 32-sector internal default.

All four crates clippy-clean and tests green on the host targets
(macOS native + cargo check on Linux). Cross-platform CI watches
Linux + Windows + macOS builds + tests.
This commit is contained in:
2026-04-25 17:30:25 -07:00
parent 9d3022d457
commit 870623dc86
7 changed files with 449 additions and 219 deletions
+61 -41
View File
@@ -54,8 +54,18 @@ const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
pub struct SgIoTransport {
fd: i32,
device_path: std::path::PathBuf,
/// Background-recovered fd. After a poll timeout `execute()` spawns a
/// thread that closes `self.fd` and opens a fresh fd; the new fd is
/// stored here. The next call to `execute()` swaps it into `self.fd`.
/// `-1` means no recovery is ready (or the recovery open failed). See
/// RIP_DESIGN.md §7 for the design rationale.
fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
}
// SgIoTransport's contained types (i32, PathBuf, Arc<AtomicI32>) are all
// Send; the auto-derived Send is intentional. Sync is NOT — callers must
// hold &mut for execute(), which the trait object dispatch enforces.
impl SgIoTransport {
/// Open a SCSI device for use. Resets the drive first to ensure
/// a known good state, then opens a fresh fd for commands.
@@ -75,6 +85,7 @@ impl SgIoTransport {
Ok(SgIoTransport {
fd,
device_path: device,
fd_recovery: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(-1)),
})
}
@@ -140,10 +151,7 @@ impl SgIoTransport {
let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
Error::DevicePermission {
path: format!(
"{}: permission denied (try running as root)",
device.display()
),
path: device.display().to_string(),
}
} else {
Error::DeviceNotFound {
@@ -219,6 +227,13 @@ impl Drop for SgIoTransport {
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
}
// Drain any background-recovered fd so it doesn't leak.
let recovered = self
.fd_recovery
.swap(-1, std::sync::atomic::Ordering::Acquire);
if recovered >= 0 {
unsafe { libc::close(recovered) };
}
}
}
@@ -245,10 +260,21 @@ impl ScsiTransport for SgIoTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
// Recover from a prior timeout: if a background reopen produced a
// fresh fd, swap it in. If recovery is still pending (-1), the
// background thread hasn't finished — return DeviceNotFound and let
// the caller's retry loop come back later.
if self.fd < 0 {
return Err(Error::DeviceNotFound {
path: self.device_path.display().to_string(),
});
let recovered = self
.fd_recovery
.swap(-1, std::sync::atomic::Ordering::Acquire);
if recovered >= 0 {
self.fd = recovered;
} else {
return Err(Error::DeviceNotFound {
path: self.device_path.display().to_string(),
});
}
}
let mut sense = [0u8; 32];
@@ -320,43 +346,37 @@ impl ScsiTransport for SgIoTransport {
};
if pr <= 0 {
// Timeout (0) or fatal poll error (-1).
// Command is still pending in the kernel. Spawn a background
// close of the old fd (which blocks until the kernel
// completes/aborts the pending command) and open a fresh fd
// on the main thread. The main-thread open() can serialize
// against the in-flight close via the kernel's per-device
// state lock — so this call may block up to ~60 s while
// the kernel finishes the abandoned command. That's the
// cost of keeping the Drive alive across a timeout. The
// Disc::copy stall guard (v0.13.9, default 120 s of
// bytes_good non-advance) is the upper bound that prevents
// a catastrophic grind on a wedged read region.
//
// History:
// - 0.13.5 and earlier: same as this — but with no upper
// bound, hence 45-min hangs.
// - 0.13.10: tried "set fd=-1, no reopen" — too aggressive,
// one transient timeout killed the whole transport, Pass
// 1 finished in 45 ms with everything NonTrimmed.
// - 0.13.11 (this): same close+reopen as 0.13.5/8 BUT with
// the v0.13.9 stall guard ensuring Disc::copy bails out
// cleanly within 120 s of zero forward progress.
// Timeout (0) or fatal poll error (-1). Command is still pending
// in the kernel. Per RIP_DESIGN.md §4(b)/§7: close + reopen run
// in a background thread so the main thread is never blocked
// beyond the poll() budget. The recovered fd is published to
// `fd_recovery`; the next call to execute() picks it up.
let old_fd = self.fd;
self.fd = -1;
std::thread::spawn(move || {
unsafe { libc::close(old_fd) };
});
let c_path = Self::to_c_path(&self.device_path);
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
self.fd = if new_fd >= 0 { new_fd } else { -1 };
let recovery = self.fd_recovery.clone();
std::thread::spawn(move || {
// Close blocks until the kernel finishes/aborts the
// abandoned command. Then we open a fresh fd. Both happen
// off the main thread.
unsafe { libc::close(old_fd) };
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if new_fd >= 0 {
let prev = recovery.swap(new_fd, std::sync::atomic::Ordering::Release);
if prev >= 0 {
// Stale recovery fd from a prior unclaimed attempt;
// close it so it doesn't leak.
unsafe { libc::close(prev) };
}
} else {
recovery.store(-1, std::sync::atomic::Ordering::Release);
}
});
return Err(Error::ScsiError {
opcode: cdb[0],