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
+71 -10
View File
@@ -175,6 +175,9 @@ const VTIDX_EXECUTE_SYNC: usize = 15;
pub struct MacScsiTransport {
device_iface: ComRef,
exclusive: bool,
/// BSD name (e.g. "disk2") retained for `try_recover()` after a
/// task-level failure. Without it we can't re-call `find_scsi_service`.
bsd_name: String,
}
// IOKit COM interface pointers are Mach port references — safe to send between threads.
@@ -195,6 +198,19 @@ impl MacScsiTransport {
dev_str
};
let device_iface = Self::acquire_device_iface(bsd_name)?;
Ok(MacScsiTransport {
device_iface,
exclusive: true,
bsd_name: bsd_name.to_string(),
})
}
/// Resolve the BSD name → IOKit SCSITaskDeviceInterface with exclusive
/// access. Shared between `open()` and `try_recover()`. Returns the
/// COM ref the caller must release.
fn acquire_device_iface(bsd_name: &str) -> Result<ComRef> {
let service = find_scsi_service(bsd_name)?;
// Create IOKit plugin for the MMC device
@@ -213,7 +229,7 @@ impl MacScsiTransport {
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::IoKitPluginFailed {
path: dev_str.to_string(),
path: bsd_name.to_string(),
kr: kr as u32,
});
}
@@ -233,7 +249,7 @@ impl MacScsiTransport {
if hr != 0 || device_iface.is_null() {
return Err(Error::ScsiInterfaceUnavailable {
path: dev_str.to_string(),
path: bsd_name.to_string(),
});
}
@@ -245,19 +261,48 @@ impl MacScsiTransport {
};
if kr != K_IO_RETURN_SUCCESS {
com_release(device_iface);
// No "Try: diskutil unmountDisk" hint — that's the CLI's job.
// The typed variant carries device path + IOReturn so the
// caller can render the right message in the right language.
return Err(Error::DeviceLocked {
path: dev_str.to_string(),
path: bsd_name.to_string(),
kr: kr as u32,
});
}
Ok(MacScsiTransport {
device_iface,
exclusive: true,
})
Ok(device_iface)
}
/// Recover the IOKit interface after a task-level failure. Releases
/// the current device_iface and re-acquires fresh state via
/// `acquire_device_iface`. Same observable contract as the Linux fd
/// recovery: after `try_recover()`, the next `execute()` either uses a
/// fresh interface or returns `DeviceNotFound` if recovery failed.
///
/// Synchronous because IOKit `RELEASE_EXCLUSIVE` + `com_release` don't
/// block on in-flight CDBs the way Linux SG_IO `close` does.
fn try_recover(&mut self) {
if !self.device_iface.is_null() {
if self.exclusive {
unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
f(self.device_iface);
}
self.exclusive = false;
}
com_release(self.device_iface);
self.device_iface = std::ptr::null_mut();
}
match Self::acquire_device_iface(&self.bsd_name) {
Ok(new_iface) => {
self.device_iface = new_iface;
self.exclusive = true;
}
Err(_) => {
// Leave device_iface null; next execute() returns
// DeviceNotFound. Caller's retry path will reopen Drive.
self.device_iface = std::ptr::null_mut();
self.exclusive = false;
}
}
}
// `reset()` removed in 0.13.6 — see scsi/mod.rs for rationale.
@@ -337,6 +382,9 @@ const K_SENSE_KEY_NOT_READY: u8 = 2;
impl Drop for MacScsiTransport {
fn drop(&mut self) {
if self.device_iface.is_null() {
return;
}
if self.exclusive {
unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
@@ -356,6 +404,15 @@ impl ScsiTransport for MacScsiTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
// Per RIP_DESIGN.md §15.1: parity with Linux/Windows recovery
// contract. If a prior execute() invalidated the interface and
// try_recover() also failed, fail fast.
if self.device_iface.is_null() {
return Err(Error::DeviceNotFound {
path: self.bsd_name.clone(),
});
}
// Create a SCSI task
let task: ComRef = unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
@@ -363,6 +420,7 @@ impl ScsiTransport for MacScsiTransport {
f(self.device_iface)
};
if task.is_null() {
self.try_recover();
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
@@ -433,6 +491,9 @@ impl ScsiTransport for MacScsiTransport {
com_release(task);
if kr != K_IO_RETURN_SUCCESS {
// Task-level failure (timeout / IOKit error). Recover the
// interface so the caller's retry path can resume.
self.try_recover();
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,