mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2905ba7bb | ||
|
|
b4de5d343d | ||
|
|
8beeac7df9 | ||
|
|
b33f41e219 | ||
|
|
e85e20f436 | ||
|
|
7bc54be8d3 | ||
|
|
6d9083743b | ||
|
|
c49a68054f | ||
|
|
4bd38787c6 | ||
|
|
b4951b1c5b | ||
|
|
92dc145864 | ||
|
|
36b65f526a | ||
|
|
ca8ebf418f | ||
|
|
34182d956a | ||
|
|
0f967a2084 | ||
|
|
0341995c0d | ||
|
|
fe0ec0c5cf | ||
|
|
022657d481 | ||
|
|
fc8eca44e1 | ||
|
|
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
|
||||
|
||||
+523
@@ -1,5 +1,528 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.20 (2026-04-26)
|
||||
|
||||
### Architecture: SCSI transport — sync blocking SG_IO
|
||||
|
||||
`scsi/linux.rs` rewritten from async `write/poll/read + 1.5 s timeout
|
||||
+ close-on-timeout in bg thread` to a single synchronous blocking
|
||||
`ioctl(fd, SG_IO, &hdr)`. The old pattern abandoned slow-but-alive
|
||||
commands faster than the drive could drain its internal queue,
|
||||
deepening the BU40N wedge. Per the audit at
|
||||
`(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`,
|
||||
no reference project (MakeMKV / sg_dd / ddrescue) does what we did —
|
||||
all use sync blocking SG_IO with 8-60 s timeouts and let the kernel's
|
||||
mid-layer (`scsi_eh.rst`) run ABORT TASK / LUN RESET / BUS RESET /
|
||||
HOST RESET escalation internally.
|
||||
|
||||
What changed:
|
||||
- `SgIoTransport::execute()` is one syscall now. Caller-supplied
|
||||
`timeout_ms` is honored by the kernel, which does its own
|
||||
ABORT/RESET escalation if the device times out.
|
||||
- Errors check `host_status` and `driver_status` (both 0xFF-synthesised
|
||||
for the caller) in addition to `status` — transport-level failures
|
||||
no longer slip through as Ok.
|
||||
- Sense-key parser handles both descriptor format (0x72/0x73, key at
|
||||
byte 1) and fixed format (0x70/0x71, key at byte 2).
|
||||
- Deleted the `fd_recovery: Arc<AtomicI32>` field, the bg close+open
|
||||
thread, and the stale-fd swap dance. `scsi/linux.rs` shrank from
|
||||
~720 to ~520 lines.
|
||||
- Module doc rewritten to reflect the new architecture.
|
||||
|
||||
### Architecture: parity strip on macOS + Windows
|
||||
|
||||
`scsi/macos.rs` and `scsi/windows.rs` had `try_recover()` —
|
||||
userspace handle-recovery on task failure. Same anti-pattern as the
|
||||
Linux fd-recovery dance, removed for the same reason: the kernel
|
||||
mid-layer already runs its own escalation. Errors bubble up directly.
|
||||
|
||||
Cleanups:
|
||||
- `MacScsiTransport`: `try_recover()` deleted, `bsd_name` field
|
||||
deleted (was only used by try_recover), fail-fast device_iface guard
|
||||
deleted (no longer null'd mid-session).
|
||||
- `SptiTransport`: `try_recover()` deleted, `wide_path` field deleted,
|
||||
INVALID_HANDLE guard deleted.
|
||||
|
||||
### API cleanup: drop `Drive::reset` and `find_drives`
|
||||
|
||||
Two duplicates removed from the public surface:
|
||||
|
||||
- `Drive::reset()` — escalating recovery (STOP/START unit + eject +
|
||||
reinit). Per the audit, userspace shouldn't escalate; the kernel
|
||||
already does. Only one internal caller (`wait_ready` line 195),
|
||||
which now just keeps polling TUR for 60 iterations. No external
|
||||
consumer used it.
|
||||
- `pub fn find_drives() -> Vec<Drive>` — opened N drives just to throw
|
||||
most away. Only caller was `find_drive()` itself, which now uses
|
||||
`discover_drives()` directly. No external consumer used it. For
|
||||
lightweight enumeration (UI sidebar etc.) use `scsi::list_drives()`.
|
||||
|
||||
`lib.rs` re-export of `find_drives` removed.
|
||||
|
||||
## 0.13.19 (2026-04-26 — held, never released)
|
||||
|
||||
Held in development; folded into 0.13.20.
|
||||
|
||||
## 0.13.18 (2026-04-26)
|
||||
|
||||
### Sync release — no functional changes
|
||||
|
||||
Bumped to satisfy the unified-versioning rule. Actual fix is in autorip
|
||||
(`web.rs` two-bar UI — separates per-pass and total progress bars +
|
||||
their own text rows so the rip dashboard is readable again).
|
||||
|
||||
## 0.13.17 (2026-04-26)
|
||||
|
||||
### Sync release — no functional changes
|
||||
|
||||
Bumped to satisfy the unified-versioning rule. Actual fix is in autorip
|
||||
(hot-plug rescan in the drive poll loop — autorip now picks up
|
||||
unplug/replug events without a container restart).
|
||||
|
||||
## 0.13.16 (2026-04-26)
|
||||
|
||||
### Architecture: single `Progress` trait + `PassProgress` struct
|
||||
|
||||
Pre-0.13.16 the rip API leaked internal mapfile concepts (`pos`,
|
||||
`bytes_good`, `work_done`, `bytes_pending`, `Finished`/`NonTrimmed`)
|
||||
into per-pass positional callbacks. Consumers reinvented the math each
|
||||
time, and the v0.13.15 UI bug surfaced exactly because of this — autorip's
|
||||
web JS computed `progress_pct` from `bytes_good` while the backend
|
||||
computed from `pos`, silent drift, frozen UI bar.
|
||||
|
||||
This release replaces both `Disc::copy::on_progress` and
|
||||
`Disc::patch::on_progress` `Fn(u64, u64, u64)` callbacks with a single
|
||||
`Progress` trait and `PassProgress` struct (new `progress` module).
|
||||
|
||||
```rust
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind, // Sweep | Trim {reverse} | Scrape {reverse} | Mux
|
||||
pub work_done: u64,
|
||||
pub work_total: u64,
|
||||
pub bytes_good_total: u64,
|
||||
pub bytes_total_disc: u64,
|
||||
}
|
||||
|
||||
pub trait Progress {
|
||||
fn report(&self, p: &PassProgress);
|
||||
}
|
||||
|
||||
impl<F: Fn(&PassProgress)> Progress for F { ... }
|
||||
```
|
||||
|
||||
Both `CopyOptions::on_progress` and `PatchOptions::on_progress` are
|
||||
renamed to `progress: Option<&dyn Progress>`. Closure callers update
|
||||
trivially; struct callers gain a clean named-field shape with no
|
||||
positional-arg confusion.
|
||||
|
||||
`PassKind` carries the semantic (sweep vs trim vs scrape vs mux) so
|
||||
consumers can label phases without reinventing detection logic. The
|
||||
`Mux` variant is reserved for v0.13.17 when the mux pipeline emits
|
||||
progress; not yet emitted by libfreemkv code.
|
||||
|
||||
`Disc::patch` reports `Trim {reverse}` for retry passes with
|
||||
`block_sectors >= 2` and `Scrape {reverse}` when `block_sectors == 1`
|
||||
(the per-sector final pass). Direction comes through `reverse: bool`.
|
||||
|
||||
## 0.13.15 (2026-04-26)
|
||||
|
||||
### Breaking: `on_progress` callback gains `pos` parameter
|
||||
|
||||
Both `CopyOptions::on_progress` and `PatchOptions::on_progress` now take
|
||||
`Fn(bytes_good: u64, pos: u64, total_bytes: u64)`. The new `pos` parameter
|
||||
is the current sweep / retry position. Pass 1 callers should display
|
||||
`pos / total_bytes` for the "% swept" UI bar — `bytes_good` only counts
|
||||
clean reads (Finished sectors) and freezes during skip-forward bad zones,
|
||||
which made every previous version's UI look hung at the bad-zone boundary.
|
||||
This was the v0.13.9 stall-guard origin bug.
|
||||
|
||||
Live trace from v0.13.14: Pass 1 hit a Dune 2 bad zone at 24 GB and
|
||||
appeared "stuck" for 14 minutes per autorip's UI (`bytes_good = 23.97 GB`
|
||||
unchanged). Disc trace events showed `pos` actually advanced from 25.8 GB
|
||||
to 70 GB during that window — Pass 1 was 83 % through the disc, marking
|
||||
the post-bad-zone NonTrimmed via skip-forward exactly as designed. The
|
||||
display lied. Now consumers can show the truth.
|
||||
|
||||
### Feature: `PatchOptions::reverse` for reverse-direction retry passes
|
||||
|
||||
When set, `Disc::patch` walks bad ranges from highest LBA to lowest, and
|
||||
within each range reads sectors back-to-front. Hypothesis (per the live
|
||||
v0.13.14 test): drives that wedge after a forward read of a bad sector
|
||||
read fine when approached from end-of-disc backward — most of the
|
||||
post-bad-zone NonTrimmed range is actually clean data the drive could
|
||||
have read on Pass 1 had it not been wedged. autorip alternates F/R
|
||||
across retry passes (Pass 2 = reverse half-batch, Pass 3 = forward
|
||||
quarter-batch, ...).
|
||||
|
||||
### Feature: `PatchOptions::wedged_threshold` early-exit
|
||||
|
||||
When > 0, `Disc::patch` exits early if it sees this many consecutive
|
||||
read failures with zero successful reads in the same pass. Saves the
|
||||
wallclock budget for productive grinding when the drive has clearly
|
||||
wedged on the bad zone for this pass — a future pass with a different
|
||||
direction or block size may still recover. Reported via new
|
||||
`PatchResult::wedged_exit: bool`.
|
||||
|
||||
### Trace: `patch_start` and `patch_done` events
|
||||
|
||||
`freemkv::disc` target now emits `patch_start` (block_sectors, recovery,
|
||||
reverse, wedged_threshold, num_ranges) and `patch_done`
|
||||
(blocks_attempted, blocks_read_ok, blocks_read_failed, wedged_exit,
|
||||
halted, bytes_recovered) at Disc::patch boundaries.
|
||||
|
||||
## 0.13.14 (2026-04-25)
|
||||
|
||||
### Sync release — no functional changes in libfreemkv
|
||||
|
||||
Bumped solely to satisfy the unified-versioning rule. The actual fix in
|
||||
this release is in autorip: the tracing subscriber now enables
|
||||
`freemkv::scsi=trace,freemkv::disc=trace` so the v0.13.13 instrumentation
|
||||
events actually surface in `/api/debug`. Without that filter override the
|
||||
trace events were silently dropped by the default `libfreemkv=warn` rule.
|
||||
|
||||
## 0.13.13 (2026-04-25)
|
||||
|
||||
### Telemetry: instrument the rip pipeline for in-flight diagnosis
|
||||
|
||||
v0.13.12 shipped Fix 1+2+4 + cross-platform parity but a live test on Dune 2
|
||||
showed Pass 1 sat for 14 minutes with `bytes_good=0` while the inner loop
|
||||
appeared to iterate (the throttled `on_progress` log fired every 78s). The
|
||||
async fd_recovery design at §7 said each `execute()` call should bound at
|
||||
~1.5 s on poll timeout, with subsequent calls returning `DeviceNotFound` in
|
||||
microseconds until recovery completes. Observed reality contradicts that:
|
||||
each iteration takes ~60 s, not microseconds. Without trace-level telemetry
|
||||
at the SCSI + Disc::copy boundaries we can't diagnose where the time goes.
|
||||
|
||||
This release adds the telemetry. No behavior change; instrumentation only.
|
||||
|
||||
- New dep: `tracing = "0.1"`. Per project docs, debug/trace logging is permitted
|
||||
in libfreemkv (the no-English rule applies to errors, not telemetry).
|
||||
Consumers (autorip) wire a tracing subscriber and pipe events into the
|
||||
JSONL debug log automatically.
|
||||
- `SgIoTransport::execute` (Linux): trace events at every state transition
|
||||
(entry, recovery_swap_ok, recovery_pending, write_ok / write_err, poll_done,
|
||||
timeout_spawn_recovery, scsi_err, read_err, ok). Each event includes the
|
||||
opcode and elapsed timing. The bg recovery thread also traces close_ms +
|
||||
open_ms so we can see if the kernel is hanging close+open.
|
||||
- `Disc::copy`: trace events at copy_start, outer_loop, region_enter, every
|
||||
100 inner-loop iterations (iter_progress with pos / region_end / skip_size /
|
||||
bytes_good / read_ok_count / read_err_count / last_read_ms /
|
||||
copy_elapsed_ms), and copy_done.
|
||||
- All trace events use `target` strings `freemkv::scsi` and `freemkv::disc`
|
||||
so consumers can filter by subsystem.
|
||||
|
||||
### What this enables
|
||||
|
||||
- A live rip will now produce a SCSI event stream visible at
|
||||
`/api/debug?n=N&q=freemkv::scsi`. We can finally answer: "is the inner
|
||||
loop iterating slowly because each call is slow, or fast with the bg
|
||||
thread blocked?"
|
||||
- `bg_recovery_done` events with `close_ms` / `open_ms` reveal whether the
|
||||
kernel really takes 60 s for close+open on a wedged Initio bridge.
|
||||
|
||||
## 0.13.12 (2026-04-25)
|
||||
|
||||
### Fix: delete stall guard from `Disc::copy` (RIP_DESIGN.md §6 Fix 1)
|
||||
|
||||
The v0.13.9 stall guard at `disc/mod.rs` exited Pass 1 early when
|
||||
`bytes_good` was flat for `stall_secs` (default 120s). This violated the
|
||||
ddrescue model: Pass 1 must sweep end-to-end, marking failed reads
|
||||
NonTrimmed for Pass 2 retry. The guard caused Pass 1 to bail at 30% on
|
||||
Dune 2 with 56 GB still NonTried, leaving Pass 2 nothing useful to do.
|
||||
|
||||
- Deleted the stall-guard state vars and the `if cur_good != ...
|
||||
break 'outer;` block.
|
||||
- Deleted `CopyOptions::stall_secs` field — no longer wired.
|
||||
- Replaced the broken regression test
|
||||
`test_disc_copy_stall_detection_triggers_skip_forward` with
|
||||
`test_disc_copy_completes_full_disc_with_failing_reader` (asserts Pass 1
|
||||
walks to end-of-disc with everything NonTrimmed when reads keep failing)
|
||||
and added `test_disc_copy_halts_promptly_on_failing_reader` (halt flag
|
||||
honored within 2s mid-skip-forward).
|
||||
|
||||
### Fix: async SCSI transport recovery (RIP_DESIGN.md §6 Fix 2 / §7)
|
||||
|
||||
`SgIoTransport::execute` (Linux) previously did close-in-background +
|
||||
synchronous open-on-main-thread on poll timeout. The kernel serialized
|
||||
the main-thread `open()` against the in-flight `close()` of the same
|
||||
`/dev/sg*`, blocking the rip thread up to ~60s per timeout.
|
||||
|
||||
- Added `fd_recovery: Arc<AtomicI32>` field. On poll timeout, both
|
||||
`close(old_fd)` AND `open(new_fd)` run in a background thread; the new
|
||||
fd is published to `fd_recovery`. Returns Err immediately. Main thread
|
||||
is never blocked beyond the `poll()` budget (~1.5 s).
|
||||
- Top of `execute()`: if `self.fd < 0`, swap from `fd_recovery`. If
|
||||
recovery is also pending, return `DeviceNotFound` and let the caller's
|
||||
retry loop come back later.
|
||||
- Drop drains any pending `fd_recovery` so the fd doesn't leak.
|
||||
- Stripped the v0.13.9 stall-guard narrative comment that justified
|
||||
the deleted behavior.
|
||||
|
||||
### Fix: cross-platform SCSI parity — Windows + macOS recovery (RIP_DESIGN.md §15.1)
|
||||
|
||||
Per the platform parity rule (no stubs), Windows and macOS now have the
|
||||
same observable recovery contract as Linux:
|
||||
|
||||
- `SptiTransport` (Windows): added `try_recover()` that calls
|
||||
`CloseHandle` + `CreateFileW` synchronously after a failed
|
||||
`DeviceIoControl`. Stripped English error string ("run as
|
||||
administrator") from `open()`. Fixed the ms→s timeout truncation
|
||||
(1500ms now rounds up to 2s, was 1s).
|
||||
- `MacScsiTransport` (macOS): added `try_recover()` that releases the
|
||||
IOKit interface (`RELEASE_EXCLUSIVE` + `com_release`) and re-acquires
|
||||
via the new `acquire_device_iface()` helper. Stores `bsd_name` so
|
||||
recovery can re-call `find_scsi_service`.
|
||||
- All three platforms: top of `execute()` returns `DeviceNotFound`
|
||||
immediately if a prior `try_recover()` left the transport in an
|
||||
invalid state. Drop guards null'd-out interfaces.
|
||||
- Send is auto-derived on all three (i32 fd / isize HANDLE / IOKit
|
||||
interface ref are Send-safe); explicit comments document the
|
||||
intentional implicit Send and the absence of Sync.
|
||||
|
||||
### Fix: instrument `Disc::patch` — diagnostic counters (RIP_DESIGN.md §6 Fix 4)
|
||||
|
||||
`PatchResult` now reports `blocks_attempted`, `blocks_read_ok`,
|
||||
`blocks_read_failed`. Pass 2's "100 minutes recovered 0 bytes" mystery
|
||||
(Dune 2) becomes diagnosable from these counters: distinguish "drive
|
||||
returned Ok but write/record dropped data" from "every read was Err for
|
||||
the entire range" without instrumenting from outside the lib.
|
||||
|
||||
### Fix: honor `PatchOptions::full_recovery`
|
||||
|
||||
The field was previously read into `let _ = opts.full_recovery;` and
|
||||
ignored — `read_sectors(..., true)` was hardcoded. Now routed to
|
||||
`read_sectors(..., opts.full_recovery)`. Behavior unchanged for
|
||||
default callers (which pass `true`).
|
||||
|
||||
### Doc: `CopyOptions::batch_sectors` accuracy
|
||||
|
||||
Doc comment said "Defaults to 32 sectors (64 KB)". Updated to describe
|
||||
the actual production path: callers should resolve via
|
||||
`detect_max_batch_sectors(device_path)` (kernel-reported sysfs value,
|
||||
typically 60 sectors / ~120 KB on the BU40N). The 32-sector internal
|
||||
fallback is only reached when `batch_sectors=None AND skip_forward=true`.
|
||||
|
||||
## 0.13.11 (2026-04-25)
|
||||
|
||||
### Fix: revert SgIoTransport timeout path to keep transport alive
|
||||
|
||||
v0.13.10 changed `SgIoTransport::execute` to set `fd = -1` on a poll
|
||||
timeout (no reopen on the main thread, since that would serialize
|
||||
against the spawned close()). The intent was to escape the 60-s
|
||||
blocking reopen.
|
||||
|
||||
The cost was too high: a single transient poll timeout permanently
|
||||
killed the transport. Live test on Dune 2 (post-replug):
|
||||
- Pass 1 ran for **45 ms** then returned with 0 GB good and 80 GB
|
||||
pending.
|
||||
- The first SCSI READ timed out, fd went to -1, every subsequent
|
||||
read returned `DeviceNotFound` instantly, Disc::copy raced through
|
||||
the entire disc skip-forwarding in milliseconds.
|
||||
- Pass 2 inherited the dead Drive and was equally useless.
|
||||
|
||||
Revert: spawn close + reopen on main thread (the v0.13.5/8
|
||||
behavior). Yes the main-thread open() may block up to ~60 s while
|
||||
the kernel completes the abandoned command — but the v0.13.9
|
||||
`Disc::copy` stall guard already caps catastrophic stalls at 120 s
|
||||
of `bytes_good` non-advance. Net: per-timeout cost is ~60 s, but
|
||||
Pass 1 cleanly bails out within 120 s of any wedge, and Pass 2 has
|
||||
a working Drive to retry NonTrimmed ranges with `recovery=true` +
|
||||
30 s timeouts.
|
||||
|
||||
The integration test for the stall guard
|
||||
(`test_disc_copy_stall_detection_triggers_skip_forward`) continues
|
||||
to pass — the guard fires regardless of which transport-recovery
|
||||
strategy is in play.
|
||||
|
||||
## 0.13.10 (2026-04-25)
|
||||
|
||||
### Version sync — no functional changes
|
||||
|
||||
Sync bump for the autorip-side fix in 0.13.10 (Pass 1 batch reporting).
|
||||
|
||||
## 0.13.9 (2026-04-25)
|
||||
|
||||
### Fix: Disc::copy silent stall + SgIoTransport reopen-after-timeout serialization
|
||||
|
||||
Two correlated fixes for a hang observed live on the LG BU40N during a
|
||||
v0.13.8 rip of Dune: Part Two. At ~30 % progress through Pass 1
|
||||
(disc → ISO), `bytes_good` froze for 10+ minutes with `errs=0`,
|
||||
no error surfaced, drive not wedged.
|
||||
|
||||
Root cause: `SgIoTransport::execute` (linux.rs) attempted to recover from
|
||||
a `poll()` timeout by spawning a background `close()` of the old fd and
|
||||
opening a fresh `/dev/sg*` fd on the main thread. On Linux, opening the
|
||||
SAME device while a prior fd is mid-close serializes via the kernel's
|
||||
per-device state lock — so the fresh `open()` blocks for as long as the
|
||||
close does (until the kernel completes the in-flight CDB). This undid
|
||||
the userspace 1.5 s timeout: each timed-out read added 60+ s to the
|
||||
next iteration. From `Disc::copy`'s perspective, reads kept returning
|
||||
Err slowly, the skip-forward path advanced `pos` but never `bytes_good`.
|
||||
|
||||
Fixes:
|
||||
- `SgIoTransport::execute` no longer reopens on timeout. Spawns the
|
||||
close, sets `self.fd = -1`, returns Err immediately. Subsequent
|
||||
calls fail with `DeviceNotFound` (already gated at line 248).
|
||||
Caller (Drive) is invalidated until reopened. Pass 2's
|
||||
`Disc::patch` would need a fresh Drive; that's a v0.14 follow-up.
|
||||
- `Disc::copy` adds a stall guard. New `CopyOptions::stall_secs:
|
||||
Option<u64>` (default 120 s). If `bytes_good` doesn't advance for
|
||||
the threshold, breaks the outer loop with `complete: false,
|
||||
bytes_pending > 0` so the caller's retry path picks up.
|
||||
|
||||
Tests: new `test_disc_copy_stall_detection_triggers_skip_forward` in
|
||||
`tests/integration_progress_and_halt.rs` proves the guard fires within
|
||||
the configured threshold.
|
||||
|
||||
Other:
|
||||
- Cosmetic: warning text "rip thread did not drain within 35s" updated
|
||||
to 60s (matches the v0.13.8 timeout bump).
|
||||
|
||||
## 0.13.8 (2026-04-25)
|
||||
|
||||
### Version sync — no functional changes
|
||||
|
||||
Sync bump for the ecosystem. 0.13.8 carries autorip-side fixes:
|
||||
post-stop "error" leak (halt-aware Err handling in Pass 1/2+),
|
||||
60 s drain timeout, and a structural spawn_rip_thread helper.
|
||||
|
||||
## 0.13.7 (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.7
|
||||
carries an autorip-side fix (HTTP-spawned rip/scan threads now
|
||||
register for stop-drain).
|
||||
|
||||
## 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
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.13.2"
|
||||
version = "0.13.20"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -24,6 +24,10 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under project docs ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -31,6 +35,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)
|
||||
|
||||
@@ -8,13 +8,12 @@ fn main() {
|
||||
let device = std::env::args()
|
||||
.skip(1)
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.unwrap_or_else(|| {
|
||||
let drives = libfreemkv::find_drives();
|
||||
if drives.is_empty() {
|
||||
.unwrap_or_else(|| match libfreemkv::find_drive() {
|
||||
Some(d) => d.device_path().to_string(),
|
||||
None => {
|
||||
eprintln!("No drives found");
|
||||
std::process::exit(1);
|
||||
}
|
||||
drives[0].device_path().to_string()
|
||||
});
|
||||
|
||||
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
|
||||
|
||||
@@ -12,6 +12,7 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Rip Recovery](rip-recovery.md) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
|
||||
+33
-3
@@ -126,15 +126,45 @@ pub struct Event {
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
// Init / scan
|
||||
DriveOpened { device: String },
|
||||
DriveReady,
|
||||
InitComplete { success: bool },
|
||||
ProbeComplete { success: bool },
|
||||
ScanComplete { titles: usize },
|
||||
|
||||
// Read pipeline
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
SectorSkipped { sector: u64 },
|
||||
BatchSizeChanged { new_size: u16, reason: BatchSizeReason },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
|
||||
// Kept for forward-compat; not emitted in 0.13.6+
|
||||
Retry { attempt: u32 },
|
||||
SectorRecovered { sector: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
Emission notes:
|
||||
|
||||
- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents`
|
||||
after each successful sector read. `bytes` is the cumulative running
|
||||
total; `total` is the precomputed extent sum (0 if unknown).
|
||||
- `SpeedChange` is emitted from the public `Drive::set_speed` API path.
|
||||
It is no longer emitted from a recovery hot loop (recovery loop removed
|
||||
in 0.13.6).
|
||||
- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink
|
||||
(read failed at a larger size) and on probe-up (clean-read streak hit
|
||||
the threshold). Consumers use it to display a "recovering" state
|
||||
distinct from "ripping normally".
|
||||
- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were
|
||||
tied to the inline `Drive::read` recovery phases that were removed; the
|
||||
variants are kept for forward compatibility so consumers' match arms
|
||||
don't need conditional compilation.
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||
prints a line. Server logs to file.
|
||||
|
||||
@@ -145,8 +175,8 @@ libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive/ Drive (open, init, read with recovery)
|
||||
│ ├── mod.rs Drive struct, init, read, reset, eject
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||
│ ├── capture.rs Drive profile capture for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
|
||||
@@ -41,7 +41,7 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||
│ ├── drive Drive — open, identify, init, unlock, single-shot read
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
@@ -93,10 +93,17 @@ Drive::open(Path::new("/dev/sg4"))
|
||||
After open:
|
||||
- `init()` -- unlock + firmware upload + speed calibration
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf)` -- single read method with built-in error recovery
|
||||
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (1.5 s vs. 30 s)
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
- `eject()` -- eject tray
|
||||
|
||||
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
||||
(`Disc::patch`) handles bad-range retry by replaying the ddrescue mapfile.
|
||||
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
||||
`(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`.
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
+4
-2
@@ -77,7 +77,9 @@ Insert disc
|
||||
10. Stream content (mux/disc.rs → DiscStream)
|
||||
│ Read sectors → decrypt → TS demux → PES frames
|
||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||
│ Drive::read() handles error recovery (min speed → reset → retry)
|
||||
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
||||
│ 1 above this — Disc::patch re-runs against the mapfile.
|
||||
│
|
||||
▼
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
@@ -110,7 +112,7 @@ output.finish()?;
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read |
|
||||
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
|
||||
+37
-15
@@ -26,8 +26,8 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||
| `init()` | Firmware upload + unlock + speed calibration |
|
||||
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||
| `read(lba, count, buf)` | Read sectors with built-in error recovery |
|
||||
| `reset()` | Close/reopen device, TUR, escalate if needed |
|
||||
| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. |
|
||||
| `reset()` | Eject-cycle escape hatch. Caller-invoked only; not on the read path. |
|
||||
| `lock_tray()` | Prevent tray ejection during rip |
|
||||
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||
| `eject()` | Eject disc tray |
|
||||
@@ -44,22 +44,33 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
3. Speed calibration after unlock
|
||||
4. Max 3 attempts before giving up
|
||||
|
||||
### read() with Recovery
|
||||
### read() — single-shot
|
||||
|
||||
`Drive::read(lba, count, buf, recovery)` is the single read method. The
|
||||
`recovery` parameter controls whether to attempt multi-phase recovery on
|
||||
failure or return immediately (used by DiscStream's binary search for
|
||||
single-sector probes).
|
||||
`Drive::read(lba, count, buf, recovery)` is the single read method. It issues
|
||||
exactly one READ(10) CDB and returns the result. The `recovery` parameter only
|
||||
selects the per-CDB timeout:
|
||||
|
||||
On error with `recovery = true`:
|
||||
| `recovery` | Timeout | Used by |
|
||||
|------------|----------|------------------------------------------|
|
||||
| `false` | 1.5 s | `Disc::copy` fast skip-forward sweep, `DiscStream::fill_extents` |
|
||||
| `true` | 30 s | `Disc::patch` multi-pass over the mapfile |
|
||||
|
||||
1. **Phase 1 — gentle retry (5 attempts):** set min speed, sleep 30s, retry.
|
||||
Each retry has a hard wall-clock timeout via async SG_IO.
|
||||
2. **Phase 2 — fresh start:** close transport, reset device, reopen, reinit.
|
||||
3. **Phase 3 — gentle retry on fresh connection (5 attempts).**
|
||||
4. If all fail: return `Err(DiscRead)`. DiscStream handles it (binary search,
|
||||
skip, zero-fill).
|
||||
5. Stay at min speed for 500 MB after any recovery (recovery window).
|
||||
On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately.
|
||||
There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation.
|
||||
|
||||
Recovery is layered above `Drive::read`:
|
||||
|
||||
- **Layer 1 — `Disc::patch`** loops over the ddrescue mapfile and re-issues
|
||||
`read(.., recovery=true)` against each non-`+` range.
|
||||
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
||||
failure, retries at the same LBA, and probes back up on a clean-read
|
||||
streak.
|
||||
|
||||
Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was
|
||||
removed in 0.13.6. See `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`
|
||||
for rationale: the inline reset wedged drive firmware on the LG BU40N (Initio
|
||||
USB-SATA bridge) without ever recovering a sector. See
|
||||
[`rip-recovery.md`](rip-recovery.md) for the full three-layer model.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,6 +110,17 @@ cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
On non-zero SCSI status, the transport parses sense key from the sense buffer
|
||||
and returns `Error::ScsiError`.
|
||||
|
||||
`SgIoTransport::reset` (Linux) does pure userspace state cleanup: an open +
|
||||
close pair to make the kernel cancel any SG_IO commands queued against a
|
||||
previous fd, a 2 s sleep to let the kernel finish that cancellation, then a
|
||||
fresh fd to send ALLOW MEDIUM REMOVAL to clear any stale tray lock. It does
|
||||
NOT issue `SG_SCSI_RESET` or escalate via STOP+START UNIT. Both 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. The macOS reset (which
|
||||
had been a no-op) was removed entirely in 0.13.6, and the top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
removed at the same time (no callers).
|
||||
|
||||
### CDB Builders
|
||||
|
||||
The `scsi` module provides platform-agnostic CDB constructors:
|
||||
|
||||
+74
-20
@@ -1,12 +1,27 @@
|
||||
# Rip recovery — multi-pass architecture
|
||||
# Rip recovery — three-layer architecture
|
||||
|
||||
`libfreemkv` supports a two-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, followed by targeted
|
||||
`libfreemkv` supports a multi-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, in-loop request-size
|
||||
adaptation that survives transient drive trouble without bailing, and targeted
|
||||
retry passes against a persistent bad-range map. The stream pipeline
|
||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||
the mux stage never touches the drive.
|
||||
|
||||
Three primitives compose the flow:
|
||||
Recovery is layered cleanly. Each layer has one responsibility and does not
|
||||
reach into the others.
|
||||
|
||||
| Layer | Where it lives | What it does |
|
||||
|-------|---------------|--------------|
|
||||
| 1 — Bad-range retry | `Disc::patch` (multi-pass over the mapfile) | Re-reads non-`+` ranges with the long timeout. Idempotent; call N times. |
|
||||
| 2 — Single-shot primitive | `Drive::read` in `src/drive/mod.rs` | One CDB, one timeout, one result. No inline retries, no SCSI reset. |
|
||||
| 3 — In-loop request adaptation | `DiscStream::fill_extents` adaptive batch sizer | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
|
||||
|
||||
The caller orchestrates layer 1. Autorip's `rip_disc` loops `copy` then
|
||||
N × `patch` per the `MAX_RETRIES` config, then hands the ISO off to the
|
||||
existing mux pipeline. Layer 3 runs inside any consumer of `DiscStream`
|
||||
(direct PES pipeline, ISO playback, etc.) without caller involvement.
|
||||
|
||||
Three primitives compose the disc-side flow:
|
||||
|
||||
| Primitive | What it does |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
@@ -14,9 +29,6 @@ Three primitives compose the flow:
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. Idempotent; call N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
The caller orchestrates. Autorip's `rip_disc` loops `copy` then N × `patch` per
|
||||
the `MAX_RETRIES` config, then hands the ISO off to the existing mux pipeline.
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
@@ -26,7 +38,7 @@ plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.11.22
|
||||
# Rescue Logfile. Created by libfreemkv v0.13.6
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
@@ -50,8 +62,8 @@ Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `CopyOptions` and `PatchOptions`
|
||||
|
||||
Defaults preserve pre-`0.11.21` behavior — full drive recovery on every read,
|
||||
abort on the first unreadable sector. Opt in to the recovery-friendly path:
|
||||
Defaults preserve pre-`0.11.21` behavior — abort on the first unreadable
|
||||
sector. Opt in to the recovery-friendly path:
|
||||
|
||||
```rust
|
||||
CopyOptions {
|
||||
@@ -65,9 +77,10 @@ CopyOptions {
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep
|
||||
### Pass 1 — fast sweep (`Disc::copy`)
|
||||
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA via
|
||||
`Drive::read(.., recovery=false)` — short 1.5 s timeout, single shot.
|
||||
2. On success: mark the range `+`, advance by one block.
|
||||
3. On failure (with `skip_on_error`): zero-fill the block in the ISO, mark
|
||||
it `*`, advance.
|
||||
@@ -80,12 +93,13 @@ CopyOptions {
|
||||
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
|
||||
bails via the halt flag).
|
||||
|
||||
### Pass 2+ — patch
|
||||
### Pass 2+ — patch (`Disc::patch`)
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
|
||||
|
||||
1. Issue a drive read with full recovery enabled (SCSI-level retries,
|
||||
ECC recovery, the lot).
|
||||
1. Issue a drive read via `Drive::read(.., recovery=true)` — long 30 s
|
||||
timeout, still single shot. Drive firmware does its own ECC and retries
|
||||
inside that window; userspace does not pile on additional retries here.
|
||||
2. On success: write the good bytes into the ISO at the exact byte offset,
|
||||
mark `+`.
|
||||
3. On failure: mark `-`.
|
||||
@@ -95,21 +109,61 @@ Idempotent. Call `patch` N times for N retry attempts; typically the caller
|
||||
stops early if a pass recovers zero bytes (structure-protected sectors will
|
||||
never yield).
|
||||
|
||||
### In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||
|
||||
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||
`fill_extents` runs an adaptive sizer in front of `Drive::read`:
|
||||
|
||||
1. Try the current preferred batch size (e.g. 32 sectors, one BD ECC block).
|
||||
2. On failure: halve the batch and retry at the same LBA. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Shrunk }`.
|
||||
3. On a clean-read streak: probe back up toward the preferred size. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Probed }`.
|
||||
4. If a single-sector read fails: skip (zero-fill, emit
|
||||
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||
`Err(DiscRead)`.
|
||||
|
||||
This is layer 3. It exists so a transient single-sector glitch in a 32-sector
|
||||
batch can be isolated and read individually without the caller needing to
|
||||
implement retry logic.
|
||||
|
||||
## Design choices
|
||||
|
||||
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset,
|
||||
no eject cycle. The `recovery` flag controls only the per-CDB timeout
|
||||
(1.5 s vs. 30 s); on any failure it returns `Err(DiscRead)` immediately.
|
||||
Inline recovery (5× gentle retry → close + SCSI reset + reopen → 5× more)
|
||||
was removed in 0.13.6. See `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`
|
||||
for rationale: the inline reset on the LG BU40N (Initio USB-SATA bridge)
|
||||
wedged drive firmware below the bridge without ever recovering a sector,
|
||||
and the gentle-retry phase produced long stretches of 0 KB/s with no
|
||||
recoveries to show for it. Recovery responsibility is now layered: layer 1
|
||||
handles ranges, layer 3 handles request size, neither touches the
|
||||
wedge-prone reset path.
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
|
||||
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
|
||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts instead, and we avoid per-sector probing on first
|
||||
contact by using large blocks + skip-forward.
|
||||
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
||||
in pass 2 / patch.
|
||||
|
||||
**No SCSI reset from any retry path.** `SgIoTransport::reset` (Linux) is
|
||||
trimmed to a kernel SG_IO state flush plus ALLOW MEDIUM REMOVAL — the
|
||||
`SG_SCSI_RESET` ioctl and STOP/START UNIT escalation were removed in 0.13.6.
|
||||
The macOS reset (which had been a no-op) was removed entirely. The top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
also removed (no callers). The remaining `Drive::reset()` is only invoked
|
||||
explicitly by callers that need an eject-cycle escape hatch — it is never
|
||||
reached from a read path.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||
directly — the lib doesn't forbid it.
|
||||
directly — the lib doesn't forbid it, and layer 3 (adaptive batch halving)
|
||||
still applies there.
|
||||
|
||||
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||
@@ -117,11 +171,11 @@ coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with full recovery. Future work can specialize (trim vs.
|
||||
non-finished range with the long timeout. Future work can specialize (trim vs.
|
||||
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
|
||||
## References
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
|
||||
+224
-19
@@ -1286,6 +1286,20 @@ impl Disc {
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut bytes_done = 0u64;
|
||||
let mut halt_requested = false;
|
||||
let copy_t0 = std::time::Instant::now();
|
||||
let mut iter_count: u64 = 0;
|
||||
let mut read_ok_count: u64 = 0;
|
||||
let mut read_err_count: u64 = 0;
|
||||
let mut last_log_iter: u64 = 0;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "copy_start",
|
||||
total_bytes,
|
||||
batch,
|
||||
skip_init,
|
||||
skip_max,
|
||||
"Disc::copy entered"
|
||||
);
|
||||
|
||||
// Iterate over not-yet-finished regions from the mapfile. We re-read the
|
||||
// mapfile after each block because record() mutates the region list.
|
||||
@@ -1295,6 +1309,12 @@ impl Disc {
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
]);
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "outer_loop",
|
||||
regions_remaining = regions_to_do.len(),
|
||||
"Disc::copy outer iter"
|
||||
);
|
||||
if regions_to_do.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -1307,6 +1327,14 @@ impl Disc {
|
||||
};
|
||||
let region_end = region_pos + region_size;
|
||||
let mut pos = region_pos;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "region_enter",
|
||||
region_pos,
|
||||
region_size,
|
||||
region_end,
|
||||
"entering NonTried region"
|
||||
);
|
||||
|
||||
while pos < region_end {
|
||||
if let Some(ref h) = opts.halt {
|
||||
@@ -1321,11 +1349,15 @@ impl Disc {
|
||||
let bytes = count as usize * 2048;
|
||||
|
||||
let recovery = !opts.skip_on_error; // fast reads when skipping
|
||||
iter_count += 1;
|
||||
let read_t0 = std::time::Instant::now();
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
||||
.is_ok();
|
||||
let read_elapsed_ms = read_t0.elapsed().as_millis() as u64;
|
||||
|
||||
if read_ok {
|
||||
read_ok_count += 1;
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
@@ -1339,6 +1371,7 @@ impl Disc {
|
||||
skip_size = skip_init; // reset after success
|
||||
pos += block_bytes;
|
||||
} else if opts.skip_on_error {
|
||||
read_err_count += 1;
|
||||
// Zero-fill this block, mark non-trimmed for later patch trim.
|
||||
buf[..bytes].fill(0);
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
@@ -1364,15 +1397,54 @@ impl Disc {
|
||||
return Err(Error::DiscRead { sector: lba as u64 });
|
||||
}
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
// Throttled iter telemetry — every 100 inner iterations.
|
||||
if iter_count - last_log_iter >= 100 {
|
||||
last_log_iter = iter_count;
|
||||
let stats = map.stats();
|
||||
cb(stats.bytes_good, total_bytes);
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "iter_progress",
|
||||
iter_count,
|
||||
read_ok_count,
|
||||
read_err_count,
|
||||
last_read_ms = read_elapsed_ms,
|
||||
pos,
|
||||
region_end,
|
||||
skip_size,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Disc::copy inner iter"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(reporter) = opts.progress {
|
||||
let stats = map.stats();
|
||||
reporter.report(&crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Sweep,
|
||||
work_done: pos,
|
||||
work_total: total_bytes,
|
||||
bytes_good_total: stats.bytes_good,
|
||||
bytes_total_disc: total_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "copy_done",
|
||||
iter_count,
|
||||
read_ok_count,
|
||||
read_err_count,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
halted = halt_requested,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Disc::copy returning"
|
||||
);
|
||||
Ok(CopyResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
@@ -1392,8 +1464,10 @@ pub struct CopyOptions<'a> {
|
||||
/// Resume from existing mapfile + ISO if present. Without this, any
|
||||
/// existing mapfile is wiped and the ISO recreated.
|
||||
pub resume: bool,
|
||||
/// Override the default block size. Defaults to 32 sectors (64 KB) in
|
||||
/// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise.
|
||||
/// Override the default block size in sectors. Callers should resolve
|
||||
/// this with `detect_max_batch_sectors(device_path)` for live drives.
|
||||
/// When `None`, falls back to 32 sectors (64 KB BD ECC block) in
|
||||
/// `skip_forward` mode or `DEFAULT_BATCH_SECTORS=60` otherwise.
|
||||
pub batch_sectors: Option<u16>,
|
||||
/// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue.
|
||||
/// Uses fast reads (no drive-level recovery loop).
|
||||
@@ -1402,7 +1476,11 @@ pub struct CopyOptions<'a> {
|
||||
/// `skip_on_error`. The skipped region is marked `non-trimmed` for later
|
||||
/// trimming/scraping by `Disc::patch`.
|
||||
pub skip_forward: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
/// Per-iteration progress reporter. v0.13.16 architecture: the library
|
||||
/// emits a single `PassProgress` shape via the `Progress` trait;
|
||||
/// consumers compute their own derived percentages / ETAs from it. No
|
||||
/// more positional `(bytes_good, pos, total)` callbacks.
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
@@ -1436,11 +1514,23 @@ pub struct PatchOptions<'a> {
|
||||
/// Use full drive-level recovery on each read (slow but thorough). Defaults
|
||||
/// to true — patch is the pass where we *want* the drive to try hard.
|
||||
pub full_recovery: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
/// Walk bad ranges in reverse order, and within each range walk sectors
|
||||
/// from high to low LBA. Useful for drives that wedge after a forward
|
||||
/// read of a bad sector — approaching the post-bad-zone from end-of-disc
|
||||
/// reads good sectors before the drive sees a bad one.
|
||||
pub reverse: bool,
|
||||
/// Bail out early if this many consecutive read failures occur with zero
|
||||
/// successful reads in the same pass — i.e. the drive is wedged on the
|
||||
/// bad zone and won't recover during this attempt. `0` disables the
|
||||
/// guard (run to completion or halt).
|
||||
pub wedged_threshold: u64,
|
||||
/// Per-iteration progress reporter. See `CopyOptions::progress`.
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Result of `Disc::patch` — how many bad bytes were recovered.
|
||||
/// Result of `Disc::patch` — how many bad bytes were recovered, plus
|
||||
/// per-block counters for diagnosing why a pass made or didn't make progress.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PatchResult {
|
||||
pub bytes_total: u64,
|
||||
@@ -1449,6 +1539,16 @@ pub struct PatchResult {
|
||||
pub bytes_pending: u64,
|
||||
pub bytes_recovered_this_pass: u64,
|
||||
pub halted: bool,
|
||||
/// Total inner-loop iterations this pass (one per block attempted).
|
||||
pub blocks_attempted: u64,
|
||||
/// Reads that returned Ok and were promoted to `Finished`.
|
||||
pub blocks_read_ok: u64,
|
||||
/// Reads that returned Err and were marked `Unreadable`.
|
||||
pub blocks_read_failed: u64,
|
||||
/// Pass exited early because `wedged_threshold` consecutive failures
|
||||
/// occurred with zero successful reads — drive appears wedged on the
|
||||
/// bad zone for this pass.
|
||||
pub wedged_exit: bool,
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
@@ -1482,43 +1582,85 @@ impl Disc {
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
let block_sectors = opts.block_sectors.unwrap_or(1);
|
||||
// Patch always reads with full drive recovery — this is the pass where
|
||||
// we want the drive's ECC retry machinery. Consumers who want fast-fail
|
||||
// use Disc::copy with skip_on_error instead.
|
||||
let _ = opts.full_recovery;
|
||||
let recovery = opts.full_recovery;
|
||||
|
||||
let bytes_good_before = map.stats().bytes_good;
|
||||
let mut halted = false;
|
||||
let mut wedged_exit = false;
|
||||
let mut blocks_attempted: u64 = 0;
|
||||
let mut blocks_read_ok: u64 = 0;
|
||||
let mut blocks_read_failed: u64 = 0;
|
||||
let mut consecutive_failures: u64 = 0;
|
||||
let mut buf = vec![0u8; block_sectors as usize * 2048];
|
||||
|
||||
// Collect bad ranges up front. Iterating while mutating is fragile;
|
||||
// each recorded change is persisted, so resume works even if we crash
|
||||
// mid-loop.
|
||||
let bad_ranges = map.ranges_with(&[
|
||||
let mut bad_ranges = map.ranges_with(&[
|
||||
mapfile::SectorStatus::NonTried,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
mapfile::SectorStatus::Unreadable,
|
||||
]);
|
||||
// Reverse mode: walk ranges from highest LBA to lowest.
|
||||
if opts.reverse {
|
||||
bad_ranges.reverse();
|
||||
}
|
||||
// work_total = sum of all bad-range bytes. on_progress's third arg
|
||||
// is this value; the second arg (work_done) is incremented per block
|
||||
// attempted. UI consumers (autorip) compute pass_progress_pct =
|
||||
// work_done / work_total — true 0..100% per pass per RIP_DESIGN.md §16.
|
||||
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
|
||||
let mut work_done: u64 = 0;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_start",
|
||||
block_sectors,
|
||||
recovery,
|
||||
reverse = opts.reverse,
|
||||
wedged_threshold = opts.wedged_threshold,
|
||||
num_ranges = bad_ranges.len(),
|
||||
work_total,
|
||||
"Disc::patch entered"
|
||||
);
|
||||
|
||||
'outer: for (range_pos, range_size) in bad_ranges {
|
||||
let mut pos = range_pos;
|
||||
let end = range_pos + range_size;
|
||||
while pos < end {
|
||||
// In reverse mode, walk this range from end - block_bytes back to range_pos.
|
||||
// Each iteration emits the block ending at `block_end` (so reads land on
|
||||
// increasing LBAs internally; we just choose blocks back-to-front).
|
||||
let mut block_end = if opts.reverse { end } else { range_pos };
|
||||
loop {
|
||||
if let Some(ref h) = opts.halt {
|
||||
if h.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
halted = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
let block_bytes = (end - pos).min(block_sectors as u64 * 2048);
|
||||
// Compute block boundaries based on direction.
|
||||
let (pos, block_bytes) = if opts.reverse {
|
||||
if block_end <= range_pos {
|
||||
break;
|
||||
}
|
||||
let span = (block_end - range_pos).min(block_sectors as u64 * 2048);
|
||||
(block_end - span, span)
|
||||
} else {
|
||||
if block_end >= end {
|
||||
break;
|
||||
}
|
||||
let span = (end - block_end).min(block_sectors as u64 * 2048);
|
||||
(block_end, span)
|
||||
};
|
||||
let lba = (pos / 2048) as u32;
|
||||
let count = (block_bytes / 2048) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
blocks_attempted += 1;
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], true)
|
||||
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
||||
.is_ok();
|
||||
if read_ok {
|
||||
blocks_read_ok += 1;
|
||||
consecutive_failures = 0;
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
@@ -1529,20 +1671,79 @@ impl Disc {
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
} else {
|
||||
blocks_read_failed += 1;
|
||||
consecutive_failures += 1;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
pos += block_bytes;
|
||||
// Advance block_end in chosen direction.
|
||||
if opts.reverse {
|
||||
block_end = block_end.saturating_sub(block_bytes);
|
||||
} else {
|
||||
block_end += block_bytes;
|
||||
}
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
// Wedged-drive early-exit: many consecutive failures with zero
|
||||
// recovered bytes this pass means the drive is stuck and won't
|
||||
// produce data this pass. Save the wallclock budget for productive
|
||||
// grinding; future passes (with smaller block size, reverse, or
|
||||
// after settle) may still recover.
|
||||
if opts.wedged_threshold > 0
|
||||
&& consecutive_failures >= opts.wedged_threshold
|
||||
&& blocks_read_ok == 0
|
||||
{
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_wedged_exit",
|
||||
consecutive_failures,
|
||||
blocks_read_failed,
|
||||
"Disc::patch giving up — drive appears wedged"
|
||||
);
|
||||
wedged_exit = true;
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// Track work done in this pass for the per-pass progress bar.
|
||||
// Each block iterated counts as work, regardless of read
|
||||
// outcome — a failed retry is still progress through the
|
||||
// bad-range walk.
|
||||
work_done = work_done.saturating_add(block_bytes);
|
||||
|
||||
if let Some(reporter) = opts.progress {
|
||||
let s = map.stats();
|
||||
cb(s.bytes_good, total_bytes);
|
||||
let kind = if block_sectors == 1 {
|
||||
crate::progress::PassKind::Scrape {
|
||||
reverse: opts.reverse,
|
||||
}
|
||||
} else {
|
||||
crate::progress::PassKind::Trim {
|
||||
reverse: opts.reverse,
|
||||
}
|
||||
};
|
||||
reporter.report(&crate::progress::PassProgress {
|
||||
kind,
|
||||
work_done,
|
||||
work_total,
|
||||
bytes_good_total: s.bytes_good,
|
||||
bytes_total_disc: total_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_done",
|
||||
blocks_attempted,
|
||||
blocks_read_ok,
|
||||
blocks_read_failed,
|
||||
wedged_exit,
|
||||
halted,
|
||||
bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before),
|
||||
"Disc::patch returning"
|
||||
);
|
||||
Ok(PatchResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
@@ -1550,6 +1751,10 @@ impl Disc {
|
||||
bytes_pending: stats.bytes_pending,
|
||||
bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before),
|
||||
halted,
|
||||
blocks_attempted,
|
||||
blocks_read_ok,
|
||||
blocks_read_failed,
|
||||
wedged_exit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+45
-224
@@ -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,
|
||||
})
|
||||
@@ -121,6 +115,9 @@ impl Drive {
|
||||
self.event_fn = Some(Box::new(f));
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // public on_event registration kept; Drive currently
|
||||
// has no internal emission sites after the 0.13.6 recovery strip.
|
||||
// DiscStream is the BytesRead source. Plan to drop on_event in 0.14.
|
||||
fn emit(&self, kind: EventKind) {
|
||||
if let Some(ref f) = self.event_fn {
|
||||
f(Event { kind });
|
||||
@@ -131,13 +128,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
|
||||
@@ -187,31 +177,16 @@ impl Drive {
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut tried_reset = false;
|
||||
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 5, .. }) if !tried_reset => {
|
||||
// Illegal Request on TUR — drive may be stuck from a previous session.
|
||||
// Try reset() which attempts multiple recovery approaches.
|
||||
tried_reset = true;
|
||||
if self.reset().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// If reset failed but disc is present, proceed anyway —
|
||||
// the scan path will handle errors individually.
|
||||
if self.drive_status() == DriveStatus::DiscPresent {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
@@ -274,86 +249,6 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reset the drive to a clean state.
|
||||
///
|
||||
/// Escalates through increasingly aggressive recovery:
|
||||
/// 1. Unlock tray + stop/start — handles normal stuck states
|
||||
/// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N)
|
||||
/// 3. Re-init — firmware re-upload if profile available
|
||||
///
|
||||
/// Note: step 2 physically ejects the tray. On slimline drives the user
|
||||
/// must push it back in manually. Returns Ok(()) if TUR succeeds after
|
||||
/// any step, even if the drive reports "tray open" (that's a valid state).
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let mut buf = [0u8; 0];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
// 1. Unlock + stop/start
|
||||
self.unlock_tray();
|
||||
let stop = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let start = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Eject cycle — clears MT1959 LibreDrive stuck state.
|
||||
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
|
||||
// counts as success: the drive is functional, just needs disc reinserted.
|
||||
self.unlock_tray();
|
||||
let eject = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&eject, crate::scsi::DataDirection::None, &mut buf, 30_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
match self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 3. If still stuck and we have a profile, try re-init
|
||||
if self.driver.is_some() {
|
||||
self.init()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: self.device_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
@@ -519,29 +414,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 +446,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 +567,22 @@ 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`.
|
||||
/// Find the first optical drive on this system and open it.
|
||||
///
|
||||
/// Wakes within ~100 ms of a halt, regardless of the requested duration.
|
||||
/// For just listing drives without opening (e.g. UI sidebar), use
|
||||
/// `scsi::list_drives()` — that returns `DriveInfo` (path + identity)
|
||||
/// without the cost of running every drive's profile + identity probe.
|
||||
pub fn find_drive() -> Option<Drive> {
|
||||
discover_drives()
|
||||
.into_iter()
|
||||
.find_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
|
||||
}
|
||||
|
||||
/// 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 +599,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")]
|
||||
|
||||
+2
-1
@@ -88,6 +88,7 @@ pub mod mux;
|
||||
pub mod pes;
|
||||
pub(crate) mod platform;
|
||||
pub mod profile;
|
||||
pub mod progress;
|
||||
pub mod scsi;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
@@ -102,7 +103,7 @@ pub mod verify;
|
||||
pub use drive::capture::{
|
||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||
};
|
||||
pub use drive::{Drive, DriveStatus, find_drive, find_drives};
|
||||
pub use drive::{Drive, DriveStatus, find_drive};
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -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,7 @@ 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 +203,8 @@ impl DiscStream {
|
||||
halt: None,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
bytes_read_total: 0,
|
||||
bytes_total_extents,
|
||||
ts_demuxer,
|
||||
ps_demuxer,
|
||||
parsers,
|
||||
@@ -287,6 +298,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Pipeline-progress reporting for the rip pipeline.
|
||||
//!
|
||||
//! v0.13.16 architecture rule: ONE progress signal type. Every long-running
|
||||
//! pipeline operation (`Disc::copy`, `Disc::patch`, mux) emits the same
|
||||
//! `PassProgress` shape via the `Progress` trait. Consumers (autorip) compute
|
||||
//! a single `PipelineStats` derived view and never reach into per-pass
|
||||
//! internals.
|
||||
//!
|
||||
//! Why this matters: pre-0.13.16 the API leaked `pos`, `bytes_good`,
|
||||
//! `work_done`, `bytes_pending`, `Finished/NonTrimmed` mapfile semantics —
|
||||
//! and consumers reinvented the math each time they wanted a percentage.
|
||||
//! UIs ended up reading one source while server-side computed from another,
|
||||
//! producing wrong percentages without anyone noticing.
|
||||
|
||||
/// Identifies which pipeline phase the progress event belongs to.
|
||||
///
|
||||
/// Consumers can render a phase-specific label (e.g. "Sweep", "Trim
|
||||
/// (reverse)", "Scrape", "Mux") or just use a generic "Pass N" label.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PassKind {
|
||||
/// `Disc::copy` — initial sweep across the entire disc.
|
||||
Sweep,
|
||||
/// `Disc::patch` retry pass with `block_sectors >= 2`. `reverse=true`
|
||||
/// means walking bad ranges from highest to lowest LBA.
|
||||
Trim { reverse: bool },
|
||||
/// `Disc::patch` final pass at 1 sector per block.
|
||||
Scrape { reverse: bool },
|
||||
/// Demux ISO → output (MKV / M2TS / network). Single phase that runs
|
||||
/// after all rip passes complete.
|
||||
Mux,
|
||||
}
|
||||
|
||||
/// One progress sample from a pipeline phase.
|
||||
///
|
||||
/// `work_done / work_total` is the per-pass percentage — always 0..=100%
|
||||
/// regardless of which kind of pass is running. `bytes_good_total` is the
|
||||
/// cumulative count of confirmed-clean bytes across the whole rip; useful
|
||||
/// for the "data recovered" stat the user sees.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind,
|
||||
/// Bytes processed in this pass so far. Monotonically non-decreasing.
|
||||
pub work_done: u64,
|
||||
/// Total bytes this pass will process. Constant for the duration of
|
||||
/// the pass.
|
||||
pub work_total: u64,
|
||||
/// Cumulative bytes confirmed clean (`Finished` mapfile state) across
|
||||
/// every pass run on this rip. Doesn't change across pass boundaries.
|
||||
pub bytes_good_total: u64,
|
||||
/// Total disc capacity in bytes. Constant.
|
||||
pub bytes_total_disc: u64,
|
||||
}
|
||||
|
||||
/// A consumer of pipeline progress events. Library code calls
|
||||
/// `Progress::report` once per inner-loop iteration (throttling is the
|
||||
/// consumer's job — `report` is cheap; the library doesn't gate it).
|
||||
///
|
||||
/// No `Send`/`Sync` bound — `report` is always called from the same thread
|
||||
/// running the rip pipeline, so closures with non-`Sync` captures (e.g.
|
||||
/// `RefCell<PassProgressState>`) work directly. Blanket impl below lets
|
||||
/// callers pass closures without explicit struct types.
|
||||
pub trait Progress {
|
||||
fn report(&self, p: &PassProgress);
|
||||
}
|
||||
|
||||
impl<F: Fn(&PassProgress)> Progress for F {
|
||||
fn report(&self, p: &PassProgress) {
|
||||
(self)(p)
|
||||
}
|
||||
}
|
||||
+223
-388
@@ -1,33 +1,34 @@
|
||||
//! Linux SCSI transport via async sg write/poll/read.
|
||||
//! Linux SCSI transport via synchronous blocking SG_IO ioctl.
|
||||
//!
|
||||
//! Uses the sg driver's asynchronous interface instead of the blocking
|
||||
//! SG_IO ioctl. Commands are submitted via write(), waited on via
|
||||
//! poll() with a hard timeout, and completed via read(). If poll()
|
||||
//! times out, the fd is abandoned (closed in a background thread) and
|
||||
//! a fresh fd is opened. This gives us true user-controlled timeouts
|
||||
//! that the kernel's USB error recovery cannot override.
|
||||
//! `execute()` is one syscall: `ioctl(fd, SG_IO, &hdr)` blocks until the
|
||||
//! kernel completes the command (success, error, or its own timeout).
|
||||
//! No userspace abort, no fd close+reopen, no SG_SCSI_RESET escalation —
|
||||
//! the kernel SCSI mid-layer's `scsi_eh.rst` ladder
|
||||
//! (ABORT TASK → LUN RESET → BUS RESET → HOST RESET) runs internally
|
||||
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
||||
//! kernel has already done what it can.
|
||||
//!
|
||||
//! This matches what every reference project does: MakeMKV (8 s sync
|
||||
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
|
||||
//! devices (30 s `/sys/.../timeout`). See
|
||||
//! `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`
|
||||
//! for the full primary-source audit.
|
||||
//!
|
||||
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
||||
//! bg reopen` pattern. That abandoned slow-but-alive commands faster than
|
||||
//! the drive could drain its internal queue, deepening the wedge
|
||||
//! pattern on the LG BU40N. Reverted in 0.13.20.
|
||||
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
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 {
|
||||
@@ -56,7 +57,7 @@ struct sg_io_hdr {
|
||||
}
|
||||
|
||||
// Compile-time validation: sg_io_hdr must match the kernel's layout.
|
||||
// 64 bytes on 64-bit, 44 bytes on 32-bit (pointer-size dependent).
|
||||
// 88 bytes on 64-bit, 64 bytes on 32-bit (pointer-size dependent).
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
@@ -68,8 +69,9 @@ pub struct SgIoTransport {
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
/// Open a SCSI device for use. Resets the drive first to ensure
|
||||
/// a known good state, then opens a fresh fd for commands.
|
||||
/// Open a SCSI device for use. Software-clean the kernel SG queue
|
||||
/// (`reset()`) before opening so a previous killed process's queued
|
||||
/// commands don't bleed into ours.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let device = Self::resolve_to_sg(device);
|
||||
Self::reset(&device)?;
|
||||
@@ -89,49 +91,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 +127,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,139 +141,19 @@ 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 {
|
||||
Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: permission denied (try running as root)",
|
||||
device.display()
|
||||
),
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
@@ -298,8 +163,7 @@ impl SgIoTransport {
|
||||
}
|
||||
|
||||
/// Send a raw SCSI command on an fd. Used by reset() before the
|
||||
/// transport is constructed. Uses synchronous SG_IO — fine for
|
||||
/// short commands (TUR, PREVENT MEDIUM REMOVAL, START/STOP).
|
||||
/// transport is constructed.
|
||||
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
@@ -315,7 +179,7 @@ impl SgIoTransport {
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
if ret < 0 || hdr.status != 0 {
|
||||
if ret < 0 || hdr.status != 0 || hdr.host_status != 0 || hdr.driver_status != 0 {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -360,7 +224,7 @@ impl SgIoTransport {
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.fd >= 0 {
|
||||
// Unlock tray before closing — don't leave it locked
|
||||
// Unlock tray before closing — don't leave it locked.
|
||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
@@ -368,21 +232,24 @@ impl Drop for SgIoTransport {
|
||||
}
|
||||
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
/// Execute a SCSI command with an enforceable timeout.
|
||||
/// Execute a SCSI command via synchronous blocking SG_IO.
|
||||
///
|
||||
/// Uses the sg driver's async write/poll/read interface:
|
||||
/// 1. write() submits the command — returns immediately
|
||||
/// 2. poll() waits for completion — respects our timeout exactly
|
||||
/// 3. read() retrieves the result — copies data to caller's buffer
|
||||
/// One syscall: `ioctl(fd, SG_IO, &hdr)`. The kernel honors
|
||||
/// `hdr.timeout` and runs its own ABORT TASK → LUN RESET → BUS
|
||||
/// RESET → HOST RESET escalation if the device times out (per
|
||||
/// `Documentation/scsi/scsi_eh.rst`). By the time this returns,
|
||||
/// the kernel has done its recovery work.
|
||||
///
|
||||
/// If poll() times out, the pending command is abandoned: the old fd
|
||||
/// is closed in a background thread (may block while kernel finishes
|
||||
/// the USB transfer) and a fresh fd is opened. The caller sees a
|
||||
/// normal SCSI error and can retry.
|
||||
/// Errors we surface to caller (any of these = command failed):
|
||||
///
|
||||
/// Without SG_FLAG_DIRECT_IO, the kernel uses internal buffers for
|
||||
/// DMA and copies to userspace during read(). On timeout (no read),
|
||||
/// the caller's buffer is untouched — safe to return immediately.
|
||||
/// - ioctl returned -1 → `Error::IoError` (kernel-level failure)
|
||||
/// - `hdr.host_status` != 0 → `Error::ScsiError` with status=0xFF
|
||||
/// (transport-level: timeout, bridge wedge, etc.)
|
||||
/// - `hdr.driver_status` != 0 → `Error::ScsiError` with status=0xFF
|
||||
/// - `hdr.status` != 0 → `Error::ScsiError` with parsed sense key
|
||||
///
|
||||
/// Caller's `data` buffer is mutated only on success; partial
|
||||
/// transfers are reported via `bytes_transferred = data.len() - resid`.
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
@@ -390,19 +257,17 @@ impl ScsiTransport for SgIoTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if self.fd < 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: self.device_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
let exec_t0 = std::time::Instant::now();
|
||||
let opcode = cdb[0];
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "enter",
|
||||
opcode = opcode,
|
||||
timeout_ms,
|
||||
data_len = data.len(),
|
||||
fd = self.fd,
|
||||
"SgIoTransport::execute"
|
||||
);
|
||||
|
||||
if data.len() > u32::MAX as usize {
|
||||
return Err(Error::ScsiError {
|
||||
@@ -412,8 +277,14 @@ impl ScsiTransport for SgIoTransport {
|
||||
});
|
||||
}
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
let cmd_len = cdb.len().min(16) as u8;
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
@@ -426,66 +297,43 @@ impl ScsiTransport for SgIoTransport {
|
||||
hdr.timeout = timeout_ms;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
// Submit command asynchronously via write()
|
||||
let hdr_size = std::mem::size_of::<sg_io_hdr>();
|
||||
let wr = unsafe {
|
||||
libc::write(
|
||||
self.fd,
|
||||
&hdr as *const sg_io_hdr as *const libc::c_void,
|
||||
hdr_size,
|
||||
)
|
||||
};
|
||||
if wr < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
// The single blocking syscall. Returns when the device responds,
|
||||
// when the kernel's timeout fires, or when the kernel's error
|
||||
// recovery completes its escalation. On a healthy read this is
|
||||
// <100 ms; on a slow-recovery bad sector it can be tens of
|
||||
// seconds; on a hung drive it returns at `timeout_ms` with
|
||||
// `host_status` flagged.
|
||||
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
let exec_elapsed_ms = exec_t0.elapsed().as_millis() as u64;
|
||||
|
||||
if ret < 0 {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "ioctl_err",
|
||||
opcode = opcode,
|
||||
errno = errno.raw_os_error().unwrap_or(0),
|
||||
exec_elapsed_ms,
|
||||
"ioctl(SG_IO) returned <0"
|
||||
);
|
||||
return Err(Error::IoError { source: errno });
|
||||
}
|
||||
|
||||
// Wait for completion with enforceable timeout.
|
||||
// Retry on EINTR (signal interrupted poll) with remaining time.
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
|
||||
let pr = loop {
|
||||
let remaining = deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.as_millis() as i32;
|
||||
if remaining <= 0 {
|
||||
break 0; // expired
|
||||
}
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let ret = unsafe { libc::poll(&mut pfd, 1, remaining) };
|
||||
if ret >= 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
break ret;
|
||||
}
|
||||
};
|
||||
|
||||
if pr <= 0 {
|
||||
// Timeout (0) or fatal poll error (-1).
|
||||
// Command is still pending in the kernel. Abandon this fd and
|
||||
// open a fresh one. The old fd is closed in a background thread
|
||||
// because close() blocks until the kernel completes/aborts the
|
||||
// pending command.
|
||||
let old_fd = self.fd;
|
||||
self.fd = -1;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
unsafe { libc::close(old_fd) };
|
||||
});
|
||||
|
||||
let c_path = Self::to_c_path(&self.device_path);
|
||||
let new_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
self.fd = if new_fd >= 0 { new_fd } else { -1 };
|
||||
|
||||
// Transport-level failure (kernel timeout, USB bridge wedge,
|
||||
// bus error). `hdr.status` may still be zero — the SCSI device
|
||||
// never got to send a status byte. Surface as 0xFF so callers
|
||||
// (e.g. `drive_has_disc`) can detect the wedge signature.
|
||||
if hdr.host_status != 0 || hdr.driver_status != 0 {
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "transport_err",
|
||||
opcode = opcode,
|
||||
host_status = hdr.host_status,
|
||||
driver_status = hdr.driver_status,
|
||||
status = hdr.status,
|
||||
exec_elapsed_ms,
|
||||
"transport-level failure (timeout / bridge wedge)"
|
||||
);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
@@ -493,35 +341,19 @@ impl ScsiTransport for SgIoTransport {
|
||||
});
|
||||
}
|
||||
|
||||
// Read response — copies data from kernel buffer to caller's buffer
|
||||
let rd = unsafe {
|
||||
libc::read(
|
||||
self.fd,
|
||||
&mut hdr as *mut sg_io_hdr as *mut libc::c_void,
|
||||
hdr_size,
|
||||
)
|
||||
};
|
||||
if rd < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
|
||||
|
||||
// SCSI-level failure: device responded, returned non-zero status.
|
||||
// Parse sense key for the caller.
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr >= 3 {
|
||||
let response_code = sense[0] & 0x7F;
|
||||
if response_code == 0x72 || response_code == 0x73 {
|
||||
// Descriptor format sense: sense key at byte 1
|
||||
sense[1] & 0x0F
|
||||
} else {
|
||||
// Fixed format sense (0x70/0x71): sense key at byte 2
|
||||
sense[2] & 0x0F
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let sense_key = super::parse_sense_key(&sense, hdr.sb_len_wr);
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "scsi_err",
|
||||
opcode = opcode,
|
||||
status = hdr.status,
|
||||
sense_key,
|
||||
exec_elapsed_ms,
|
||||
"SCSI status non-zero"
|
||||
);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
@@ -529,6 +361,15 @@ impl ScsiTransport for SgIoTransport {
|
||||
});
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "ok",
|
||||
opcode = opcode,
|
||||
bytes_transferred,
|
||||
exec_elapsed_ms,
|
||||
"execute() success"
|
||||
);
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
@@ -544,10 +385,10 @@ impl ScsiTransport for SgIoTransport {
|
||||
// `/dev/sg0..15` probe when sysfs is unreadable (minimal containers).
|
||||
//
|
||||
// `drive_has_disc` issues a single TEST UNIT READY. On the wedge signature
|
||||
// (kernel returns status `0xff` with no sense) it escalates: SCSI bus reset
|
||||
// → if still wedged → USB device reset (`USBDEVFS_RESET`) → retry TUR.
|
||||
// Callers never see the escalation; if it fails too, surface
|
||||
// `DeviceResetFailed` so the caller can back off.
|
||||
// (kernel returns status `0xff` with no sense — synthesised by `execute()`
|
||||
// from a non-zero `host_status`) the error bubbles directly to the caller;
|
||||
// no in-library reset escalation. See the rationale block on
|
||||
// `drive_has_disc` below.
|
||||
|
||||
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE, etc.).
|
||||
/// Stored in `/sys/class/scsi_generic/sgN/device/type` as ASCII decimal.
|
||||
@@ -563,16 +404,6 @@ const SENSE_KEY_NOT_READY: u8 = 2;
|
||||
/// realistic homelab (typical PERC + USB optical = ≤8 nodes).
|
||||
const SG_FALLBACK_MAX: u8 = 16;
|
||||
|
||||
/// SCSI INQUIRY response field offsets (SPC-4, 6-byte standard CDB
|
||||
/// returning 96 bytes). Used to populate `DriveInfo` fields without
|
||||
/// magic-number arithmetic at the call site.
|
||||
const INQUIRY_VENDOR_OFFSET: usize = 8;
|
||||
const INQUIRY_VENDOR_LEN: usize = 8;
|
||||
const INQUIRY_MODEL_OFFSET: usize = 16;
|
||||
const INQUIRY_MODEL_LEN: usize = 16;
|
||||
const INQUIRY_FIRMWARE_OFFSET: usize = 32;
|
||||
const INQUIRY_FIRMWARE_LEN: usize = 4;
|
||||
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut out = Vec::new();
|
||||
let names = enumerate_sg_names();
|
||||
@@ -581,26 +412,38 @@ 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,
|
||||
// commands beyond what `SgIoTransport::open` already does.
|
||||
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 +451,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 +507,36 @@ fn enumerate_sg_names() -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
/// `drive_has_disc` = single TEST UNIT READY. Any error (including the
|
||||
/// wedge signature `ScsiError { status: 0xFF }` synthesised by `execute()`
|
||||
/// when `host_status` is set) 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 +554,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;
|
||||
|
||||
+45
-212
@@ -195,6 +195,17 @@ impl MacScsiTransport {
|
||||
dev_str
|
||||
};
|
||||
|
||||
let device_iface = Self::acquire_device_iface(bsd_name)?;
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the BSD name → IOKit SCSITaskDeviceInterface with exclusive
|
||||
/// access. Returns the COM ref the caller must release.
|
||||
fn acquire_device_iface(bsd_name: &str) -> Result<ComRef> {
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
|
||||
// Create IOKit plugin for the MMC device
|
||||
@@ -213,7 +224,7 @@ impl MacScsiTransport {
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
return Err(Error::IoKitPluginFailed {
|
||||
path: dev_str.to_string(),
|
||||
path: bsd_name.to_string(),
|
||||
kr: kr as u32,
|
||||
});
|
||||
}
|
||||
@@ -233,7 +244,7 @@ impl MacScsiTransport {
|
||||
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::ScsiInterfaceUnavailable {
|
||||
path: dev_str.to_string(),
|
||||
path: bsd_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -245,179 +256,22 @@ impl MacScsiTransport {
|
||||
};
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
com_release(device_iface);
|
||||
// No "Try: diskutil unmountDisk" hint — that's the CLI's job.
|
||||
// The typed variant carries device path + IOReturn so the
|
||||
// caller can render the right message in the right language.
|
||||
return Err(Error::DeviceLocked {
|
||||
path: dev_str.to_string(),
|
||||
path: bsd_name.to_string(),
|
||||
kr: kr as u32,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
})
|
||||
Ok(device_iface)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// `try_recover()` removed in 0.13.20 — userspace handle-recovery on
|
||||
// task failure was the same anti-pattern stripped from Linux SG_IO
|
||||
// (see (internal)/docs/audits/2026-04-26-scsi-architecture-research.md).
|
||||
// Errors bubble up; caller decides whether to reopen the Drive.
|
||||
}
|
||||
|
||||
/// `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,60 +321,34 @@ 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.device_iface.is_null() {
|
||||
return;
|
||||
}
|
||||
if self.exclusive {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
@@ -617,6 +445,8 @@ impl ScsiTransport for MacScsiTransport {
|
||||
com_release(task);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
// Task-level failure (timeout / IOKit error). Bubble it up;
|
||||
// the kernel mid-layer has already done what it can.
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
@@ -625,7 +455,10 @@ impl ScsiTransport for MacScsiTransport {
|
||||
}
|
||||
|
||||
if task_status != K_SCSI_TASK_STATUS_GOOD as u32 {
|
||||
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
|
||||
// IOKit doesn't surface a "bytes written into sense buffer"
|
||||
// count the way SG_IO does — pass the buffer's full length
|
||||
// and let parse_sense_key inspect byte 0's response code.
|
||||
let sense_key = super::parse_sense_key(&sense, sense.len() as u8);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status as u8,
|
||||
|
||||
+161
-185
@@ -43,6 +43,36 @@ pub const AACS_KEY_CLASS: u8 = 0x02;
|
||||
/// a poll-loop tick.
|
||||
pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
// ── Sense-key parsing ───────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the SPC-4 sense key from a sense buffer.
|
||||
///
|
||||
/// Handles both response-code formats:
|
||||
/// - Descriptor format (0x72 / 0x73): sense key in the low nibble of byte 1.
|
||||
/// - Fixed format (0x70 / 0x71): sense key in the low nibble of byte 2.
|
||||
///
|
||||
/// `sb_len_wr` is the number of bytes the transport actually wrote into
|
||||
/// `sense`. When < 3 (or `sense.len() < 3`) we can't safely read either
|
||||
/// the format byte or the key byte — return 0 (NO SENSE) per SPC-4 §4.5.3.
|
||||
///
|
||||
/// Pure function. Same parse runs on every platform backend so
|
||||
/// callers don't have to special-case Linux SG_IO vs macOS IOKit vs
|
||||
/// Windows SPTI sense layouts.
|
||||
pub(crate) fn parse_sense_key(sense: &[u8], sb_len_wr: u8) -> u8 {
|
||||
if (sb_len_wr as usize) < 3 || sense.len() < 3 {
|
||||
return 0;
|
||||
}
|
||||
let response_code = sense[0] & 0x7F;
|
||||
if response_code == 0x72 || response_code == 0x73 {
|
||||
sense[1] & 0x0F
|
||||
} else {
|
||||
// Fixed format (0x70/0x71) and any unknown code fall through here;
|
||||
// SPC-4 says implementations MUST tolerate unknown response codes
|
||||
// and treat them as fixed — matches what reference projects do.
|
||||
sense[2] & 0x0F
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -98,192 +128,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 ─────────────────────────────────
|
||||
//
|
||||
@@ -486,3 +356,109 @@ pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_sense_tests {
|
||||
//! Unit tests for `parse_sense_key`. Covers both SPC-4 sense data
|
||||
//! formats (descriptor / fixed) and the short-buffer fallback. The
|
||||
//! same helper runs on every platform backend so a regression here
|
||||
//! would silently miscategorize SCSI errors on Linux, macOS, and
|
||||
//! Windows simultaneously.
|
||||
use super::parse_sense_key;
|
||||
|
||||
/// Helper: build a 32-byte sense buffer whose first three bytes are
|
||||
/// the given prefix; the rest are zeroes (sense data area).
|
||||
fn buf(b0: u8, b1: u8, b2: u8) -> [u8; 32] {
|
||||
let mut s = [0u8; 32];
|
||||
s[0] = b0;
|
||||
s[1] = b1;
|
||||
s[2] = b2;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_format_72_picks_byte_1() {
|
||||
// Response code 0x72 (current, descriptor): sense key is the
|
||||
// low nibble of byte 1. Byte 2 here is 0x77 to prove it is NOT
|
||||
// the byte the parser reads.
|
||||
let s = buf(0x72, 0x05, 0x77); // ILLEGAL REQUEST
|
||||
assert_eq!(parse_sense_key(&s, 8), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_format_73_picks_byte_1() {
|
||||
// Response code 0x73 (deferred, descriptor): same parse rule
|
||||
// as 0x72.
|
||||
let s = buf(0x73, 0x06, 0xFF); // UNIT ATTENTION
|
||||
assert_eq!(parse_sense_key(&s, 8), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_format_70_picks_byte_2() {
|
||||
// Response code 0x70 (current, fixed): sense key is the low
|
||||
// nibble of byte 2. Byte 1 is 0x77 to prove it is NOT read.
|
||||
let s = buf(0x70, 0x77, 0x05); // ILLEGAL REQUEST
|
||||
assert_eq!(parse_sense_key(&s, 18), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_format_71_picks_byte_2() {
|
||||
// Response code 0x71 (deferred, fixed): same parse as 0x70.
|
||||
let s = buf(0x71, 0x77, 0x02); // NOT READY
|
||||
assert_eq!(parse_sense_key(&s, 18), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_bit_in_byte_0_is_masked() {
|
||||
// SPC-4 sets the top bit of byte 0 ("INFORMATION VALID" / "VALID")
|
||||
// independently of the response code. parse_sense_key must mask
|
||||
// it off before classifying the format.
|
||||
let s = buf(0xF2, 0x05, 0x77);
|
||||
assert_eq!(parse_sense_key(&s, 8), 5, "VALID-bit must not leak into format detection");
|
||||
let s = buf(0xF0, 0x77, 0x02);
|
||||
assert_eq!(parse_sense_key(&s, 18), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_nibble_in_key_byte_is_masked() {
|
||||
// Sense key is byte_n & 0x0F (low nibble). Top nibble holds
|
||||
// FILEMARK / EOM / ILI / SDAT_OVFL flags, which must not bleed
|
||||
// into the key value.
|
||||
let s = buf(0x70, 0x00, 0xE5); // 0xE0 flags + key 5
|
||||
assert_eq!(parse_sense_key(&s, 18), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sb_len_wr_zero_returns_no_sense() {
|
||||
// Transport set status non-zero but wrote zero sense bytes —
|
||||
// SPC-4 §4.5.3 says treat as NO SENSE (key 0).
|
||||
let s = buf(0x72, 0x05, 0x05);
|
||||
assert_eq!(parse_sense_key(&s, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sb_len_wr_below_three_returns_no_sense() {
|
||||
// Less than three bytes in the buffer means we can't safely
|
||||
// read either format byte 0 or key byte 2 — return 0.
|
||||
let s = buf(0x72, 0x05, 0x05);
|
||||
assert_eq!(parse_sense_key(&s, 1), 0);
|
||||
assert_eq!(parse_sense_key(&s, 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_below_three_returns_no_sense() {
|
||||
// Defense-in-depth: even if a caller passes a too-short slice
|
||||
// with a falsely-large sb_len_wr, we don't panic and we return 0.
|
||||
let s = [0x72u8, 0x05];
|
||||
assert_eq!(parse_sense_key(&s, 8), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_response_code_falls_through_to_fixed() {
|
||||
// SPC-4 mandates implementations tolerate unknown response
|
||||
// codes and treat them as fixed format. Vendor-specific codes
|
||||
// in the 0x74..0x7E range surface here.
|
||||
let s = buf(0x7A, 0x77, 0x03); // MEDIUM ERROR via "fixed"
|
||||
assert_eq!(parse_sense_key(&s, 18), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-35
@@ -87,6 +87,10 @@ pub struct SptiTransport {
|
||||
handle: isize,
|
||||
}
|
||||
|
||||
// SptiTransport's only field is the isize HANDLE — Send is auto-derived
|
||||
// and intentional. Sync is NOT: handle mutation in execute() requires
|
||||
// &mut, enforced by the trait object dispatch.
|
||||
|
||||
/// Normalize a device path to Windows \\.\X: format.
|
||||
///
|
||||
/// NOTE: A near-identical `normalize_path` exists in `drive::windows`.
|
||||
@@ -129,8 +133,17 @@ impl SptiTransport {
|
||||
};
|
||||
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: cannot open device (run as administrator)", dev_str),
|
||||
// Map last-os-error → Error variant; don't embed English hints
|
||||
// in the path field (the CLI handles localization).
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: dev_str.to_string(),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: dev_str.to_string(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,28 +198,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 +215,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];
|
||||
@@ -245,8 +235,10 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
if self.handle != INVALID_HANDLE_VALUE {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,7 +269,11 @@ impl ScsiTransport for SptiTransport {
|
||||
DataDirection::ToDevice => SCSI_IOCTL_DATA_OUT,
|
||||
};
|
||||
sptwb.spt.DataTransferLength = data.len() as u32;
|
||||
sptwb.spt.TimeOutValue = (timeout_ms / 1000).max(1) as u32;
|
||||
// Round up to the next whole second so a 1500ms request gets at
|
||||
// least 2s, not 1s. SPTI's TimeOutValue is u32 seconds with no
|
||||
// sub-second resolution; biasing toward "more time" is safer than
|
||||
// truncating (truncation broke 1500ms fast-reads on Drive::read).
|
||||
sptwb.spt.TimeOutValue = ((timeout_ms + 999) / 1000).max(1);
|
||||
sptwb.spt.DataBuffer = if data.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
@@ -303,6 +299,13 @@ impl ScsiTransport for SptiTransport {
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
// Driver-level failure (timeout, handle gone, etc.). Bubble
|
||||
// up; in-library handle recovery was removed in 0.13.20 along
|
||||
// with Linux's async fd-recovery and macOS's `try_recover` —
|
||||
// the kernel mid-layer already did its escalation by the time
|
||||
// DeviceIoControl returned, and re-issuing reset/reopen here
|
||||
// is at best redundant and at worst deepens the wedge. Caller
|
||||
// surfaces the failure to UX.
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
@@ -311,11 +314,11 @@ impl ScsiTransport for SptiTransport {
|
||||
}
|
||||
|
||||
if sptwb.spt.ScsiStatus != 0 {
|
||||
let sense_key = if sptwb.sense[2] != 0 {
|
||||
sptwb.sense[2] & 0x0F
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// SPTI doesn't surface a "bytes written into sense buffer"
|
||||
// count separate from SenseInfoLength (input). Pass the full
|
||||
// K_SENSE_SIZE; parse_sense_key keys off byte 0's response
|
||||
// code to handle descriptor (0x72/0x73) vs fixed (0x70/0x71).
|
||||
let sense_key = super::parse_sense_key(&sptwb.sense, K_SENSE_SIZE as u8);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: sptwb.spt.ScsiStatus,
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
//! 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));
|
||||
|
||||
struct CountingReporter {
|
||||
calls: Arc<AtomicU64>,
|
||||
last_bytes: Arc<AtomicU64>,
|
||||
}
|
||||
impl libfreemkv::progress::Progress for CountingReporter {
|
||||
fn report(&self, p: &libfreemkv::progress::PassProgress) {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let reporter = CountingReporter {
|
||||
calls: calls.clone(),
|
||||
last_bytes: last_bytes.clone(),
|
||||
};
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
progress: Some(&reporter),
|
||||
..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");
|
||||
}
|
||||
|
||||
// ── 6. Pass 1 sweeps the entire disc even when every read fails ───────────
|
||||
//
|
||||
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
|
||||
// regardless of how many reads fail. The only legitimate early exit is the
|
||||
// halt flag. With `skip_on_error + skip_forward` and a reader that returns
|
||||
// Err for every read, Pass 1 must:
|
||||
// - mark every sector NonTrimmed (so Pass 2 can retry them)
|
||||
// - return cleanly (no panic, no hang)
|
||||
// - bytes_good = 0
|
||||
// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile
|
||||
// accounting; see disc/mapfile.rs::stats)
|
||||
// - bytes_unreadable = 0 (only Pass 2 marks Unreadable)
|
||||
// - complete = false (work remains for Pass 2)
|
||||
// - halted = false (no user stop)
|
||||
// - ISO file is `total_bytes` size on disk (sparse zeros)
|
||||
|
||||
/// Reader that returns Err for every read. Optionally signals a halt
|
||||
/// flag on the first read so tests can exercise the halt-during-skip-forward
|
||||
/// path deterministically (no wallclock dependency).
|
||||
struct FailingSectorReader {
|
||||
capacity: u32,
|
||||
/// If set, signals halt on the first `read_sectors` call. Cleared after
|
||||
/// the first signal so subsequent reads are plain Err.
|
||||
halt_on_first_read: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl FailingSectorReader {
|
||||
fn new(capacity: u32) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
halt_on_first_read: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
halt_on_first_read: Some(halt),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for FailingSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
if let Some(h) = self.halt_on_first_read.take() {
|
||||
h.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Err(libfreemkv::error::Error::DiscRead { sector: lba as u64 })
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_completes_full_disc_with_failing_reader() {
|
||||
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
|
||||
// skip_forward, Pass 1 must mark every sector NonTrimmed and return
|
||||
// cleanly — no bail, no hang.
|
||||
let capacity_sectors: u32 = 1024;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
let mut reader = FailingSectorReader::new(capacity_sectors);
|
||||
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 opts = CopyOptions {
|
||||
decrypt: false,
|
||||
skip_on_error: true,
|
||||
skip_forward: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let result = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
// Hard bound — even at 0 ms per read, 1024 sectors with skip-forward
|
||||
// should complete in well under a second on any host. If this test runs
|
||||
// for minutes, something has regressed (e.g. stall guard reintroduced
|
||||
// with infinite-loop semantics, or Pass 1 is hanging on each read).
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 5 s"
|
||||
);
|
||||
|
||||
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
|
||||
// read outcomes.
|
||||
assert_eq!(
|
||||
result.bytes_total, total_bytes,
|
||||
"bytes_total must match disc capacity"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_good, 0,
|
||||
"no reads succeeded, bytes_good must be 0"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_unreadable, 0,
|
||||
"Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_pending, total_bytes,
|
||||
"every sector must be NonTrimmed → counted as pending. \
|
||||
Got bytes_pending={} of total {}",
|
||||
result.bytes_pending, total_bytes
|
||||
);
|
||||
assert!(
|
||||
!result.complete,
|
||||
"complete=false because NonTrimmed regions remain (work for Pass 2)"
|
||||
);
|
||||
assert!(!result.halted, "no halt was set; halted must be false");
|
||||
|
||||
// ISO file should be the full disc size on disk (sparse zeros where
|
||||
// reads failed).
|
||||
// Note: tempfile was dropped above; the file may or may not still exist
|
||||
// depending on cleanup ordering. We only assert what we can observe in
|
||||
// the CopyResult.
|
||||
}
|
||||
|
||||
// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─
|
||||
//
|
||||
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
|
||||
// Even when every read is failing (skip-forward path), a halt must be
|
||||
// honored within a small bounded time.
|
||||
//
|
||||
// Deterministic fixture: the reader signals halt on its FIRST read. The
|
||||
// inner copy loop's halt check fires on the next iteration, breaking out
|
||||
// of 'outer. This avoids any wallclock race on fast CI runners (where a
|
||||
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_halts_promptly_on_failing_reader() {
|
||||
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
|
||||
|
||||
let halt = Arc::new(AtomicBool::new(false));
|
||||
let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
|
||||
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 opts = CopyOptions {
|
||||
decrypt: false,
|
||||
skip_on_error: true,
|
||||
skip_forward: true,
|
||||
halt: Some(halt),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let result = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok on halt");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"halt must return within 2 s; took {elapsed:?}"
|
||||
);
|
||||
assert!(result.halted, "result.halted must be true");
|
||||
assert!(
|
||||
!result.complete,
|
||||
"halted run cannot be complete (bytes_pending > 0 expected)"
|
||||
);
|
||||
assert!(
|
||||
result.bytes_pending > 0,
|
||||
"halt fired before sweep completed; bytes_pending must be > 0"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user