mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43836865be | ||
|
|
5e22199441 | ||
|
|
761b77bbd9 | ||
|
|
cc05ef2a3a |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user