diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d130df..c417a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # 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.1–0.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//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) ### Bug fix — `drive_has_disc` wedge recovery was dead code for TUR errors diff --git a/Cargo.toml b/Cargo.toml index a366c96..e69b33d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.13.3" +version = "0.13.4" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/scsi/linux.rs b/src/scsi/linux.rs index c0a7aca..9911161 100644 --- a/src/scsi/linux.rs +++ b/src/scsi/linux.rs @@ -19,15 +19,6 @@ const SG_DXFER_TO_DEV: i32 = -2; const SG_DXFER_FROM_DEV: i32 = -3; 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)] #[allow(non_camel_case_types)] struct sg_io_hdr { @@ -181,106 +172,6 @@ impl SgIoTransport { 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 { - 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(device: &Path) -> Result { let err = std::io::Error::last_os_error(); Err(if err.kind() == std::io::ErrorKind::PermissionDenied { @@ -581,26 +472,39 @@ pub(super) fn list_drives() -> Vec { if !std::path::Path::new(&path).exists() { 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 // identify, no init, no firmware reset preamble's secondary // commands beyond what `SgIoTransport::open` already does (one // SCSI bus reset on the kernel SG fd, ~2 s). - let mut transport = match SgIoTransport::open(std::path::Path::new(&path)) { - Ok(t) => t, - Err(_) => continue, - }; - let info = match super::inquiry(&mut transport) { - Ok(r) => super::DriveInfo { - path: path.clone(), - vendor: r.vendor_id, - model: r.model, - firmware: r.firmware, + let info = match SgIoTransport::open(std::path::Path::new(&path)) { + Ok(mut transport) => match super::inquiry(&mut transport) { + Ok(r) => super::DriveInfo { + path: path.clone(), + vendor: pick_identity(r.vendor_id, &sysfs_vendor), + model: pick_identity(r.model, &sysfs_model), + firmware: pick_identity(r.firmware, &sysfs_firmware), + }, + Err(_) => super::DriveInfo { + path: path.clone(), + vendor: sysfs_vendor, + model: sysfs_model, + firmware: sysfs_firmware, + }, }, Err(_) => super::DriveInfo { path: path.clone(), - vendor: String::new(), - model: String::new(), - firmware: String::new(), + vendor: sysfs_vendor, + model: sysfs_model, + firmware: sysfs_firmware, }, }; out.push(info); @@ -608,6 +512,29 @@ pub(super) fn list_drives() -> Vec { 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 /// SCSI peripheral type 5 (optical). Falls back to a `sg0..15` probe /// when sysfs is unreadable. Returns names sorted lexically so caller @@ -641,19 +568,36 @@ fn enumerate_sg_names() -> Vec { 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//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 { - 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 { let mut transport = SgIoTransport::open(path)?; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; let mut buf = [0u8; 0]; @@ -671,52 +615,3 @@ fn probe_tur(path: &Path) -> Result { 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 { - // 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; diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index dde4954..dc28582 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -277,147 +277,8 @@ impl MacScsiTransport { std::thread::sleep(std::time::Duration::from_secs(2)); 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 { - 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` /// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral /// type 5). Same logic, exposed through the new `DriveInfo` shape so @@ -467,58 +328,29 @@ const K_INQUIRY_TYPE_MASK: u8 = 0x1F; /// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE). const K_SCSI_TYPE_OPTICAL: u8 = 0x05; -/// TEST UNIT READY probe on macOS. Same shape as the Linux impl — -/// open transport, run TUR, classify response. The macOS path doesn't -/// surface the Linux `0xff`-status wedge pattern (IOKit returns its -/// own error codes), so wedge-detection here is sense-key based: a -/// transport-level error during TUR escalates to SCSI reset → USB -/// reset. Most macOS drives auto-recover at the SCSI-reset stage. +/// TEST UNIT READY probe on macOS. Any non-"not ready" error bubbles up +/// to the caller — in-library wedge recovery was rolled back in 0.13.4 +/// after USB-layer resets failed to recover the LG BU40N on Linux; the +/// macOS impl mirrors that choice for symmetry. See the Linux +/// `drive_has_disc` in `scsi/linux.rs` for the full rationale. pub(super) fn drive_has_disc(path: &Path) -> Result { - match probe_tur(path) { - Ok(present) => Ok(present), + let mut transport = MacScsiTransport::open(path)?; + let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; + let mut buf = [0u8; 0]; + match transport.execute( + &cdb, + crate::scsi::DataDirection::None, + &mut buf, + crate::scsi::TUR_TIMEOUT_MS, + ) { + Ok(_) => Ok(true), Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false), - Err(_) => recover_then_probe(path), + Err(e) => Err(e), } } const K_SENSE_KEY_NOT_READY: u8 = 2; -fn probe_tur(path: &Path) -> Result { - let mut transport = MacScsiTransport::open(path)?; - let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0]; - let mut buf = [0u8; 0]; - transport - .execute( - &cdb, - crate::scsi::DataDirection::None, - &mut buf, - crate::scsi::TUR_TIMEOUT_MS, - ) - .map(|_| true) -} - -fn recover_then_probe(path: &Path) -> Result { - 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 { fn drop(&mut self) { if self.exclusive { diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 0a710b8..2391688 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -196,94 +196,24 @@ fn reset_blocking(device: &Path) -> Result<()> { } } -/// Default upper bound on `scsi::usb_reset()`. Kernel USB stack -/// typically completes a port reset in under a second; 5 s is generous -/// while still catching a hang in the driver. -pub(crate) const DEFAULT_USB_RESET_TIMEOUT_SECS: u64 = 5; - -/// USB-layer reset for USB-attached SCSI devices. -/// -/// `pub(crate)` — outside callers reach recovery through the -/// higher-level `drive_has_disc` (poll-loop entry point) which folds -/// in the SCSI→USB escalation internally. See module-level docs for -/// the architectural reasoning. -/// -/// **When to call this.** `scsi::reset()` resets at the SCSI layer; if -/// the wedge is at the USB Mass Storage interface *below* SCSI (the -/// classic mode where INQUIRY returns status `0xFF` because the kernel -/// got nothing back), SCSI commands never reach the device and SCSI -/// reset is meaningless. USB reset re-enumerates the device at the USB -/// layer — software equivalent of unplug-replug. -/// -/// **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(), - }) - } -} +// ── USB-layer recovery: rolled back in 0.13.4 ─────────────────────────────── +// +// 0.13.1 – 0.13.3 exposed `scsi::usb_reset()` (`USBDEVFS_RESET` on Linux, +// `IOUSBDeviceInterface::ResetDevice` on macOS) and chained it into +// `drive_has_disc` recovery. Production testing on the LG BU40N USB BD-RE +// confirmed the USB stack resets succeed — dmesg logs +// `usb 3-2: reset high-speed USB device` and the device re-authorises — +// but the drive firmware below the USB bridge stays locked: LUN never +// re-enumerates, TUR still times out, the drive is unusable until +// physical unplug-replug or host reboot. Additional approaches tried +// and discarded: `authorized` 0→1 toggle, usb-storage driver +// unbind/rebind, forced SCSI host rescan, `STOP` + `START UNIT`. +// +// The APIs were removed so no caller can be misled into thinking a +// software-only recovery exists for this class of wedge. If a future +// hardware class surfaces where USB-layer recovery actually helps, the +// code should live here again, gated on a wedge signature — see git +// tag `v0.13.3` for the full implementation. // ── Lightweight discovery + presence probes ───────────────────────────────── // diff --git a/src/scsi/windows.rs b/src/scsi/windows.rs index 33b9935..3b6994e 100644 --- a/src/scsi/windows.rs +++ b/src/scsi/windows.rs @@ -185,28 +185,6 @@ impl SptiTransport { std::thread::sleep(std::time::Duration::from_secs(2)); 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 @@ -224,9 +202,8 @@ pub(super) fn list_drives() -> Vec { .collect() } -/// TEST UNIT READY probe on Windows. Wedge recovery on this platform -/// goes through `IOCTL_STORAGE_RESET_DEVICE` (already in `reset()`); -/// USB-layer cycle-port is stubbed — see `usb_reset` above. +/// TEST UNIT READY probe on Windows. No in-library recovery — see the +/// Linux `drive_has_disc` doc block for the rationale. pub(super) fn drive_has_disc(path: &Path) -> Result { let mut transport = SptiTransport::open(path)?; let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];