Compare commits

...
4 Commits
Author SHA1 Message Date
MattJackson 43836865be v0.13.6: strip Drive::read inline recovery + reset escalation; emit BytesRead
Drive::read is now single-shot. Phase 1/2/3 retries + scsi::reset+reopen
removed (~80 lines). recovery=true bumps timeout to 30s; recovery=false
stays at 1.5s. On any failure returns Err(DiscRead) immediately — caller
(Disc::patch outer loop, DiscStream batch halver) handles retries.

Inline reset+reopen WAS the wedge primitive on the LG BU40N. Per prior
post-mortem, every USB/SCSI reset path tested fails to recover the
wedged Initio bridge — the inline retry was pure cost.

SgIoTransport::reset (Linux) trimmed to kernel SG_IO state flush +
ALLOW MEDIUM REMOVAL. SG_SCSI_RESET ioctl + STOP/START UNIT escalation
removed. macOS reset removed (no-op). scsi::reset() top-level family
removed (no callers).

EventKind::BytesRead { bytes, total } now actually emitted from
DiscStream::fill_extents after each successful sector read. Was
declared in 0.13.0, never fired. Drives autorip per-device progress
in direct mode.

EventKind::Retry / SectorRecovered no longer emitted (variants kept
for forward compat). SpeedChange still emitted via Drive::set_speed
public path.

Tests: new tests/integration_progress_and_halt.rs (5 tests). 233 unit
tests + 5 integration green.
2026-04-24 21:32:45 -07:00
MattJackson 5e22199441 v0.13.5: version sync (autorip-side fixes) 2026-04-24 20:13:07 -07:00
MattJackson 761b77bbd9 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.
2026-04-24 19:53:44 -07:00
MattJackson cc05ef2a3a v0.13.3: broaden is_wedge_signature — fix dead-code wedge recovery
0.13.2's is_wedge_signature gated on opcode=SCSI_INQUIRY (0x12), but
drive_has_disc issues TEST UNIT READY (0x00). Production wedge errors
(E4000: 0x00/0xff/0x00) never matched → SCSI reset + USB reset
escalation never fired.

Drop the opcode gate. Status byte 0xFF is synthesised by our own
execute() path on poll() timeout — it's the ground-truth wedge
marker for any opcode.

