v0.13.4: roll back wedge recovery + add sysfs identity fallback

USB/SCSI recovery escalation in drive_has_disc (0.13.1-0.13.3) tested
on LG BU40N USB BD-RE: USBDEVFS_RESET, authorized toggle, driver
unbind/rebind, SCSI host rescan — all succeed at the USB transport
layer but the drive firmware below the bridge stays locked. Only
physical unplug-replug clears it. Rolled back so consumers can
surface the real failure to the user.

New: list_drives falls back to sysfs-cached vendor/model/rev from
/sys/class/scsi_generic/sgN/device/ when live INQUIRY returns empty,
so wedged drives still show their identity in UIs.

Removed: scsi::usb_reset, usb_reset_with_timeout, per-platform
usb_reset methods, recover_then_probe, is_wedge_signature. Breadcrumb
comment in scsi/linux.rs::drive_has_disc points at v0.13.3 tag for
the full implementation if future hardware needs it back.

Linux/macOS/Windows pass-through symmetric; 233 tests passing.
This commit is contained in:
2026-04-24 19:53:44 -07:00
parent 5f495d7a0b
commit 1ffb1d3542
6 changed files with 162 additions and 481 deletions
+47
View File
@@ -1,5 +1,52 @@
# Changelog # Changelog
## 0.13.4 (2026-04-25)
### Wedge recovery rolled back + sysfs identity fallback
**What changed.** The in-library USB / SCSI wedge-recovery escalation
added in 0.13.1 0.13.3 has been removed. `drive_has_disc` now returns
the raw TUR result (or the `0xFF` poll-timeout wedge error) directly to
the caller. `scsi::usb_reset()` / `usb_reset_with_timeout()` /
`DEFAULT_USB_RESET_TIMEOUT_SECS` and the per-platform
`SgIoTransport::usb_reset` / `MacScsiTransport::usb_reset` /
`SptiTransport::usb_reset` are gone. All three platform backends pass
transport errors through verbatim, keeping the public
`list_drives` + `drive_has_disc` contract symmetric
(Linux / macOS / Windows).
**Why.** Production testing against the LG BU40N USB BD-RE (the drive
that drove the whole 0.13.10.13.3 recovery push) showed:
- `SG_SCSI_RESET`, `STOP UNIT` + `START UNIT`, and `USBDEVFS_RESET`
all succeed at the USB transport layer (kernel logs
`usb 3-2: reset high-speed USB device`, device re-authorises).
- But the drive firmware *below* the USB bridge stays locked: no
LUN enumerates on the fresh `scsi_host`, TUR never succeeds,
`/dev/sg*` never reappears.
- Also tried (outside the lib): `/sys/bus/usb/devices/<port>/authorized`
toggle, `usb-storage` driver unbind/rebind, forced SCSI host rescan.
All same outcome.
Only physical unplug-replug (or host reboot) clears this wedge class.
The library was logging 2-minute-per-tick escalation cycles for nothing,
and consumers had no way to surface "drive needs physical intervention"
to users because the escalation was masking the real failure. Upper
layers (autorip, CLI) now see the wedge error directly and prompt the
user.
A breadcrumb in `scsi/linux.rs::drive_has_disc` catalogues every
recovery method tried and points to git tag `v0.13.3` for the full
implementation, in case a future hardware class is found where
USB-layer recovery actually works.
**New: sysfs-cached identity fallback (Linux).** `list_drives` now
populates empty INQUIRY vendor/model/firmware fields from
`/sys/class/scsi_generic/sgN/device/{vendor,model,rev}` — the kernel
runs its own INQUIRY at device probe time and stashes the answer there,
so even a mid-wedge INQUIRY still yields the UI a human-readable
identity. The drive surface on screen doesn't suddenly go blank the
moment the drive firmware locks up.
## 0.13.3 (2026-04-24) ## 0.13.3 (2026-04-24)
### Bug fix — `drive_has_disc` wedge recovery was dead code for TUR errors ### Bug fix — `drive_has_disc` wedge recovery was dead code for TUR errors
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.13.3" version = "0.13.4"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+76 -181
View File
@@ -19,15 +19,6 @@ const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3; const SG_DXFER_FROM_DEV: i32 = -3;
const SG_FLAG_Q_AT_HEAD: u32 = 0x10; const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
/// `USBDEVFS_RESET = _IO('U', 20)` — re-enumerates the USB device,
/// equivalent to a software unplug-replug. Resets at the USB layer
/// *below* SCSI, which is what's needed when the USB Mass Storage
/// interface itself wedges (the wedge mode `SG_SCSI_RESET` can't
/// recover, since SCSI commands never make it through the broken USB
/// link to the device). 30-line `usbreset.c` everyone passes around
/// uses this same ioctl.
const USBDEVFS_RESET: u32 = 0x5514;
#[repr(C)] #[repr(C)]
#[allow(non_camel_case_types)] #[allow(non_camel_case_types)]
struct sg_io_hdr { struct sg_io_hdr {
@@ -181,106 +172,6 @@ impl SgIoTransport {
Ok(()) Ok(())
} }
/// USB-layer reset. Resolves the sg device → underlying USB device
/// (`/dev/bus/usb/BBB/DDD`) and issues `USBDEVFS_RESET`, the same
/// ioctl `usbreset.c` uses. Software equivalent of unplug-replug.
///
/// Returns `DeviceNotFound` if the sg device isn't USB-attached
/// (SATA/PERC etc.) so callers can detect the fall-through case and
/// know not to retry — USB reset is meaningless for non-USB drives.
/// Returns `DeviceResetFailed` for actual ioctl failures.
///
/// Step-by-step:
/// 1. `/dev/sg4` → device name `sg4`
/// 2. Canonicalize `/sys/class/scsi_generic/sg4/device` to follow
/// the kernel's symlink chain into `/sys/devices/pci…/usb1/1-2/…`
/// 3. Walk parents until we find a directory that has both
/// `busnum` and `devnum` files — that's the USB device node
/// 4. Read `busnum` + `devnum`, format `/dev/bus/usb/{busnum:03}/{devnum:03}`
/// 5. open(O_WRONLY), ioctl(USBDEVFS_RESET), close
pub fn usb_reset(device: &Path) -> Result<()> {
let usb_path = Self::resolve_usb_device(device)?;
let c_path = Self::to_c_path(&usb_path);
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_WRONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(Error::DeviceResetFailed {
path: usb_path.display().to_string(),
});
}
// USBDEVFS_RESET — kernel does its own bounded wait here (the
// USB stack waits for the device to come back, typically ≤1 s).
// Unlike SG_SCSI_RESET this rarely hangs because the kernel USB
// layer has its own timeouts on the device-side handshake.
let r = unsafe { libc::ioctl(fd, USBDEVFS_RESET as _) };
unsafe { libc::close(fd) };
if r < 0 {
Err(Error::DeviceResetFailed {
path: usb_path.display().to_string(),
})
} else {
Ok(())
}
}
/// Resolve `/dev/sgN` → `/dev/bus/usb/BBB/DDD` for USB-attached SCSI
/// devices. Returns `DeviceNotFound` (not a reset failure) when the
/// sg device isn't USB-attached, so callers can distinguish "this
/// drive isn't a USB drive" from "USB reset attempted but failed".
fn resolve_usb_device(device: &Path) -> Result<std::path::PathBuf> {
let dev_name =
device
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let sysfs_link = format!("/sys/class/scsi_generic/{dev_name}/device");
let canonical = std::fs::canonicalize(&sysfs_link).map_err(|_| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Walk up the parent chain looking for a directory that
// contains both `busnum` and `devnum`. That marks the USB
// device entry in sysfs (e.g. /sys/devices/.../usb1/1-2/).
let mut cur = canonical.as_path();
while let Some(parent) = cur.parent() {
let busnum_p = parent.join("busnum");
let devnum_p = parent.join("devnum");
if busnum_p.exists() && devnum_p.exists() {
let busnum: u32 = std::fs::read_to_string(&busnum_p)
.ok()
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let devnum: u32 = std::fs::read_to_string(&devnum_p)
.ok()
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
return Ok(std::path::PathBuf::from(format!(
"/dev/bus/usb/{busnum:03}/{devnum:03}"
)));
}
cur = parent;
}
// No USB ancestor found — SATA / RAID / non-USB SCSI device.
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
fn open_error<T>(device: &Path) -> Result<T> { fn open_error<T>(device: &Path) -> Result<T> {
let err = std::io::Error::last_os_error(); let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied { Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
@@ -581,26 +472,39 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
if !std::path::Path::new(&path).exists() { if !std::path::Path::new(&path).exists() {
continue; continue;
} }
// Read sysfs-cached identity first. The kernel runs its own INQUIRY
// at device probe time and stashes vendor/model/rev under
// `/sys/class/scsi_generic/sgN/device/`. Those values survive even
// when the drive firmware is wedged below the USB bridge (our own
// INQUIRY times out but sysfs still has the pre-wedge answer), so
// the UI always has a human-readable identity to show.
let (sysfs_vendor, sysfs_model, sysfs_firmware) = sysfs_identity(&name);
// INQUIRY-only probe — open transport, run INQUIRY, drop. No // INQUIRY-only probe — open transport, run INQUIRY, drop. No
// identify, no init, no firmware reset preamble's secondary // identify, no init, no firmware reset preamble's secondary
// commands beyond what `SgIoTransport::open` already does (one // commands beyond what `SgIoTransport::open` already does (one
// SCSI bus reset on the kernel SG fd, ~2 s). // SCSI bus reset on the kernel SG fd, ~2 s).
let mut transport = match SgIoTransport::open(std::path::Path::new(&path)) { let info = match SgIoTransport::open(std::path::Path::new(&path)) {
Ok(t) => t, Ok(mut transport) => match super::inquiry(&mut transport) {
Err(_) => continue,
};
let info = match super::inquiry(&mut transport) {
Ok(r) => super::DriveInfo { Ok(r) => super::DriveInfo {
path: path.clone(), path: path.clone(),
vendor: r.vendor_id, vendor: pick_identity(r.vendor_id, &sysfs_vendor),
model: r.model, model: pick_identity(r.model, &sysfs_model),
firmware: r.firmware, firmware: pick_identity(r.firmware, &sysfs_firmware),
}, },
Err(_) => super::DriveInfo { Err(_) => super::DriveInfo {
path: path.clone(), path: path.clone(),
vendor: String::new(), vendor: sysfs_vendor,
model: String::new(), model: sysfs_model,
firmware: String::new(), firmware: sysfs_firmware,
},
},
Err(_) => super::DriveInfo {
path: path.clone(),
vendor: sysfs_vendor,
model: sysfs_model,
firmware: sysfs_firmware,
}, },
}; };
out.push(info); out.push(info);
@@ -608,6 +512,29 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
out out
} }
/// Prefer the live INQUIRY answer over the sysfs-cached one, but fall
/// back to sysfs when the live answer is empty (wedge / bridge bug).
fn pick_identity(live: String, sysfs: &str) -> String {
let trimmed = live.trim();
if trimmed.is_empty() {
sysfs.to_string()
} else {
live
}
}
/// Read the kernel's cached INQUIRY identity strings for `sgN` from
/// `/sys/class/scsi_generic/sgN/device/{vendor,model,rev}`. Empty strings
/// when sysfs is unavailable (minimal container, non-Linux filesystem).
fn sysfs_identity(name: &str) -> (String, String, String) {
let read = |field: &str| -> String {
std::fs::read_to_string(format!("/sys/class/scsi_generic/{name}/device/{field}"))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
(read("vendor"), read("model"), read("rev"))
}
/// Enumerate `sg*` names via `/sys/class/scsi_generic/`, filtered to /// Enumerate `sg*` names via `/sys/class/scsi_generic/`, filtered to
/// SCSI peripheral type 5 (optical). Falls back to a `sg0..15` probe /// SCSI peripheral type 5 (optical). Falls back to a `sg0..15` probe
/// when sysfs is unreadable. Returns names sorted lexically so caller /// when sysfs is unreadable. Returns names sorted lexically so caller
@@ -641,19 +568,36 @@ fn enumerate_sg_names() -> Vec<String> {
names names
} }
/// `drive_has_disc` = single TEST UNIT READY. Any error (including our
/// synthesised wedge signature, `ScsiError { status: 0xFF }`, when
/// `execute()` times out) bubbles straight up to the caller.
///
/// ## No in-library wedge recovery — and why
///
/// Versions 0.13.1 0.13.3 layered `scsi::reset()` + `scsi::usb_reset()`
/// (`USBDEVFS_RESET`) escalation inside `drive_has_disc`. Production
/// testing on the LG BU40N USB BD-RE showed all three userspace recovery
/// ladders succeed at the USB transport level (the kernel logs
/// `usb 3-2: reset high-speed USB device`, the device re-authorises
/// and re-attaches on a fresh `scsi_host`) **but the drive firmware
/// below the USB bridge stays locked** — no LUN ever enumerates, TUR
/// never succeeds, /dev/sg* never reappears. Physical power-cycle
/// (unplug-replug or host reboot) is the only recovery.
///
/// Methods tried and discarded:
/// - `SG_SCSI_RESET` (device-level SCSI bus reset)
/// - `STOP UNIT` / `START UNIT` CDB pair
/// - `USBDEVFS_RESET` ioctl on `/dev/bus/usb/BBB/DDD`
/// - `/sys/bus/usb/devices/<port>/authorized` 0→1 toggle
/// - `/sys/bus/usb/drivers/usb-storage/{unbind,bind}` driver rebind
/// - Forced `echo "- - -" > /sys/class/scsi_host/hostN/scan`
///
/// Rolled back in 0.13.4. Callers (autorip, CLI) surface the error
/// directly and prompt the user to physically reconnect the drive.
/// If a future hardware class is found where USB-layer recovery
/// actually works, the escalation belongs here, gated on the wedge
/// signature — see git tag `v0.13.3` for the full implementation.
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
match probe_tur(path) {
Ok(present) => Ok(present),
Err(e) if is_wedge_signature(&e) => recover_then_probe(path, e),
Err(e) => Err(e),
}
}
/// Single TEST UNIT READY — the cheapest way to ask "is there a disc?".
/// Returns `Ok(true)` on a sense-clean OK, `Ok(false)` on sense-key 2
/// ("not ready, medium not present"), and `Err` for any other failure
/// (the wedge case lands here too — caller's escalation handles it).
fn probe_tur(path: &Path) -> Result<bool> {
let mut transport = SgIoTransport::open(path)?; let mut transport = SgIoTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0]; let mut buf = [0u8; 0];
@@ -671,52 +615,3 @@ fn probe_tur(path: &Path) -> Result<bool> {
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
/// Two-stage wedge recovery: SCSI reset → USB reset → retry probe.
/// Caller has already classified the original error as a wedge.
fn recover_then_probe(path: &Path, original: Error) -> Result<bool> {
// Stage 1: SCSI bus reset. Bounded by `DEFAULT_RESET_TIMEOUT_SECS`.
let _ = super::reset(path);
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
// Stage 2: USB-layer re-enumeration (USBDEVFS_RESET). Software
// equivalent of unplug-replug; the only thing that recovers a
// kernel-level USB Mass Storage wedge.
if super::usb_reset(path).is_ok() {
std::thread::sleep(std::time::Duration::from_secs(USB_RESET_SETTLE_SECS));
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
}
// Both stages exhausted — surface the original error so the caller
// can choose to back off / mark this drive stay-clear.
Err(original)
}
/// Wedge signature: `Error::ScsiError` with status byte 0xFF, for any
/// opcode. 0xFF isn't a real SCSI status — our own `execute()` path
/// synthesises it when poll() times out waiting for the kernel to
/// deliver a response. That timeout is the ground-truth signature of
/// the USB Mass Storage layer wedging; opcode-in-flight is incidental.
fn is_wedge_signature(err: &Error) -> bool {
matches!(
err,
Error::ScsiError {
status: WEDGE_STATUS_BYTE,
..
}
)
}
/// Synthesised SCSI status byte returned by our own transport when
/// poll() on the SG fd times out — the wedge signature. Real SCSI
/// statuses are GOOD (0x00), CHECK_CONDITION (0x02), BUSY (0x08), etc.;
/// 0xFF is reserved/invalid in the spec, so we can't collide with a
/// real device response. Applies to any opcode (TUR, INQUIRY, READ, …).
const WEDGE_STATUS_BYTE: u8 = 0xFF;
/// Settle time after `USBDEVFS_RESET` returns. The kernel re-enumerates
/// the device over ~1-2 s; sleeping briefly avoids racing the next
/// `Drive::open` against an interim sysfs-vanished state.
const USB_RESET_SETTLE_SECS: u64 = 2;
+12 -180
View File
@@ -277,147 +277,8 @@ impl MacScsiTransport {
std::thread::sleep(std::time::Duration::from_secs(2)); std::thread::sleep(std::time::Duration::from_secs(2));
Ok(()) Ok(())
} }
/// USB-layer reset on macOS via `IOUSBDeviceInterface::ResetDevice`.
///
/// Mirrors the Linux `USBDEVFS_RESET` path: walk from the BSD-named
/// SCSI service up the IORegistry plane to the parent `IOUSBDevice`,
/// query its `IOUSBDeviceInterface`, call `ResetDevice()`. Software
/// equivalent of unplug-replug — the only thing that recovers a
/// kernel-level USB Mass Storage wedge on macOS.
///
/// Returns `DeviceNotFound` when the device isn't USB-attached
/// (Thunderbolt/SATA/internal SuperDrive over PCIe — they don't
/// have an `IOUSBDevice` ancestor) so the caller's escalation can
/// fall through cleanly. `DeviceResetFailed` on actual reset
/// failures.
pub fn usb_reset(device: &Path) -> Result<()> {
let bsd_name =
device
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let service = find_scsi_service(bsd_name)?;
let usb_service = walk_to_usb_device(service);
unsafe { IOObjectRelease(service) };
let usb_service = usb_service.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Get IOUSBDeviceInterface from the USB device service.
let mut plugin: ComRef = std::ptr::null_mut();
let mut score: i32 = 0;
let kr = unsafe {
IOCreatePlugInInterfaceForService(
usb_service,
&K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID,
&K_IO_CFPLUGIN_INTERFACE_ID,
&mut plugin,
&mut score,
)
};
unsafe { IOObjectRelease(usb_service) };
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::DeviceResetFailed {
path: device.display().to_string(),
});
}
// QueryInterface for IOUSBDeviceInterface.
let mut device_iface: ComRef = std::ptr::null_mut();
let hr = unsafe {
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
let qi: QiFn = vtable_fn(plugin, 1);
qi(plugin, &K_IO_USB_DEVICE_INTERFACE_ID, &mut device_iface)
};
com_release(plugin);
if hr != 0 || device_iface.is_null() {
return Err(Error::DeviceResetFailed {
path: device.display().to_string(),
});
}
// Call ResetDevice() — vtable index 11 in IOUSBDeviceInterface.
// Verified against IOUSBLib.h headers (Apple OSS).
let kr = unsafe {
type ResetFn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: ResetFn = vtable_fn(device_iface, K_IO_USB_DEVICE_RESET_VTABLE_INDEX);
f(device_iface)
};
com_release(device_iface);
if kr != K_IO_RETURN_SUCCESS {
Err(Error::DeviceResetFailed {
path: device.display().to_string(),
})
} else {
Ok(())
}
}
} }
/// `kIOUSBDeviceUserClientTypeID` — IOKit plugin type for accessing a
/// USB device through user-space (the gateway to `IOUSBDeviceInterface`).
const K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
0x9D, 0xC7, 0xB7, 0x80, 0x9E, 0xC0, 0x11, 0xD4, 0xA5, 0x4F, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
/// `kIOUSBDeviceInterfaceID` — `IOUSBDeviceInterface` (revision 0).
/// Sufficient for `ResetDevice()` which has been at vtable index 11
/// since the original interface revision.
const K_IO_USB_DEVICE_INTERFACE_ID: [u8; 16] = [
0x5C, 0x81, 0x87, 0xD0, 0x9E, 0xF3, 0x11, 0xD4, 0x8B, 0x45, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
/// Vtable index of `IOUSBDeviceInterface::ResetDevice`. Per IOUSBLib.h:
/// the interface inherits from IOCFPlugInInterface which occupies slots
/// 0..2 (QueryInterface, AddRef, Release), then IOUSBDeviceInterface
/// methods start at slot 3. ResetDevice is the 9th IOUSBDevice-specific
/// method → slot 3 + 8 = 11.
const K_IO_USB_DEVICE_RESET_VTABLE_INDEX: usize = 11;
/// Walk up the IORegistry plane from a SCSI peripheral service to the
/// parent `IOUSBDevice` (if any). Mirrors the Linux sysfs walk in
/// `linux::SgIoTransport::resolve_usb_device`. Returns the IOService
/// for the USB device (caller owns the reference; release with
/// `IOObjectRelease`), or `None` for non-USB-attached drives.
fn walk_to_usb_device(start: IOObject) -> Option<IOObject> {
let mut current = start;
// Retain the start so we can release uniformly each loop iteration.
unsafe {
let kr = IOObjectRetain(current);
if kr != K_IO_RETURN_SUCCESS {
return None;
}
}
for _ in 0..K_USB_PARENT_WALK_LIMIT {
if unsafe { IOObjectConformsTo(current, c"IOUSBDevice".as_ptr() as *const u8) } != 0 {
return Some(current);
}
let mut parent: IOObject = 0;
let kr = unsafe {
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
};
unsafe { IOObjectRelease(current) };
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
return None;
}
current = parent;
}
unsafe { IOObjectRelease(current) };
None
}
/// Maximum IORegistry parent-chain depth searched for a USB ancestor.
/// Real chains for USB-attached optical drives are 6-10 entries deep
/// (IOMedia → BlockStorageDriver → SCSIPeripheralDeviceNub →
/// SCSIProtocolEmulator → IOUSBInterface → IOUSBDevice → ...). 32 is
/// generous; if we don't find it by then, the device isn't USB.
const K_USB_PARENT_WALK_LIMIT: u32 = 32;
/// Enumerate optical drives on macOS. Mirrors `drive::macos::find_drives` /// Enumerate optical drives on macOS. Mirrors `drive::macos::find_drives`
/// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral /// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral
/// type 5). Same logic, exposed through the new `DriveInfo` shape so /// type 5). Same logic, exposed through the new `DriveInfo` shape so
@@ -467,57 +328,28 @@ const K_INQUIRY_TYPE_MASK: u8 = 0x1F;
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE). /// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE).
const K_SCSI_TYPE_OPTICAL: u8 = 0x05; const K_SCSI_TYPE_OPTICAL: u8 = 0x05;
/// TEST UNIT READY probe on macOS. Same shape as the Linux impl — /// TEST UNIT READY probe on macOS. Any non-"not ready" error bubbles up
/// open transport, run TUR, classify response. The macOS path doesn't /// to the caller — in-library wedge recovery was rolled back in 0.13.4
/// surface the Linux `0xff`-status wedge pattern (IOKit returns its /// after USB-layer resets failed to recover the LG BU40N on Linux; the
/// own error codes), so wedge-detection here is sense-key based: a /// macOS impl mirrors that choice for symmetry. See the Linux
/// transport-level error during TUR escalates to SCSI reset → USB /// `drive_has_disc` in `scsi/linux.rs` for the full rationale.
/// reset. Most macOS drives auto-recover at the SCSI-reset stage.
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
match probe_tur(path) {
Ok(present) => Ok(present),
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false),
Err(_) => recover_then_probe(path),
}
}
const K_SENSE_KEY_NOT_READY: u8 = 2;
fn probe_tur(path: &Path) -> Result<bool> {
let mut transport = MacScsiTransport::open(path)?; let mut transport = MacScsiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0]; let mut buf = [0u8; 0];
transport match transport.execute(
.execute(
&cdb, &cdb,
crate::scsi::DataDirection::None, crate::scsi::DataDirection::None,
&mut buf, &mut buf,
crate::scsi::TUR_TIMEOUT_MS, crate::scsi::TUR_TIMEOUT_MS,
) ) {
.map(|_| true) Ok(_) => Ok(true),
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false),
Err(e) => Err(e),
}
} }
fn recover_then_probe(path: &Path) -> Result<bool> { const K_SENSE_KEY_NOT_READY: u8 = 2;
let _ = super::reset(path);
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
if super::usb_reset(path).is_ok() {
std::thread::sleep(std::time::Duration::from_secs(K_USB_RESET_SETTLE_SECS));
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
}
Err(Error::DeviceResetFailed {
path: path.display().to_string(),
})
}
const K_USB_RESET_SETTLE_SECS: u64 = 2;
unsafe extern "C" {
fn IOObjectRetain(object: IOObject) -> IOReturn;
}
impl Drop for MacScsiTransport { impl Drop for MacScsiTransport {
fn drop(&mut self) { fn drop(&mut self) {
+18 -88
View File
@@ -196,94 +196,24 @@ fn reset_blocking(device: &Path) -> Result<()> {
} }
} }
/// Default upper bound on `scsi::usb_reset()`. Kernel USB stack // ── USB-layer recovery: rolled back in 0.13.4 ───────────────────────────────
/// typically completes a port reset in under a second; 5 s is generous //
/// while still catching a hang in the driver. // 0.13.1 0.13.3 exposed `scsi::usb_reset()` (`USBDEVFS_RESET` on Linux,
pub(crate) const DEFAULT_USB_RESET_TIMEOUT_SECS: u64 = 5; // `IOUSBDeviceInterface::ResetDevice` on macOS) and chained it into
// `drive_has_disc` recovery. Production testing on the LG BU40N USB BD-RE
/// USB-layer reset for USB-attached SCSI devices. // confirmed the USB stack resets succeed — dmesg logs
/// // `usb 3-2: reset high-speed USB device` and the device re-authorises —
/// `pub(crate)` — outside callers reach recovery through the // but the drive firmware below the USB bridge stays locked: LUN never
/// higher-level `drive_has_disc` (poll-loop entry point) which folds // re-enumerates, TUR still times out, the drive is unusable until
/// in the SCSI→USB escalation internally. See module-level docs for // physical unplug-replug or host reboot. Additional approaches tried
/// the architectural reasoning. // and discarded: `authorized` 0→1 toggle, usb-storage driver
/// // unbind/rebind, forced SCSI host rescan, `STOP` + `START UNIT`.
/// **When to call this.** `scsi::reset()` resets at the SCSI layer; if //
/// the wedge is at the USB Mass Storage interface *below* SCSI (the // The APIs were removed so no caller can be misled into thinking a
/// classic mode where INQUIRY returns status `0xFF` because the kernel // software-only recovery exists for this class of wedge. If a future
/// got nothing back), SCSI commands never reach the device and SCSI // hardware class surfaces where USB-layer recovery actually helps, the
/// reset is meaningless. USB reset re-enumerates the device at the USB // code should live here again, gated on a wedge signature — see git
/// layer — software equivalent of unplug-replug. // tag `v0.13.3` for the full implementation.
///
/// **Linux**: resolves `/dev/sgN` → `/dev/bus/usb/BBB/DDD` via sysfs,
/// then `USBDEVFS_RESET` ioctl. Same mechanism as the well-known
/// `usbreset.c` snippet.
///
/// **macOS**, **Windows**: returns `DeviceNotFound` for now (stub).
/// Real impls tracked for 0.13.3.
///
/// **Returns** `DeviceNotFound` when the sg device isn't USB-attached
/// (SATA / RAID / NVMe-passthrough sg nodes) so callers can detect the
/// fall-through case and know not to retry — USB reset is meaningless
/// for those drives.
///
/// Wraps the platform call in a thread + `recv_timeout` for the same
/// reason as `reset()` — kernel ioctls can hang and we don't want the
/// caller's poll loop wedged on it.
pub(crate) fn usb_reset(device: &Path) -> Result<()> {
usb_reset_with_timeout(
device,
std::time::Duration::from_secs(DEFAULT_USB_RESET_TIMEOUT_SECS),
)
}
/// USB reset with a caller-specified timeout. See [`usb_reset`].
pub(crate) fn usb_reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
let device_owned = device.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("usb-reset".into())
.spawn(move || {
let r = usb_reset_blocking(&device_owned);
let _ = tx.send(r);
})
.map_err(|_| Error::DeviceResetFailed {
path: device.display().to_string(),
})?;
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(_) => Err(Error::DeviceResetFailed {
path: device.display().to_string(),
}),
}
}
fn usb_reset_blocking(device: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
linux::SgIoTransport::usb_reset(device)
}
#[cfg(target_os = "macos")]
{
macos::MacScsiTransport::usb_reset(device)
}
#[cfg(target_os = "windows")]
{
windows::SptiTransport::usb_reset(device)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = device;
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
}
// ── Lightweight discovery + presence probes ───────────────────────────────── // ── Lightweight discovery + presence probes ─────────────────────────────────
// //
+2 -25
View File
@@ -185,28 +185,6 @@ impl SptiTransport {
std::thread::sleep(std::time::Duration::from_secs(2)); std::thread::sleep(std::time::Duration::from_secs(2));
Ok(()) Ok(())
} }
/// USB-layer reset on Windows. Returns `DeviceNotFound` —
/// **intentional**, not a stub.
///
/// Why: `reset()` above already issues `IOCTL_STORAGE_RESET_DEVICE`
/// which goes through `storport.sys`. On Windows that single IOCTL
/// covers both the SCSI layer **and** the USB Mass Storage layer
/// for storport-attached devices — functionally equivalent to
/// Linux's `SG_SCSI_RESET` + `USBDEVFS_RESET` combined into one
/// call. So Windows doesn't need a separate USB-layer step in the
/// recovery escalation; `drive_has_disc`'s second stage gracefully
/// falls through (it treats `DeviceNotFound` as "not applicable
/// for this platform / this drive type").
///
/// If a future case is found where storport's reset doesn't reach
/// the USB layer (e.g. raw Win USB devices that bypass storport),
/// this can become a real `IOCTL_USB_HUB_CYCLE_PORT_EX` impl.
pub fn usb_reset(device: &Path) -> Result<()> {
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
} }
/// Enumerate optical drives on Windows via `find_drives()` (CdRom0..15 /// Enumerate optical drives on Windows via `find_drives()` (CdRom0..15
@@ -224,9 +202,8 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
.collect() .collect()
} }
/// TEST UNIT READY probe on Windows. Wedge recovery on this platform /// TEST UNIT READY probe on Windows. No in-library recovery — see the
/// goes through `IOCTL_STORAGE_RESET_DEVICE` (already in `reset()`); /// Linux `drive_has_disc` doc block for the rationale.
/// USB-layer cycle-port is stubbed — see `usb_reset` above.
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> { pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
let mut transport = SptiTransport::open(path)?; let mut transport = SptiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];