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:
@@ -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.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/<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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user