Linux-only; macOS/Windows use sense-key-based wedge detection.
2026-04-24 19:26:11 -07:00
12 changed files with 699 additions and 784 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo test
- run: cargo test --tests
check-macos:
runs-on: macos-latest
+128
View File
@@ -1,5 +1,133 @@
# Changelog
## 0.13.6 (2026-04-25)
### Inline retry/reset stripped from `Drive::read`; `BytesRead` now emitted
Two related changes that close the loop on the BU40N wedge work from
0.13.1–0.13.4 and on the long-standing autorip "0 KB/s, 0%" UI bug.
**`Drive::read` is now single-shot.** The phase 1 / 2 / 3 retry loop
(reset → reopen → repeat) inside `Drive::read` is gone (~80 lines
deleted). `recovery=true` only bumps the per-CDB timeout to 30 s;
`recovery=false` keeps the 1.5 s timeout. On a failed read the
function returns `Err(DiscRead)` immediately. Per the BU40N
post-mortem, every USB / SCSI reset path tested in 0.13.1–0.13.3
resets the bridge but not the drive firmware, and the inline
reset+reopen *was* the wedge primitive itself — issuing it from
inside `Drive::read` produced multi-minute hangs and made the wedge
class harder to surface to the user. The correct retry layer is
`Disc::patch`'s outer multi-pass loop, which is unaffected. A stuck
drive now surfaces as a clean `DiscRead` to the caller, who can
prompt physical replug.
**SCSI reset surface trimmed.** `SgIoTransport::reset` (Linux) drops
the `SG_SCSI_RESET` ioctl and the STOP / START UNIT escalation; it
keeps the kernel `SG_IO` state flush plus `ALLOW MEDIUM REMOVAL`.
`MacScsiTransport::reset` is removed entirely (was open + drop +
sleep, no SCSI). The top-level `scsi::reset` /
`scsi::reset_with_timeout` / `scsi::reset_blocking` family is
removed — no callers remain after the `Drive::read` strip.
**`EventKind::BytesRead` now emitted.** The variant was declared in
0.13.0 but never fired. `DiscStream::fill_extents` now emits
`BytesRead { bytes_read_total, total_extents_bytes }` after every
successful sector read, so consumers in direct (no-mapfile) mode can
drive a real-time progress bar without polling `output.bytes_written`.
Multi-pass mode continues to use `Disc::copy`'s `on_progress`
callback unchanged. Drives the autorip per-device live progress UI.
`Drive::checked_sleep` is removed (only used by the recovery loop);
`Drive::sleep_until_halted` is `#[cfg(test)]`-only; `Drive::emit` is
retained because `BytesRead` uses it.
### Tests
- New `tests/integration_progress_and_halt.rs` (5 tests): `BytesRead`
emission, `Disc::copy` `on_progress` regression guard, halt aborts
copy, Drop safety, `FileSectorReader` round-trip.
- 233 unit tests + 5 integration tests pass.
### Net diff
~80 lines deleted, ~20 added.
### Version sync
0.13.6 ecosystem release (libfreemkv + freemkv + bdemu + autorip all
on 0.13.6).
## 0.13.5 (2026-04-25)
### Version sync — no functional changes
Sync bump for the ecosystem. All four freemkv crates (libfreemkv,
freemkv CLI, bdemu, autorip) always share a version number; 0.13.5
carries autorip-side fixes (stop-is-reset, startup staging sweep).
## 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)
### Bug fix — `drive_has_disc` wedge recovery was dead code for TUR errors
The wedge-signature predicate introduced in 0.13.2 gated on
`opcode == SCSI_INQUIRY (0x12)` — a holdover from when enumerate-time
INQUIRY was the only path wedges surfaced on. `drive_has_disc` issues
`TEST UNIT READY (0x00)`, so its wedge errors (`E4000: 0x00/0xff/0x00`)
never matched the predicate and the SCSI-reset + USB-reset escalation
never fired. Production result: the BU40N USB BD-RE stayed wedged
indefinitely, with autorip logging `recovery exhausted` on the raw
pass-through error while no recovery had actually been attempted.
Fix: drop the opcode constraint. Status byte `0xFF` is synthesised by
our own `execute()` path when `poll()` on the SG fd times out; it's
the ground-truth wedge marker regardless of which opcode was in flight.
Doc comments on `is_wedge_signature` and `WEDGE_STATUS_BYTE` updated
accordingly. Linux-only — macOS / Windows use sense-key-based wedge
detection and are unaffected.
## 0.13.2 (2026-04-24)
### Public discovery + presence APIs; SCSI/USB primitives no longer
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.13.2"
version = "0.13.6"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
@@ -31,6 +31,9 @@ libc = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
[[bench]]
name = "sgio_read"
harness = false
+20
View File
@@ -0,0 +1,20 @@
# libfreemkv — local dev helper.
# Mirrors the cross-crate scripts in (internal)/scripts/test-all.sh
# but scoped to this single crate.
.PHONY: test build check ci clean
test:
cargo test --tests
build:
cargo build --release
check:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
ci: check build test
clean:
cargo clean
+5 -5
View File
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.11"
libfreemkv = "0.13"
```
## Quick Start
@@ -84,7 +84,7 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
- **Content reading** — adaptive batch reads with automatic decryption
- **Stream I/O** — unified stream pipeline for reading and writing any format
### Streams
@@ -92,21 +92,21 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
| Stream | Input | Output | Transport |
|--------|-------|--------|-----------|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | Yes | Blu-ray ISO image file |
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::copy()`) |
| MkvStream | Yes | Yes | Matroska container |
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream instances. All URLs use the `scheme://path` format — bare paths are rejected.
Streams implement `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
## Architecture
```text
Drive — open, identify, init, unlock, read (with recovery)
Drive — open, identify, init, unlock, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
├── DriveProfile — per-drive unlock parameters (bundled)
└── PlatformDriver — MediaTek (supported), Renesas (planned)
+40 -123
View File
@@ -53,9 +53,6 @@ const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
const SCSI_MODE_SENSE: u8 = 0x5A;
const SCSI_REPORT_KEY: u8 = 0xA4;
/// Recovery state after a read error — stay at min speed for N bytes.
const RECOVERY_WINDOW: u64 = 500 * 1024 * 1024; // 500 MB
/// Optical disc drive session -- open, identify, unlock, and read.
pub struct Drive {
scsi: Box<dyn ScsiTransport>,
@@ -64,11 +61,9 @@ pub struct Drive {
pub platform: Option<profile::Platform>,
pub drive_id: DriveId,
device_path: String,
/// Bytes remaining in the min-speed recovery window.
recovery_bytes_remaining: u64,
/// Halt flag — when set, Drive::read() bails at the next check point.
halt: Arc<AtomicBool>,
/// Event handler — fires during read recovery.
/// Event handler — fires for read errors and library-level state changes.
event_fn: Option<Box<dyn Fn(Event) + Send>>,
}
@@ -95,7 +90,6 @@ impl Drive {
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
recovery_bytes_remaining: 0,
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
})
@@ -131,13 +125,6 @@ impl Drive {
self.halt.load(Ordering::Relaxed)
}
/// Halt-aware sleep. Returns `Err(Halted)` if the flag fires before or
/// during the wait. The only sleep the drive's recovery path is allowed
/// to use — makes it impossible to accidentally block through a Stop.
fn checked_sleep(&self, total: std::time::Duration) -> Result<()> {
sleep_until_halted(&self.halt, total)
}
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
/// before the command dispatches or by the time it completes. The only
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
@@ -519,29 +506,25 @@ impl Drive {
}
}
/// Read sectors from the disc with automatic error recovery.
/// Read sectors from the disc. Single-shot — no inline retries, no
/// SCSI reset.
///
/// On failure: drops to min speed, waits with escalating patience
/// (5s, 10s, 15s, 30s, 60s), resets drive between attempts.
/// After recovery, stays at min speed for 500 MB before ramping up.
/// `recovery=true` bumps the per-CDB timeout to 30 s for the
/// `Disc::patch` pass; `recovery=false` uses 1.5 s for `Disc::copy`'s
/// fast skip-forward sweep. On any failure returns `Err(DiscRead)`
/// immediately. The orchestration layer (`Disc::patch`'s outer loop
/// for the patch pass, `DiscStream`'s adaptive batch halving for the
/// stream path) handles retries.
///
/// Returns Err only after all attempts exhausted — user should clean
/// the disc and resume.
/// Inline retry phases (5× gentle + reset+reopen + 5× more) were
/// removed in 0.13.6. Per
/// `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`,
/// the inline reset on the LG BU40N (Initio bridge) wedged drive
/// firmware without ever recovering a sector. The remaining recovery
/// layers (Disc::patch multi-pass, DiscStream batch halving) do not
/// touch the wedge-prone reset path.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
// Non-recovery mode is the Disc::copy fast pass — intent is "fail
// fast, let skip_forward advance past bad regions." A 5s budget let
// the drive grind L-EC on structure-protected / marginal sectors for
// nearly the full interval per 64 KB block, pinning throughput at
// ~13 KB/s on difficult discs. 1500ms kills slow reads early so
// skip_forward can double its stride; recoverable sectors are picked
// up on Disc::patch where recovery=true and the timeout is 30s.
let timeout_ms = if !recovery {
1_500
} else if self.recovery_bytes_remaining > 0 {
30_000
} else {
10_000
};
let timeout_ms = if recovery { 30_000 } else { 1_500 };
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
@@ -555,82 +538,16 @@ impl Drive {
0x00,
];
// Normal read. Fast path: drive returns data, we return it.
// checked_exec short-circuits to Err(Halted) if Stop was requested;
// read() never touches the halt flag directly.
match self.checked_exec(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
timeout_ms,
) {
Ok(result) => {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
}
}
return Ok(result.bytes_transferred);
}
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => { /* fall through to recovery */ }
Ok(result) => Ok(result.bytes_transferred),
Err(Error::Halted) => Err(Error::Halted),
Err(_) => Err(Error::DiscRead { sector: lba as u64 }),
}
if !recovery {
return Err(Error::DiscRead { sector: lba as u64 });
}
// Enter recovery
self.emit(EventKind::ReadError {
sector: lba as u64,
error: Error::DiscRead { sector: lba as u64 },
});
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
self.set_speed(0);
// Phase 1: gentle — sleep 30 s, retry. 5 times.
for attempt in 1..=5u32 {
self.emit(EventKind::Retry { attempt });
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
// Phase 2: fresh start — close, reset, open, init.
let device = std::path::PathBuf::from(&self.device_path);
self.checked_sleep(std::time::Duration::from_secs(5))?;
let _ = crate::scsi::reset(&device);
self.checked_sleep(std::time::Duration::from_secs(5))?;
self.scsi = crate::scsi::open(&device)?;
let _ = self.init();
let _ = self.wait_ready();
self.set_speed(0);
// Phase 3: gentle again on fresh connection — sleep 30 s, retry. 5 times.
for attempt in 6..=10u32 {
self.emit(EventKind::Retry { attempt });
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
// Both phases failed.
self.recovery_bytes_remaining = RECOVERY_WINDOW;
Err(Error::DiscRead { sector: lba as u64 })
}
/// Read the disc capacity in sectors (2048 bytes each).
@@ -742,11 +659,26 @@ impl SectorReader for Drive {
}
}
/// Halt-aware sleep primitive. Returns `Err(Halted)` if the flag is set
/// before or during the wait. Extracted as a free function so it can be
/// unit-tested without constructing a full `Drive`.
///
/// Wakes within ~100 ms of a halt, regardless of the requested duration.
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
discover_drives()
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
.collect()
}
/// Find the first optical drive.
/// Returns an opened Drive ready for use.
pub fn find_drive() -> Option<Drive> {
find_drives().into_iter().next()
}
/// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping
/// to true. Kept for the unit tests that cover the slicing behaviour;
/// production code paths no longer sleep on the recovery hot path
/// (recovery loop removed in 0.13.6).
#[cfg(test)]
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
let deadline = std::time::Instant::now() + total;
@@ -763,21 +695,6 @@ fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<(
}
}
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
discover_drives()
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
.collect()
}
/// Find the first optical drive.
/// Returns an opened Drive ready for use.
pub fn find_drive() -> Option<Drive> {
find_drives().into_iter().next()
}
/// Internal: discover drive paths + IDs without opening full Drive objects.
fn discover_drives() -> Vec<(String, DriveId)> {
#[cfg(target_os = "linux")]
+17
View File
@@ -127,6 +127,14 @@ pub struct DiscStream {
event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool,
// Cumulative bytes successfully read from the source. Drives
// EventKind::BytesRead emission and autorip's per-device progress.
bytes_read_total: u64,
// Pre-computed total of all extents in bytes (or 0 if extents are
// empty). Carried in EventKind::BytesRead.total so consumers can show
// a percent without a separate API call.
bytes_total_extents: u64,
// PES output
ts_demuxer: Option<super::ts::TsDemuxer>,
ps_demuxer: Option<super::ps::PsDemuxer>,
@@ -149,6 +157,8 @@ impl DiscStream {
content_format: crate::disc::ContentFormat,
) -> Self {
let extents = title.extents.clone();
let bytes_total_extents: u64 =
extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
let mut pids = Vec::new();
let mut parsers = Vec::new();
@@ -194,6 +204,8 @@ impl DiscStream {
halt: None,
event_fn: None,
eof: false,
bytes_read_total: 0,
bytes_total_extents,
ts_demuxer,
ps_demuxer,
parsers,
@@ -287,6 +299,11 @@ impl DiscStream {
}
self.buf_valid = bytes;
self.current_offset += sectors as u32;
self.bytes_read_total = self.bytes_read_total.saturating_add(bytes as u64);
self.emit(EventKind::BytesRead {
bytes: self.bytes_read_total,
total: self.bytes_total_extents,
});
break;
}
+103 -243
View File
@@ -12,22 +12,11 @@ use crate::error::{Error, Result};
use std::path::Path;
const SG_IO: u32 = 0x2285;
const SG_SCSI_RESET: u32 = 0x2284;
const SG_SCSI_RESET_DEVICE: i32 = 1;
const SG_DXFER_NONE: i32 = -1;
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 {
@@ -89,49 +78,32 @@ impl SgIoTransport {
})
}
/// Reset the drive to a known good state — equivalent to unplug/replug.
/// After reset, the drive is clean and no fd is held open.
/// Clean up kernel SG_IO state and unlock the tray. NOT a hardware
/// reset — purely software cleanup before this process opens the
/// device for real work.
///
/// ## Why each step exists
/// When a previous process is killed (SIGKILL) mid-SG_IO, the kernel
/// may hold queued commands against the dead fd, and `Drop` never
/// ran so the tray may still be locked via PREVENT MEDIUM REMOVAL.
/// This routine handles both: open + close flushes the kernel SG
/// queue (sg_release cancels commands tied to the fd), the 2 s sleep
/// gives the kernel time to finish that cleanup, then a fresh fd
/// sends ALLOW MEDIUM REMOVAL to clear any stale tray lock.
///
/// When a process is killed (SIGKILL/kill -9) mid-SG_IO ioctl, two things
/// go wrong: (1) the kernel's SG driver may have stale pending commands
/// queued for the dead process's fd, and (2) the drive firmware may still
/// be mid-operation (seeking, reading, processing a vendor command).
///
/// A new process opening the same /dev/sg* device gets a fresh fd, but the
/// kernel doesn't automatically abort the dead process's commands — the
/// drive can appear hung on the first SCSI command.
///
/// Additionally, killed processes skip Drop, so the tray may be locked
/// via PREVENT MEDIUM REMOVAL with no process alive to unlock it.
///
/// ## Sequence
///
/// 1. **open** — allocates kernel SG state for this fd
/// 2. **close** — triggers kernel cleanup: aborts any pending SG_IO
/// commands associated with this fd. The key operation —
/// the kernel's sg_release() cancels queued commands.
/// 3. **sleep 2s** — the drive firmware needs time to finish/abort whatever
/// it was doing when the previous process died. Without
/// this, the next command may block on drive-internal state.
/// 4. **open** — fresh fd with no stale commands in the kernel queue
/// 5. **unlock** — ALLOW MEDIUM REMOVAL (CDB 0x1E, prevent=0). Clears
/// any tray lock left by a killed process that never
/// ran its Drop/cleanup.
/// 6. **TUR** — TEST UNIT READY (CDB 0x00) with 3s timeout. If the
/// drive responds, it's in a good state.
/// 7. **escalate** — if TUR fails:
/// - SG_SCSI_RESET (device level) — kernel sends a SCSI
/// bus reset to the device, clearing all firmware state.
/// - STOP + START UNIT (CDB 0x1B) — power-cycles the
/// drive's logical unit, like pressing the eject button
/// and reinserting.
/// 8. **close** — release the fd. Drive is clean, nobody holds it.
/// We do NOT verify the drive with TUR or escalate to SG_SCSI_RESET /
/// STOP+START UNIT. Both escalations were tried in 0.13.0–0.13.5
/// against the LG BU40N (Initio USB-SATA bridge); both failed to
/// recover wedged drives and made the wedge worse — see
/// (internal)/postmortems/2026-04-25-bu40n-wedge-recovery.md.
/// If the drive is genuinely unresponsive, the next workload command
/// fails naturally and the caller surfaces a "physical reconnect
/// required" prompt. Software has no path back from a wedged Initio
/// bridge — only physical replug clears it.
pub fn reset(device: &Path) -> Result<()> {
let c_path = Self::to_c_path(device);
// Step 1-2: open + close — flush stale kernel SG_IO state
// open + close — make the kernel cancel any SG_IO commands queued
// against a previous fd that didn't close cleanly.
let probe_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
@@ -142,10 +114,10 @@ impl SgIoTransport {
unsafe { libc::close(probe_fd) };
}
// Step 3: let drive settle
// Let the kernel finish that cancellation before we reopen.
std::thread::sleep(std::time::Duration::from_secs(2));
// Step 4: open clean fd
// Fresh fd just to send the unlock command, then close.
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
@@ -156,131 +128,14 @@ impl SgIoTransport {
return Self::open_error(device);
}
// Step 5: unlock tray
// ALLOW MEDIUM REMOVAL — clear any tray lock left by a killed
// process whose Drop never ran. Best-effort; ignore result.
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
// Step 6: TUR — if drive responds, we're done
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
// Step 7: escalate — SG_SCSI_RESET
let mut reset_type: i32 = SG_SCSI_RESET_DEVICE;
unsafe { libc::ioctl(fd, SG_SCSI_RESET as _, &mut reset_type) };
std::thread::sleep(std::time::Duration::from_secs(3));
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
// STOP + START
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x00, 0], 3_000);
std::thread::sleep(std::time::Duration::from_secs(1));
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x01, 0], 3_000);
std::thread::sleep(std::time::Duration::from_secs(3));
let _ = Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000);
}
}
// Step 8: close — drive is clean
unsafe { libc::close(fd) };
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> {
let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
@@ -581,26 +436,39 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
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 +476,29 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
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 +532,36 @@ fn enumerate_sg_names() -> Vec<String> {
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> {
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 cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0];
@@ -671,51 +579,3 @@ fn probe_tur(path: &Path) -> Result<bool> {
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 INQUIRY opcode (0x12) and
/// status byte 0xFF. 0xFF isn't a valid SCSI status — the kernel synthesises
/// it when the device gives no answer, which is the real-world signature
/// of a USB Mass Storage layer wedge.
fn is_wedge_signature(err: &Error) -> bool {
matches!(
err,
Error::ScsiError {
opcode: crate::scsi::SCSI_INQUIRY,
status: WEDGE_STATUS_BYTE,
..
}
)
}
/// Synthesised SCSI status byte returned by the Linux SG driver when
/// the kernel got no useful response from the device — the wedge
/// signature. Real SCSI statuses are GOOD (0x00), CHECK_CONDITION (0x02),
/// BUSY (0x08), etc.; 0xFF is reserved/invalid in the spec.
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;
+17 -201
View File
@@ -260,164 +260,9 @@ impl MacScsiTransport {
})
}
/// Reset the drive to a known good state.
/// On macOS, we open the device, release exclusive access, wait for
/// the system to reclaim it, then the next open() re-acquires.
/// IOKit's USB layer handles device-level resets internally when the
/// exclusive access is released and re-acquired.
///
/// NOTE: untested — macOS reset may need IOUSBDeviceInterface::ResetDevice()
/// for USB drives. This is a best-effort implementation.
pub fn reset(device: &Path) -> Result<()> {
// Opening and immediately dropping triggers release of exclusive access
// which forces IOKit to reset the device state.
if let Ok(transport) = Self::open(device) {
drop(transport); // Drop releases exclusive access + closes plugin
}
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(())
}
}
// `reset()` removed in 0.13.6 — see scsi/mod.rs for rationale.
}
/// `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`
/// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral
/// type 5). Same logic, exposed through the new `DriveInfo` shape so
@@ -467,58 +312,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<bool> {
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<bool> {
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<bool> {
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 {
+25 -185
View File
@@ -98,192 +98,32 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
}
}
/// Default upper bound on `scsi::reset()`. The platform-specific reset
/// sequence does ~15 s of bounded sleeps + ioctls in the happy path, so
/// 30 s is roughly 2× the worst-case happy time — long enough that a
/// healthy-but-slow drive isn't false-positively timed out, short enough
/// that a kernel-wedged ioctl doesn't take down the caller's poll loop
/// for minutes.
pub(crate) const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;
// Note: a top-level `scsi::reset()` used to live here, wrapping a
// platform reset in a thread+recv_timeout so a kernel-wedged ioctl
// couldn't hang the caller. Removed in 0.13.6 along with the
// SG_SCSI_RESET / STOP+START UNIT escalation that needed it. The
// remaining platform reset (Linux: SgIoTransport::reset, called only
// from SgIoTransport::open) does pure userspace state cleanup with
// bounded sleeps — no escape-hatch wrapper required.
/// Reset a SCSI device to a known good state, with a hard wallclock
/// bound (`DEFAULT_RESET_TIMEOUT_SECS`). On Linux: open/close fd cycle +
/// TUR + SG_SCSI_RESET escalation.
///
/// **Why the timeout matters.** SG_SCSI_RESET is an ioctl that can block
/// indefinitely on a kernel-wedged USB target (the kernel waits for the
/// SCSI subsystem to ack the reset, which never comes from a dead-bus
/// device). Without an outer bound, the call hangs the caller's thread
/// forever — observed in production on a wedged BU40N where the autorip
/// poll loop sat in the ioctl for 60+ s. The bounded version returns
/// `DeviceResetFailed` after `DEFAULT_RESET_TIMEOUT_SECS`; the inner
/// thread keeps running until the kernel eventually unblocks it (we
/// can't cancel a Linux ioctl from userspace), so this leaks one OS
/// thread per wedge — acceptable cost for a daemon that recovers
/// instead of hanging.
///
/// `pub(crate)` — outside callers use the higher-level `drive_has_disc`
/// (which folds in recovery escalation) or `Drive::reset` (instance-level).
/// Direct primitive exposure removed in 0.13.2 to enforce the
/// architectural rule that no consumer crate issues SCSI commands.
pub(crate) fn reset(device: &Path) -> Result<()> {
reset_with_timeout(
device,
std::time::Duration::from_secs(DEFAULT_RESET_TIMEOUT_SECS),
)
}
/// Reset with a caller-specified timeout. See [`reset`] for the full
/// rationale on why an outer wallclock bound is required.
pub(crate) fn reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
let device_owned = device.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
// Detach a worker thread for the actual reset. We never `join` it —
// if the kernel ioctl is wedged, the join would block forever, which
// is the very thing we're protecting the caller from. The thread will
// exit on its own when the kernel eventually returns from the ioctl
// (or never, if the device is permanently dead — process exit cleans
// it up).
std::thread::Builder::new()
.name("scsi-reset".into())
.spawn(move || {
let r = 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(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(Error::DeviceResetFailed {
path: device.display().to_string(),
}),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// The worker thread panicked before sending. Surface as a
// reset failure rather than a generic error.
Err(Error::DeviceResetFailed {
path: device.display().to_string(),
})
}
}
}
/// Inner reset — runs on the worker thread. May block indefinitely if
/// the kernel's SCSI subsystem is wedged. Callers must use the bounded
/// `reset()` wrapper above; this raw function isn't exposed.
fn reset_blocking(device: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
linux::SgIoTransport::reset(device)
}
#[cfg(target_os = "macos")]
{
macos::MacScsiTransport::reset(device)
}
#[cfg(target_os = "windows")]
{
windows::SptiTransport::reset(device)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = device;
Ok(())
}
}
/// 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 ─────────────────────────────────
//
+2 -25
View File
@@ -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<super::DriveInfo> {
.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<bool> {
let mut transport = SptiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
+337
View File
@@ -0,0 +1,337 @@
//! Integration tests for progress reporting, halt behavior, drop safety,
//! and the file-backed sector reader round trip.
use libfreemkv::disc::{CopyOptions, DiscRegion};
use libfreemkv::error::Result;
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::{
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader,
SectorReader,
};
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
const SECTOR_SIZE: usize = 2048;
// ── helpers ────────────────────────────────────────────────────────────────
/// Returns zeroed sectors. Always succeeds. Counts each call.
struct ZeroSectorReader {
capacity: u32,
calls: Arc<AtomicU64>,
}
impl ZeroSectorReader {
fn new(capacity: u32) -> Self {
Self {
capacity,
calls: Arc::new(AtomicU64::new(0)),
}
}
}
impl SectorReader for ZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.calls.fetch_add(1, Ordering::Relaxed);
let bytes = count as usize * SECTOR_SIZE;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity(&self) -> u32 {
self.capacity
}
}
/// Like ZeroSectorReader but sleeps a configurable duration per call.
/// Used by the halt test so the copy takes >1 s.
struct SlowZeroSectorReader {
capacity: u32,
sleep_per_call: Duration,
}
impl SlowZeroSectorReader {
fn new(capacity: u32, sleep_per_call: Duration) -> Self {
Self {
capacity,
sleep_per_call,
}
}
}
impl SectorReader for SlowZeroSectorReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
std::thread::sleep(self.sleep_per_call);
let bytes = count as usize * SECTOR_SIZE;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity(&self) -> u32 {
self.capacity
}
}
/// Build a Disc instance with a known capacity, no titles, no encryption.
/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys).
fn synthetic_disc(capacity_sectors: u32) -> Disc {
Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors,
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
content_format: ContentFormat::BdTs,
}
}
/// Build a DiscTitle with a single extent of `sector_count` sectors and no
/// streams (DiscStream still iterates sectors and would emit BytesRead).
fn synthetic_title(sector_count: u32) -> DiscTitle {
DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: sector_count as u64 * SECTOR_SIZE as u64,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents: vec![Extent {
start_lba: 0,
sector_count,
}],
content_format: ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
// ── 1. BytesRead events emitted during disc copy (TDD red) ────────────────
#[test]
fn test_bytes_read_emitted_during_disc_copy() {
// Build a tiny synthetic disc and stream it through DiscStream.
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
let count = Arc::new(AtomicU64::new(0));
let count_cb = count.clone();
stream.on_event(move |ev| {
if let EventKind::BytesRead { .. } = ev.kind {
count_cb.fetch_add(1, Ordering::Relaxed);
}
});
// Drive the stream to EOF. With no streams configured, read() returns
// Ok(None) once all extents are exhausted.
loop {
match stream.read() {
Ok(Some(_frame)) => {}
Ok(None) => break,
Err(e) => panic!("stream read failed: {e:?}"),
}
}
let n = count.load(Ordering::Relaxed);
// EXPECTED TO FAIL until BytesRead emission is wired up. TDD red.
assert!(
n > 0,
"expected at least one BytesRead event, got {n} (lib does not yet emit BytesRead)"
);
}
// ── 2. Disc::copy on_progress callback fires (regression guard) ───────────
#[test]
fn test_disc_copy_progress_callback_fires() {
let disc = synthetic_disc(64);
let mut reader = ZeroSectorReader::new(64);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp); // we want the path, not the file handle
let calls = Arc::new(AtomicU64::new(0));
let last_bytes = Arc::new(AtomicU64::new(0));
let calls_cb = calls.clone();
let last_bytes_cb = last_bytes.clone();
let progress = move |bytes: u64, _total: u64| {
calls_cb.fetch_add(1, Ordering::Relaxed);
last_bytes_cb.store(bytes, Ordering::Relaxed);
};
let opts = CopyOptions {
decrypt: false,
on_progress: Some(&progress),
..Default::default()
};
let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok");
// Cleanup any sidecar mapfile + ISO before assertions.
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
assert!(result.complete, "copy should be complete");
let n = calls.load(Ordering::Relaxed);
let last = last_bytes.load(Ordering::Relaxed);
assert!(n > 0, "on_progress should fire at least once, got {n}");
assert!(last > 0, "final progress bytes should be non-zero, got {last}");
}
// ── 3. Halt aborts disc copy promptly ─────────────────────────────────────
#[test]
fn test_halt_aborts_disc_copy_promptly() {
// 6000 sectors, 60-sector batches → 100 read_sectors() calls.
// 10 ms sleep per call → ~1 s total without halt.
let capacity_sectors: u32 = 6000;
let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10));
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let halt = Arc::new(AtomicBool::new(false));
let halt_for_thread = halt.clone();
let iso_path_for_thread = iso_path.clone();
let join = std::thread::spawn(move || {
let opts = CopyOptions {
decrypt: false,
halt: Some(halt_for_thread),
..Default::default()
};
let t0 = Instant::now();
let res = disc.copy(&mut reader, &iso_path_for_thread, &opts);
(res, t0.elapsed())
});
// Let copy run, then halt.
std::thread::sleep(Duration::from_millis(200));
halt.store(true, Ordering::Relaxed);
// Bound the join: should exit far before the full 1 s otherwise needed.
let started = Instant::now();
let mut joined = None;
while started.elapsed() < Duration::from_millis(2000) {
if join.is_finished() {
joined = Some(join.join().expect("thread join"));
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt");
// Cleanup
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
let copy_result = result.expect("copy returns Ok with halted=true on halt");
assert!(
copy_result.halted,
"copy_result.halted should be true after halt"
);
assert!(
!copy_result.complete,
"copy_result.complete should be false when halted"
);
assert!(
elapsed < Duration::from_millis(2000),
"copy thread exit elapsed {elapsed:?} exceeded 2s"
);
}
// ── 4. DiscStream Drop does not panic or block ────────────────────────────
#[test]
fn test_drop_impls_do_not_panic_or_block() {
let reader = ZeroSectorReader::new(64);
let title = synthetic_title(64);
let keys = libfreemkv::DecryptKeys::None;
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
// Drop on a worker thread; main thread enforces the timeout.
let handle = std::thread::spawn(move || {
drop(stream);
});
let started = Instant::now();
while started.elapsed() < Duration::from_millis(100) {
if handle.is_finished() {
handle.join().expect("drop thread join");
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("DiscStream drop did not complete within 100ms");
}
// ── 5. FileSectorReader round trip ────────────────────────────────────────
#[test]
fn test_file_sector_reader_round_trip() {
// Build 8 sectors of pseudo-random bytes (sector-aligned).
const N_SECTORS: usize = 8;
let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE];
for (i, b) in data.iter_mut().enumerate() {
// Cheap PRNG: just a multiplicative pattern, deterministic for asserts.
*b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8;
}
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create");
tmp.write_all(&data).expect("write data");
tmp.flush().expect("flush");
let path = tmp.path().to_str().expect("path utf-8").to_string();
let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader");
assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch");
// Read each sector individually and compare.
let mut buf = vec![0u8; SECTOR_SIZE];
for lba in 0..N_SECTORS as u32 {
let n = fsr
.read_sectors(lba, 1, &mut buf, false)
.expect("read_sectors");
assert_eq!(n, SECTOR_SIZE);
let off = lba as usize * SECTOR_SIZE;
assert_eq!(
&buf[..],
&data[off..off + SECTOR_SIZE],
"sector {lba} mismatch"
);
}
// Read all sectors at once and compare.
let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE];
let n = fsr
.read_sectors(0, N_SECTORS as u16, &mut all, false)
.expect("read all sectors");
assert_eq!(n, N_SECTORS * SECTOR_SIZE);
assert_eq!(all, data, "bulk read mismatch");
}