Compare commits

...
28 Commits
Author SHA1 Message Date
MattJackson b5ed97801b libfreemkv: extend DiscRead with SCSI status/sense for 30% wedge diagnostics 2026-04-27 16:47:27 -07:00
MattJackson ae76aaf0fa v0.13.24 — MapStats: split bytes_pending into nontried / retryable
bytes_pending was an opaque aggregate of NonTried + NonTrimmed +
NonScraped. UIs that wanted a "will retry in Pass 2-N" bucket were
stuck showing the entire unread disc as Maybe at pct=0.

Adds two granular fields to MapStats:

  bytes_nontried   — Pass 1 hasn't read these yet
  bytes_retryable  — NonTrimmed + NonScraped, Pass 2-N will retry

bytes_pending stays for back-compat (= bytes_nontried + bytes_retryable).

Also picks up the cargo fmt --check lint that's been red on main CI
since v0.13.18 (rustfmt fold differences on a few long format-string
layouts; functional no-op).
2026-04-26 19:28:35 -07:00
MattJackson 2cd4fbead7 v0.13.23 — stop discarding the drive's SCSI sense data
Through the entire 0.13.x line, every CHECK CONDITION reply from the
drive (the standard way SCSI tells you why a sector failed) was being
collapsed into a synthetic status=0xFF, sense_key=0 transport-wedge
sentinel and the actual sense data was thrown away. Confirmed live on
the BU40N reading Dune 2 on 2026-04-27: drive returned host_status=0,
driver_status=8, status=2, exec_elapsed_ms=1416 on every bad sector
— a clean CHECK CONDITION carrying full sense data — and Disc::copy
was bailing on it as if the bridge had wedged.

Root cause: scsi/linux.rs's wedge check was
  `host_status != 0 || driver_status != 0`
SG's DRIVER_SENSE bit (0x08) is set on every CHECK CONDITION reply
just to flag "sense buffer is populated" — it's not a transport
failure on its own. Pre-fix we conflated the two and silently lost
every drive-reported error reason. macOS and Windows backends had
the same shape: they extracted sense_key only, dropping ASC/ASCQ.

API restructure (clean separation):

  Error::ScsiError {
      opcode: u8,
      status: u8,                  // 0xFF = synthetic transport-failure
      sense: Option<ScsiSense>,    // None ⇔ no sense delivered
  }

  pub struct ScsiSense { sense_key: u8, asc: u8, ascq: u8 }
  impl ScsiSense {
      pub fn is_marginal(&self) -> bool       // keys 0/1/3/B
      pub fn is_medium_error(&self) -> bool
      pub fn is_hardware_error(&self) -> bool
      pub fn is_unit_attention(&self) -> bool
      pub fn is_data_protect(&self) -> bool
      pub fn is_not_ready(&self) -> bool
      pub fn is_illegal_request(&self) -> bool
      pub fn is_aborted_command(&self) -> bool
  }

  impl Error {
      pub fn scsi_sense(&self) -> Option<&ScsiSense>
      pub fn is_scsi_transport_failure(&self) -> bool
      pub fn is_marginal_read(&self) -> bool
  }

SCSI protocol constants (SCSI_STATUS_*, SENSE_KEY_*) moved from
error.rs to scsi/mod.rs where they belong alongside SCSI_INQUIRY,
SCSI_READ_10, etc. parse_sense replaces parse_sense_key (returns the
full triple, not just the key); inline tests now exercise ASC/ASCQ
extraction at the right offsets for both descriptor (0x72/0x73) and
fixed (0x70/0x71) sense formats.

Disc::copy + Disc::patch sense-aware dispatch:
  - marginal sense (MEDIUM ERROR / ABORTED COMMAND / RECOVERED ERROR
    / NO SENSE) → engage hysteresis (Block→Single, bpt=1)
  - non-marginal sense (HARDWARE / DATA PROTECT / UNIT ATTENTION /
    NOT READY / ILLEGAL REQUEST / transport failure / kernel
    IoError) → bail with full sense info preserved; caller (autorip)
    surfaces "physical replug" / "drive failing" / "media changed"

  Pre-fix: every CHECK CONDITION → 0xFF synthetic → Disc::copy bailed
  → bytes_good froze at the bad zone. The hysteresis from v0.13.22
  was correct but never got to run. This release unblocks it.

  Disc::patch's wedged_threshold (50 consecutive failures) stays as
  defense-in-depth for chains of marginal failures; a single
  non-marginal sense now short-circuits it.

New phase=bail trace event records the bail reason with the sense
triple. phase=transport_err remains for genuine bridge wedges /
kernel timeouts; phase=scsi_err carries the parsed sense_key, asc,
ascq for drive-reported errors.

All 350 tests pass. Clippy clean across all targets.
2026-04-26 19:06:19 -07:00
MattJackson ebffc6eb88 v0.13.22 — replace bisect-on-fail with hysteresis Block↔Single
The v0.13.21 bisect-on-fail recovery was correct (100% of recoverable
sectors picked up) but slow on dense damage clusters. Live test on
Dune 2 v0.13.21 burned ~30 s per damaged 60-block — paying a ~5 s
kernel ABORT/timeout at every level of a log₂(60) ≈ 6 deep DFS, on
the failing branch each time.

Replaced with a two-state hysteresis machine in Disc::copy:

  Block(batch):
    read(batch) ok    → write, advance, stay Block
    read(batch) fail  → switch to Single, retry SAME range at bpt=1

  Single:
    read(1) ok    → write, consecutive_good++
                    if consecutive_good >= BPT1_EXIT_THRESHOLD:
                      switch to Block, reset counter
    read(1) fail  → mark NonTrimmed, consecutive_good = 0

BPT1_EXIT_THRESHOLD = 10_000 sectors (= 20 MB clean run). Calibrated
from the 2026-04-26 BU40N empirical probe data; tunable.

Per-block math on a damaged 60-block with 1 truly bad sector:

  Bisect      (v0.13.21): ~30 s  (5 s × 6 levels)
  Hysteresis  (v0.13.22): ~10 s  (5 s bpt=batch fail
                                  + 59 × 1 ms good
                                  + 1 × 5 s bad)

Inside a damaged cluster spanning many 60-blocks the win compounds:
hysteresis pays the bpt=batch fail cost ONCE on entry, then stays at
bpt=1 across the cluster; bisection re-paid it every 60 sectors. For
Dune 2's ~1248-sector boundary cluster that's ~21 fewer 5-sec
kernel timeouts ≈ 100 s saved per pass.

Telemetry: new phase=mode_change trace event with from, to, lba, and
consecutive_good. Replaces v0.13.21's phase=bisect. Worklist DFS is
gone — single iterative for s in 0..count on the failure path.

Test rename, same fixture and same 100% recovery expectation:
  test_disc_copy_bisect_recovers_via_single_sector_reads
  → test_disc_copy_hysteresis_recovers_via_single_sector_reads

Also adds DamageSeverity (Clean / Cosmetic / Moderate / Serious) +
classify_damage(bad_sectors, lost_ms), re-exported from libfreemkv,
so applications can render structured severity instead of formatting
their own from raw counters.
2026-04-26 17:27:57 -07:00
MattJackson 424d3cd4f2 v0.13.21 — bisect-on-fail in Disc::copy + 10s caller READ timeout
Fixes the BU40N wedge cycle that has been chasing us through
v0.13.18-20. Two changes, both backed by empirical live-hardware
probes recorded in (internal)/docs/TEST_PLAN.md:

1. scsi/mod.rs: READ_TIMEOUT_MS 1500 → 10000 ms.
   Cold-start seek on the BU40N takes ~1.5 s. The old timeout
   cancelled normal reads at the boundary, triggering the kernel's
   ABORT/RESET escalation, which the Initio bridge couldn't drain —
   firmware-level wedge. 10 s catches every legitimate slow read
   (max successful ECC recovery: 2.6 s; cold-start: 1.5 s) with
   margin and short-circuits truly bad sectors at ~10 s.

2. disc/mod.rs: Disc::copy bisect-on-fail (replaces skip-forward).
   Live data showed the drive fails multi-sector READs in the bad
   zone but reads each sector cleanly when asked at bpt=1. Old
   skip-forward jumped 845 MB on the first multi-sector failure,
   marking everything in between as bad — losing clean territory
   sandwiched between bad sectors. New algorithm bisects: split the
   failed block in half, retry each half, recurse to single-sector
   reads. Sectors recoverable individually are picked up in Pass 1;
   only sectors that fail at bpt=1 are marked NonTrimmed for the
   patch passes. Stack-based DFS, log2(batch) = 6 levels for the
   default 60-sector batch.

Multi-pass machinery is untouched. Pass 2..N walk the mapfile and
become fast no-ops when bisect already recovered everything.
Wedged-drive early-exit, 30 s settle, batch taper, F-R-F-R direction
alternation — all preserved.

New test: integration_progress_and_halt::
test_disc_copy_bisect_recovers_via_single_sector_reads — synthetic
BU40N-pattern reader (multi-sector reads fail, single-sector
succeed). Pre-patch: lost everything to skip-forward. Post-patch:
100 % bytes_good. Plus the 10 sense-key parser tests from the
0.13.20 test-coverage pass.

Empirical recovery on Dune 2 UHD on the BU40N (per TEST_PLAN.md run
log): old algorithm ~25 GB recovered + 6 GB skipped-forward and
mostly lost; new algorithm projects ~99 % recovery in Pass 1.

Audits + raw probe data:
- (internal)/docs/TEST_PLAN.md (run log)
- (internal)/docs/audits/2026-04-26-scsi-architecture-research.md
2026-04-26 15:57:44 -07:00
MattJackson d2905ba7bb v0.13.20 — sync blocking SG_IO + cross-platform parity strip
- scsi/linux.rs: full rewrite from async write/poll/read+1.5s timeout+
  close-on-timeout to one synchronous ioctl(fd, SG_IO, &hdr). Kernel
  honors hdr.timeout and runs its own ABORT/RESET escalation. Errors
  check host_status and driver_status (both 0xFF-synthesised) plus
  status. Sense-key parser handles descriptor (0x72/0x73) + fixed
  (0x70/0x71) formats. Deleted fd_recovery, bg close+open thread, fd
  swap dance. -331/+155 lines.

- scsi/macos.rs: try_recover() removed (userspace handle-recovery on
  task failure was the same anti-pattern stripped from Linux). bsd_name
  field deleted. Errors bubble up directly.

- scsi/windows.rs: try_recover() removed, wide_path field deleted,
  INVALID_HANDLE guard removed.

- scsi/mod.rs: parse_sense_key() helper extracted (used by all three
  platforms now — single canonical sense-key parse rather than three
  inlined copies). +10 unit tests covering descriptor format, fixed
  format, truncated buffers, unknown response codes.

- drive/mod.rs: Drive::reset() deleted (escalating eject + STOP/START +
  reinit recovery — per audit, kernel handles its own escalation;
  userspace shouldn't).
  pub fn find_drives() -> Vec<Drive> deleted (opened N drives just to
  throw most away). find_drive() now uses discover_drives() directly.
  wait_ready() simplified — drops the reset path on sense_key=5,
  just keeps polling TUR for 60 iterations.

- lib.rs: find_drives re-export removed.

- benches/sgio_read.rs: switched to find_drive() (no longer iterates a
  drive list).

Net: 9 files changed, 226 insertions(+), 473 deletions(-). 329 tests
pass, clippy -D warnings clean. No consumer breakage (CLI, autorip,
bdemu compile + test green).

Architecture decision documented in
(internal)/docs/audits/2026-04-26-scsi-architecture-research.md
(primary-source survey of MakeMKV, sg_dd, ddrescue, and the kernel
mid-layer's own scsi_eh.rst escalation ladder).
2026-04-26 09:51:46 -07:00
MattJackson b4de5d343d v0.13.18 — sync release (no functional changes; autorip two-bar UI fix) 2026-04-26 07:47:37 -07:00
MattJackson 8beeac7df9 v0.13.17 — sync release (no functional changes; actual fix is autorip hot-plug) 2026-04-26 07:27:07 -07:00
MattJackson b33f41e219 v0.13.16 — single Progress trait + PassProgress (RIP_DESIGN.md §16)
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 Fn(u64, u64, u64). Consumers reinvented the math
each time, and v0.13.15's UI bug surfaced exactly because of this —
autorip's web JS computed 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 callbacks with a single Progress trait +
PassProgress struct (new progress module).

  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 { ... }   // closures work directly

CopyOptions::on_progress and PatchOptions::on_progress are renamed to
progress: Option<&dyn Progress>. Closure callers update trivially via
the blanket impl; 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.
Disc::patch reports Trim {reverse} for retry passes with block_sectors
>= 2 and Scrape {reverse} when block_sectors == 1. Direction comes
through reverse: bool. Mux variant is reserved for v0.13.17 when the
mux pipeline emits progress.

Tests + clippy clean across all 4 crates.
2026-04-26 07:15:26 -07:00
MattJackson e85e20f436 v0.13.15 — pos in on_progress, PatchOptions::reverse, wedged_threshold
Breaking: CopyOptions::on_progress + PatchOptions::on_progress now take
Fn(bytes_good, pos, total). Consumers display `pos` for "% swept" — the
true Pass 1 progress that advances through skip-forward bad zones, where
bytes_good (Finished sectors only) freezes. v0.13.14 live trace proved
the existing UI was lying for ~14 minutes about Dune 2 being "stuck at
30%" while Pass 1 was actually 83% through the disc via skip-forward.

PatchOptions::reverse: walk bad ranges from highest LBA to lowest. For
drives that wedge after a forward read of a bad sector, approaching the
post-bad-zone NonTrimmed range from end-of-disc reads good sectors before
the drive sees a bad one. Hypothesis informed by the BU40N + Initio
bridge live data — Pass 2 forward saw zero successful reads in 7 min
while Pass 1's pos walked all the way to end-of-disc.

PatchOptions::wedged_threshold: > 0 → exit early after that many
consecutive failures with zero successes in the same pass. Saves the
wallclock budget for productive grinding when the drive has wedged on
the bad zone for THIS pass; a different direction or block size in the
next pass may still recover. New PatchResult::wedged_exit reports it.

Trace: 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 the
freemkv::disc target.
2026-04-25 20:06:03 -07:00
MattJackson 7bc54be8d3 v0.13.14 — sync release, no functional changes (autorip subscriber filter fix in autorip 0.13.14) 2026-04-25 18:38:17 -07:00
MattJackson 6d9083743b v0.13.13 — telemetry: tracing instrumentation in SgIoTransport + Disc::copy
v0.13.12 shipped the async fd_recovery design but a live test on Dune 2
showed Pass 1 sat for 14 minutes with bytes_good=0 — the inner loop iterates
(throttled on_progress log fires every 78s) but each iteration evidently
takes ~60s instead of the microseconds the design promises on fast-fail.
Without trace-level telemetry at the SCSI + Disc::copy boundaries we
can't tell where the time goes.

This release is instrumentation only — no behavior change.

- New dep: tracing 0.1. Per project docs, debug/trace logging is allowed in
  libfreemkv (the no-English rule applies to errors). Consumers wire a
  tracing subscriber.
- SgIoTransport::execute (Linux): trace 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
  opcode + elapsed timing. The bg recovery thread also traces close_ms +
  open_ms so we can see if the kernel really takes 60s to close+open on a
  wedged Initio bridge.
- Disc::copy: trace 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),
  copy_done.
- All trace events use targets `freemkv::scsi` and `freemkv::disc` so
  consumers can filter by subsystem (e.g. autorip /api/debug?q=freemkv::scsi).

Next: run the live test on Dune 2 again, read the autorip JSONL log,
diagnose why each iter is slow, fix the actual bug.
2026-04-25 18:32:05 -07:00
MattJackson c49a68054f style: cargo fmt on integration test 2026-04-25 17:36:06 -07:00
MattJackson 4bd38787c6 test(integration): make halt-on-skip-forward test deterministic
The wallclock-based halt timing failed on fast CI runners where a 2 GB
synthetic-disc skip-forward sweep finishes in <100 ms — well under the
200 ms halt fire delay. Reader now signals halt on first read; the
inner-loop halt check on iteration 2 breaks 'outer. No wallclock race.
2026-04-25 17:33:52 -07:00
MattJackson b4951b1c5b v0.13.12 — Fix 1+2+4 + cross-platform SCSI parity (RIP_DESIGN.md §6, §7, §15.1)
Fix 1: delete stall guard from Disc::copy. Pass 1 must sweep end-to-end
per ddrescue model (RIP_DESIGN.md §2.1, §3, §9). The v0.13.9 guard at
disc/mod.rs broke Pass 1 at 30% on Dune 2 with 56 GB still NonTried.
Removed stall_secs field, narrative comment in scsi/linux.rs, and the
broken regression test. Replaced with test_disc_copy_completes_full_disc_
with_failing_reader and test_disc_copy_halts_promptly_on_failing_reader.

Fix 2: async SCSI transport recovery. Added Arc<AtomicI32> fd_recovery
on SgIoTransport. On poll timeout: spawn close + spawn open in
background, return Err immediately. Top of execute() swaps fd from
recovery atomic. Main thread never blocked beyond ~1.5s poll budget
(was up to ~60s per timeout because kernel serialized main-thread
open() against in-flight close()). Drop drains pending recovery fd.

§15.1 cross-platform parity: Windows + macOS now have the same
observable recovery contract. SptiTransport gets try_recover()
(synchronous CloseHandle + CreateFileW; Windows close is fast, no
in-flight CDB drain like Linux). MacScsiTransport gets try_recover()
(release IOKit interface + reacquire via new acquire_device_iface()
helper); stores bsd_name for re-resolution. Drop guards null'd-out
interfaces. Stripped English error strings ("try as root" / "run as
administrator") on Linux + Windows. Fixed Windows TimeOutValue
ms→s ceiling so 1500ms gets 2s (was 1s; broke Drive::read fast path).

Fix 4: instrument Disc::patch arms. PatchResult exposes
blocks_attempted, blocks_read_ok, blocks_read_failed so the v0.13.11
mystery (Dune 2 Pass 2 recovered 0 bytes in 100 min) is diagnosable
from the live device log without re-instrumenting from outside.

Cleanup: honor PatchOptions::full_recovery (was read into _ and
ignored; now routed to read_sectors recovery arg). Updated
CopyOptions::batch_sectors doc to describe the actual production
path (sysfs detect_max_batch_sectors, typically 60 sectors / ~120 KB
on BU40N) rather than the test-only 32-sector internal default.

All four crates clippy-clean and tests green on the host targets
(macOS native + cargo check on Linux). Cross-platform CI watches
Linux + Windows + macOS builds + tests.
2026-04-25 17:30:25 -07:00
MattJackson 92dc145864 v0.13.11: revert SgIoTransport timeout path — keep transport alive
v0.13.10's 'fd=-1 on first poll timeout' was too aggressive: a single
transient killed the entire transport, Pass 1 finished in 45ms with
0 GB good on Dune 2.

Revert to spawn-close + main-thread-reopen (the v0.13.5/8 pattern).
Per-timeout cost is up to ~60s while the kernel completes the
abandoned command, but the v0.13.9 Disc::copy stall guard caps
catastrophic stalls at 120s of bytes_good non-advance. Pass 1 bails
cleanly with NonTrimmed ranges; Pass 2 has a working Drive for
retries with recovery=true + 30s timeouts.
2026-04-25 08:47:10 -07:00
MattJackson 36b65f526a v0.13.10: version sync 2026-04-25 08:25:08 -07:00
MattJackson ca8ebf418f v0.13.9: Disc::copy stall guard + SgIoTransport no-reopen-on-timeout
Fixes the silent Pass 1 hang observed on Dune 2 with v0.13.8 (drive
grinding through bad sectors at 0 KB/s, errs=0, no error surfaced).

Root cause: SgIoTransport::execute's reopen-after-poll-timeout opened
a fresh /dev/sg* fd on the main thread, which serialized against the
spawned close() of the old fd via the kernel's per-device state lock.
The userspace 1.5s timeout still fired, but the abandon-and-reopen
recovery itself blocked the main thread for as long as close() took.
Net: reads returned slowly, skip-forward fired on every iteration,
bytes_good never advanced.

- SgIoTransport::execute: on poll timeout, spawn close, set fd=-1,
  return Err. No reopen on the main thread. The transport is now
  invalidated until the consumer creates a fresh Drive.
- Disc::copy: add stall guard. CopyOptions.stall_secs (default 120s).
  If bytes_good doesn't advance for the threshold, break 'outer
  cleanly with complete=false, bytes_pending > 0 so Pass 2 retries
  pick up the NonTrimmed ranges with recovery=true 30s timeouts.
- New regression test: test_disc_copy_stall_detection_triggers_
  skip_forward in tests/integration_progress_and_halt.rs.
2026-04-25 08:12:01 -07:00
MattJackson 34182d956a v0.13.8: version sync 2026-04-25 07:11:47 -07:00
MattJackson 0f967a2084 v0.13.7: version sync 2026-04-25 06:58:43 -07:00
MattJackson 0341995c0d fix: allow dead_code on Drive::emit (DiscStream owns BytesRead emission post-0.13.6 strip) 2026-04-24 21:43:30 -07:00
MattJackson fe0ec0c5cf fix: drop unused INQUIRY_* constants (clippy -D warnings) 2026-04-24 21:42:30 -07:00
MattJackson 022657d481 docs: complete 0.13.6 docs sweep (architecture, api-design, disc-to-rip, README)
Follow-up to the rip-recovery + drive-access updates: aligns the
remaining docs with the v0.13.6 single-shot read model and the
three-layer recovery architecture.

- architecture.md: module map says single-shot read; new paragraph on
  layered recovery with postmortem pointer.
- api-design.md: EventKind enum example expanded; emission notes
  document that BytesRead now fires from DiscStream::fill_extents and
  Retry/SectorRecovered are no longer emitted in 0.13.6+.
- disc-to-rip.md: Step 10 of the pipeline diagram + module table
  reflect single-shot read.
- docs/README.md: added rip-recovery.md to the TOC.
2026-04-24 21:36:49 -07:00
MattJackson fc8eca44e1 docs: rewrite rip-recovery + drive-access for 0.13.6 single-shot model
Updates docs/ to reflect the recovery-loop strip:
- rip-recovery.md: drops Phase 1/2/3 description, replaces with three-layer
  model (Disc::patch multi-pass / DiscStream batch halving / Drive::read
  single-shot). Notes that no SCSI resets fire from any retry path.
- drive-access.md: removes SG_SCSI_RESET + STOP/START UNIT escalation
  references; SgIoTransport::reset is now kernel SG_IO flush + ALLOW
  MEDIUM REMOVAL only.
- src/mux/disc.rs + tests/: cargo fmt cleanup.
2026-04-24 21:35:33 -07:00
MattJackson 43836865be v0.13.6: strip Drive::read inline recovery + reset escalation; emit BytesRead
Drive::read is now single-shot. Phase 1/2/3 retries + scsi::reset+reopen
removed (~80 lines). recovery=true bumps timeout to 30s; recovery=false
stays at 1.5s. On any failure returns Err(DiscRead) immediately — caller
(Disc::patch outer loop, DiscStream batch halver) handles retries.

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

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

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

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

Tests: new tests/integration_progress_and_halt.rs (5 tests). 233 unit
tests + 5 integration green.
2026-04-24 21:32:45 -07:00
MattJackson 5e22199441 v0.13.5: version sync (autorip-side fixes) 2026-04-24 20:13:07 -07:00
MattJackson 761b77bbd9 v0.13.4: roll back wedge recovery + add sysfs identity fallback
USB/SCSI recovery escalation in drive_has_disc (0.13.1-0.13.3) tested
on LG BU40N USB BD-RE: USBDEVFS_RESET, authorized toggle, driver
unbind/rebind, SCSI host rescan — all succeed at the USB transport
layer but the drive firmware below the bridge stays locked. Only
physical unplug-replug clears it. Rolled back so consumers can
surface the real failure to the user.

New: list_drives falls back to sysfs-cached vendor/model/rev from
/sys/class/scsi_generic/sgN/device/ when live INQUIRY returns empty,
so wedged drives still show their identity in UIs.

Removed: scsi::usb_reset, usb_reset_with_timeout, per-platform
usb_reset methods, recover_then_probe, is_wedge_signature. Breadcrumb
comment in scsi/linux.rs::drive_has_disc points at v0.13.3 tag for
the full implementation if future hardware needs it back.

Linux/macOS/Windows pass-through symmetric; 233 tests passing.
2026-04-24 19:53:44 -07:00
MattJackson cc05ef2a3a v0.13.3: broaden is_wedge_signature — fix dead-code wedge recovery
0.13.2's is_wedge_signature gated on opcode=SCSI_INQUIRY (0x12), but
drive_has_disc issues TEST UNIT READY (0x00). Production wedge errors
(E4000: 0x00/0xff/0x00) never matched → SCSI reset + USB reset
escalation never fired.

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

Linux-only; macOS/Windows use sense-key-based wedge detection.
2026-04-24 19:26:11 -07:00
27 changed files with 3588 additions and 1223 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
- run: cargo test
- run: cargo test --tests
check-macos:
runs-on: macos-latest
+756
View File
@@ -1,5 +1,761 @@
# Changelog
## 0.13.26 (2026-04-27)
### Extend DiscRead with SCSI status/sense for 30% wedge diagnostics
`Error::DiscRead` now carries `status` (SCSI status byte) and `sense`
(ScsiSense with key/asc/ascq). Previously this info was discarded,
showing only `E6000: {sector}`. Now shows:
- `E6000: {sector} 0x{status}/0x{sense_key}/0x{asc}`
- Enables recovery loop to distinguish recoverable errors from drive wedge
- Enables programmatic handling: `if status == 0xFF { reset } else { retry }`
### Error display shows up to 5 fields
Display format changed from `E{sector}` to `E{code}: sector status/key/asc`.
## 0.13.25 (2026-04-27)
### Drop dead `Drive::device_path_owned()`
The method was marked `// NOTE: Debug aid — remove after fd issue is
resolved` and the fd issue closed in 0.13.6. Use `device_path()` (which
returns `&str`) instead. Removing it clears a `cargo clippy -- -D
warnings` red on Linux CI that the Mac toolchain doesn't catch.
### Pre-commit gate uses CI's exact toolchain
`(internal)/scripts/precommit.sh` (new) runs `cargo +1.86 fmt
--check`, `cargo +1.86 clippy -- -D warnings`, and `cargo +1.86 test
--tests` across all 5 freemkv crates. Mirrors each repo's
`.github/workflows/ci.yml` step-for-step. Use it as a pre-commit hook
or run by hand before pushing — green here means green CI.
The Mac default toolchain is newer (1.94) and its clippy rejects
slightly different sets of lints than 1.86 — running locally without
pinning misses lints CI catches. The script forces 1.86 so drift
between local and CI ends.
## 0.13.24 (2026-04-27)
### MapStats: split `bytes_pending` into `bytes_nontried` + `bytes_retryable`
`MapStats.bytes_pending` aggregates `NonTried` (sectors Pass 1 hasn't
reached) + `NonTrimmed` + `NonScraped` (sectors flagged for Pass 2-N
retry). UIs that wanted a "MAYBE / will retry" bucket were stuck
showing the entire unread disc as "Maybe" at pct=0.
v0.13.24 keeps `bytes_pending` for back-compat and adds two granular
fields:
- `bytes_nontried` — Pass 1 hasn't read these yet
- `bytes_retryable` — `NonTrimmed + NonScraped`, Pass 2-N will retry
`bytes_pending == bytes_nontried + bytes_retryable` (invariant).
### cargo fmt cleanup
Picks up the `cargo fmt --check` lint failure that's been red on
`main` since v0.13.18 (long format-string layouts the local rustfmt
folded differently from CI's runner).
## 0.13.23 (2026-04-27)
### Stop discarding the drive's SCSI sense data
Through the entire 0.13.x line, every CHECK CONDITION reply from the
drive (the standard way SCSI reports a sector failure) was being
collapsed into a synthetic `status=0xFF, sense_key=0` "transport
wedge" sentinel and the real sense data was thrown away. Live tracing
on the BU40N reading Dune 2 on 2026-04-27 confirmed it: the drive was
returning `host_status=0, driver_status=8, status=2, exec_elapsed_ms=1416`
on every bad sector — a clean CHECK CONDITION carrying full sense
data — and the library was misclassifying it as a wedge and bailing.
Root cause: `scsi/linux.rs`'s wedge check was `host_status != 0 ||
driver_status != 0`. SG's `DRIVER_SENSE` bit (0x08) is set on every
CHECK CONDITION reply just to flag "sense buffer is populated" — it's
not a transport failure on its own. Pre-0.13.23 we conflated the two
and silently lost every drive-reported error reason. macOS and Windows
backends had the same shape: they extracted `sense_key` only, dropping
ASC/ASCQ.
### What 0.13.23 changes (API)
- **Linux**: mask `DRIVER_SENSE` before treating `driver_status` as a
transport-layer failure. Real transport failures (`host_status != 0`
or any non-SENSE bit set) still synthesise the `0xFF` sentinel.
- **`Error::ScsiError`** carries `sense: Option<ScsiSense>` instead of
flat `sense_key`/`asc`/`ascq`. `sense=None` ⇔ transport failure (no
SCSI status delivered). `Some(ScsiSense {…})` ⇔ drive replied with
sense data. Removes the `0xFF`/`sense_key=0` magic-number coupling.
- **`ScsiSense`** is a public type with predicate methods on it —
`is_marginal`, `is_medium_error`, `is_hardware_error`,
`is_unit_attention`, `is_data_protect`, `is_not_ready`,
`is_illegal_request`, `is_aborted_command`. Callers route on the
structured fields rather than raw key comparisons.
- **`Error::scsi_sense()`** / **`Error::is_scsi_transport_failure()`** /
**`Error::is_marginal_read()`** convenience predicates on `Error`.
`is_marginal_read` is the high-level "should `Disc::copy` engage
hysteresis on this error?" check.
- **SCSI protocol constants** (`SCSI_STATUS_GOOD`,
`SCSI_STATUS_CHECK_CONDITION`, `SCSI_STATUS_TRANSPORT_FAILURE`,
`SENSE_KEY_*`) moved from `error.rs` to `scsi/mod.rs` where they
belong alongside `SCSI_INQUIRY`, `SCSI_READ_10`, etc.
- **macOS** + **Windows** backends parse the full sense triple too.
Same code path on every platform — a regression in `parse_sense`
would surface on all three OSes simultaneously.
- **`parse_sense`** replaces `parse_sense_key` (returns the full
triple). Inline sense-format tests (descriptor 0x72/0x73 vs fixed
0x70/0x71, short-buffer, VALID-bit masking, unknown response codes)
now also exercise ASC/ASCQ extraction at the right offsets.
### Disc::copy + Disc::patch sense-aware dispatch
Both passes now bail immediately when a read fails with a sense class
that retry can't help (HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION,
NOT READY, ILLEGAL REQUEST, real transport failure, kernel `IoError`)
rather than burning hysteresis cycles on a doomed loop. Marginal-read
sense (MEDIUM ERROR, ABORTED COMMAND, RECOVERED ERROR, NO SENSE)
engages hysteresis as before. New `phase=bail` trace event records
the bail reason.
`Disc::patch`'s `wedged_threshold` (50 consecutive failures) remains
as defense-in-depth for chains of marginal failures, but a single
non-marginal sense now short-circuits it.
### Behavioural impact
For damaged-disc rips on the BU40N this unblocks v0.13.22's
hysteresis: pre-fix, the misclassified "wedge" caused `Disc::copy` to
exit before hysteresis could engage, so `bytes_good` froze at the bad
zone. Post-fix the drive's CHECK CONDITION replies flow through the
normal path → hysteresis drops to bpt=1 → marginal sectors are
recovered or marked Unreadable. Calibration data
(`docs/audits/2026-04-26-bisect-on-fail-empirical-findings.md`) shows
~86 % of marginal-region sectors recover at bpt=1 on this drive.
## 0.13.22 (2026-04-26)
### Replace bisect-on-fail with hysteresis state machine (Block ↔ Single)
Live test on Dune 2 v0.13.21 showed bisect-on-fail recovered every
recoverable sector, but spent ~30 sec per damaged 60-block (paying a
~5 sec kernel timeout at every bisection level). Each level descended
log₂(60) ≈ 6 times on the failing branch.
Replaced with a two-state hysteresis machine in `Disc::copy`:
```
Block(batch):
read(batch) ok → write, advance, stay Block
read(batch) fail → switch to Single, retry SAME range at bpt=1
Single:
read(1) ok → write, consecutive_good++
if consecutive_good >= BPT1_EXIT_THRESHOLD:
switch to Block, reset counter
read(1) fail → mark NonTrimmed, consecutive_good = 0
```
`BPT1_EXIT_THRESHOLD = 10_000` sectors (= 20 MB of clean data).
Calibrated from the 2026-04-26 BU40N empirical run; tunable.
Per-block cost on a 60-sector damaged block with 1 bad sector:
- Bisect (v0.13.21): ~30 sec (5 s × 6 levels)
- Hysteresis (v0.13.22): ~10 sec (5 s bpt=batch fail + 59 × 1 ms good
+ 1 × 5 s bad)
Plus inside a damaged cluster spanning many 60-blocks, hysteresis
pays the bpt=batch fail cost ONCE on entry; bisection paid it every
60 sectors. For Dune 2's ~1248-sector boundary cluster that's ~21
fewer 5-sec waits = ~100 sec saved.
Telemetry: new `phase=mode_change` trace event with `from`, `to`,
`lba`, `consecutive_good`. Replaces v0.13.21's `phase=bisect`. The
v0.13.21 worklist DFS is gone — single iterative `for s in 0..count`
on the failure path.
Test rename:
`test_disc_copy_bisect_recovers_via_single_sector_reads` →
`test_disc_copy_hysteresis_recovers_via_single_sector_reads`. Same
synthetic BU40N-pattern reader; same 100% recovery expectation.
## 0.13.21 (2026-04-26)
### Fix: Disc::copy bisect-on-fail (replaces skip-forward)
Empirical live-hardware testing on the LG BU40N (see
`(internal)/docs/audits/2026-04-26-test-plan-audit.md` and the
TEST_PLAN.md run log) revealed that the drive often **fails
multi-sector READ commands** in damaged regions but **succeeds when
asked one sector at a time**. The old skip-forward strategy responded
to multi-sector failures by jumping up to 1 % of the disc forward,
marking everything in between as bad — losing **clean territory**
sandwiched between bad sectors.
`Disc::copy` now bisects on read failure: split the failed block in
half, retry each half, recurse down to single-sector reads. Sectors
the drive can read individually are recovered in Pass 1; only sectors
that fail at bpt=1 are marked NonTrimmed for the patch passes.
Empirical results on Dune 2 UHD on the BU40N:
- Old algorithm: 25 GB read in Pass 1, then ~6 GB skip-forwarded;
retry passes failed to recover most of the skipped zone.
- New algorithm: ~99 % of disc recovered in Pass 1; only the truly
unreadable cluster (~14 % of a 2 MB hot zone) marked NonTrimmed.
Implementation: stack-based DFS in the inner read loop. log₂(batch)
levels max — for the default 60-sector batch, 6 levels. Multi-pass
machinery is untouched: Pass 2 .. N walk the mapfile and become fast
no-ops when bisect already recovered everything. New integration test
`test_disc_copy_bisect_recovers_via_single_sector_reads` validates
the behavior against a synthetic BU40N-pattern reader.
### Fix: READ_TIMEOUT_MS bumped 1.5 s → 10 s (caller-side)
The 0.13.20 SCSI rewrite gave the kernel mid-layer the ability to run
its own ABORT/RESET escalation. But callers (`Drive::read` for the
fast path) still passed `timeout_ms=1500`. Cold-start seek on the
BU40N can take ~1.5 s, which means **normal reads were being
cancelled at the boundary**, triggering the kernel mid-layer's
escalation, which the Initio bridge couldn't drain — resulting in the
firmware-level wedge that only physical replug recovers.
Live-hardware probe data:
- Sustained sequential read: 3–7 ms
- Cold-start seek + read: up to ~1500 ms
- Successful ECC recovery: 1.6–2.6 s
- Confirmed unreadable: 3.6–8.8 s (kernel timeout)
10 s is calibrated to cover every legitimate read with margin while
still short-circuiting truly bad sectors before the kernel runs full
LUN/BUS/HOST reset. `READ_RECOVERY_TIMEOUT_MS` (60 s) unchanged.
## 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
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.13.2"
version = "0.13.26"
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
+20
View File
@@ -0,0 +1,20 @@
# libfreemkv — local dev helper.
# Mirrors the cross-crate scripts in (internal)/scripts/test-all.sh
# but scoped to this single crate.
.PHONY: test build check ci clean
test:
cargo test --tests
build:
cargo build --release
check:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
ci: check build test
clean:
cargo clean
+5 -5
View File
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.11"
libfreemkv = "0.13"
```
## Quick Start
@@ -84,7 +84,7 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
- **Content reading** — adaptive batch reads with automatic decryption
- **Stream I/O** — unified stream pipeline for reading and writing any format
### Streams
@@ -92,21 +92,21 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
| Stream | Input | Output | Transport |
|--------|-------|--------|-----------|
| DiscStream | Yes | -- | Optical drive via SCSI |
| IsoStream | Yes | Yes | Blu-ray ISO image file |
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::copy()`) |
| MkvStream | Yes | Yes | Matroska container |
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream instances. All URLs use the `scheme://path` format — bare paths are rejected.
Streams implement `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
## Architecture
```text
Drive — open, identify, init, unlock, read (with recovery)
Drive — open, identify, init, unlock, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
├── DriveProfile — per-drive unlock parameters (bundled)
└── PlatformDriver — MediaTek (supported), Renesas (planned)
+3 -4
View File
@@ -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| {
+1
View File
@@ -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
View File
@@ -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
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`).
+23 -2
View File
@@ -69,12 +69,28 @@ pub struct MapEntry {
}
/// Summary statistics over all entries.
///
/// `bytes_pending` aggregates `NonTried + NonTrimmed + NonScraped` for
/// back-compat. `bytes_nontried` and `bytes_retryable` (= NonTrimmed +
/// NonScraped) split that aggregate so UIs can distinguish *unread*
/// territory (still ahead of Pass 1's read head) from *needs-retry*
/// territory (Pass 1 already encountered, queued for Pass 2-N).
#[derive(Debug, Clone, Copy, Default)]
pub struct MapStats {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
/// Sectors Pass 1 hasn't reached yet (`NonTried`). Subset of
/// `bytes_pending`.
pub bytes_nontried: u64,
/// Sectors flagged for Pass 2-N retry — `NonTrimmed` (multi-sector
/// read failed; needs split) + `NonScraped` (small-block read
/// partially recovered; remainder still pending). Subset of
/// `bytes_pending`. This is the right signal for a "MAYBE / will
/// retry" UI bucket; `bytes_pending` over-counts because it folds
/// in `bytes_nontried`.
pub bytes_retryable: u64,
}
/// Write-through mapfile. Every `record()` persists to disk immediately
@@ -275,8 +291,13 @@ impl Mapfile {
match e.status {
SectorStatus::Finished => s.bytes_good += e.size,
SectorStatus::Unreadable => s.bytes_unreadable += e.size,
SectorStatus::NonTried | SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
s.bytes_pending += e.size
SectorStatus::NonTried => {
s.bytes_pending += e.size;
s.bytes_nontried += e.size;
}
SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
s.bytes_pending += e.size;
s.bytes_retryable += e.size;
}
}
}
+519 -62
View File
@@ -13,7 +13,7 @@ mod dvd;
mod encrypt;
pub mod mapfile;
use crate::drive::Drive;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
use crate::sector::SectorReader;
use crate::udf;
@@ -1278,14 +1278,36 @@ impl Disc {
None => DEFAULT_BATCH_SECTORS,
};
// Skip-forward state.
let skip_init = 256 * 1024u64; // 256 KB
let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc
let mut skip_size = skip_init;
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;
// Hysteresis state (0.13.22): Block(batch) <-> Single(bpt=1).
// mode_single=false means we're in Block mode (default). On a
// multi-sector read failure we flip to Single, walk the failed
// range sector-by-sector, and stay in Single until we hit
// BPT1_EXIT_THRESHOLD consecutive good reads — then back to Block.
let mut mode_single = false;
let mut consecutive_good: u64 = 0;
// Wallclock cadence for the progress callback. Default outer-loop
// tick is per-block, which is once every few ms in clean territory
// but can be tens of seconds (or minutes) in dense single-mode
// recovery. Fire the callback on a wallclock interval too so the
// UI shows movement even mid-cluster.
let mut last_progress_t = std::time::Instant::now();
const PROGRESS_TICK: std::time::Duration = std::time::Duration::from_secs(2);
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
total_bytes,
batch,
bpt1_exit_threshold = BPT1_EXIT_THRESHOLD,
"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 +1317,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 +1335,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 {
@@ -1316,63 +1352,256 @@ impl Disc {
}
}
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
let lba = (pos / 2048) as u32;
let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048;
let block_lba = (pos / 2048) as u32;
let block_count = (block_bytes / 2048) as u16;
let recovery = !opts.skip_on_error;
let recovery = !opts.skip_on_error; // fast reads when skipping
let read_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], recovery)
.is_ok();
// Hysteresis state machine (0.13.22, replaces v0.13.21
// bisect-on-fail): two states.
//
// Block(batch): try the full block. On success, write,
// advance, stay in Block. On failure, switch to Single
// and retry the same range one sector at a time.
//
// Single: read at bpt=1. Each success increments
// consecutive_good; once that reaches BPT1_EXIT_THRESHOLD
// we switch back to Block. Each failure marks the sector
// NonTrimmed and resets consecutive_good = 0.
//
// Why not bisection: the v0.13.21 bisect-on-fail descended
// log2(batch) levels on every multi-sector failure, paying
// a ~5 s kernel timeout at every level. For a 60-block with
// 1 bad sector the cost was ~30 s. Direct drop to bpt=1
// pays ~10 s for the same outcome. And once we're inside a
// bad cluster we stay there at bpt=1 instead of repeatedly
// re-trying bpt=batch (each fail = another ~5 s wasted).
//
// Empirical justification:
// (internal)/docs/audits/2026-04-26-bisect-on-fail-empirical-findings.md
let block_t0 = std::time::Instant::now();
let read_t0 = block_t0;
let block_bytes_usz = block_bytes as usize;
iter_count += 1;
let block_result = reader.read_sectors(
block_lba,
block_count,
&mut buf[..block_bytes_usz],
recovery,
);
if read_ok {
if block_result.is_ok() {
// Fast path — full block read cleanly.
read_ok_count += 1;
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
crate::decrypt::decrypt_sectors(&mut buf[..block_bytes_usz], &keys, 0)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
file.write_all(&buf[..block_bytes_usz])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
skip_size = skip_init; // reset after success
pos += block_bytes;
} else if opts.skip_on_error {
// Zero-fill this block, mark non-trimmed for later patch trim.
buf[..bytes].fill(0);
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
pos += block_bytes;
if opts.skip_forward && pos < region_end {
// Skip ahead; mark skipped bytes as non-trimmed too.
let jump = skip_size.min(region_end - pos);
if jump > 0 {
map.record(pos, jump, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
pos += jump;
}
skip_size = (skip_size * 2).min(skip_max);
}
} else if !opts.skip_on_error {
// Strict mode (skip_on_error=false): abort on first bad.
let (status, sense) = block_result
.err()
.as_ref()
.map(extract_scsi_context)
.unwrap_or((0, None));
return Err(Error::DiscRead {
sector: block_lba as u64,
status: Some(status),
sense,
});
} else if !block_result
.as_ref()
.err()
.map(Error::is_marginal_read)
.unwrap_or(false)
{
// 0.13.23: SCSI sense-aware dispatch. Block read failed
// with a sense class outside the marginal-read set
// (real transport failure, HARDWARE ERROR, DATA
// PROTECT, UNIT ATTENTION, NOT READY, ILLEGAL
// REQUEST, kernel IoError). Bail with the full sense
// triple preserved — caller (autorip) surfaces
// "physical replug needed" / "drive failing" /
// "media changed" / etc to the user. Hysteresis
// would just hammer the same failure for thousands
// of sectors at 1.4 sec each.
let err = block_result.err().unwrap();
tracing::trace!(
target: "freemkv::disc",
phase = "bail",
lba = block_lba,
error = %err,
"block read failed with non-recoverable sense; bailing"
);
return Err(err);
} else {
// Current behavior (pre-0.11.21): abort on first bad sector.
return Err(Error::DiscRead { sector: lba as u64 });
// Block failed → drop to Single and read this range
// sector-by-sector. Stay in Single across subsequent
// outer-loop blocks until we hit
// BPT1_EXIT_THRESHOLD consecutive good single-sector
// reads, then return to Block mode.
if !mode_single {
tracing::trace!(
target: "freemkv::disc",
phase = "mode_change",
from = "Block",
to = "Single",
lba = block_lba,
block_elapsed_ms = read_t0.elapsed().as_millis() as u64,
"block read failed; switching to bpt=1"
);
mode_single = true;
consecutive_good = 0;
}
// Walk the failed block one sector at a time.
for s in 0..block_count {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
break 'outer;
}
}
iter_count += 1;
let s_lba = block_lba + s as u32;
let s_pos = pos + (s as u64) * 2048;
let one_bytes = 2048usize;
let one_result =
reader.read_sectors(s_lba, 1, &mut buf[..one_bytes], recovery);
// 0.13.23: same sense-aware dispatch inside Single
// mode. If a single-sector read fails with a
// non-marginal sense (transport / hardware /
// DATA PROTECT / UNIT ATTENTION / NOT READY /
// ILLEGAL REQUEST / kernel IoError), the drive
// isn't going to start succeeding for the next
// 60 sectors either — bail with full sense info
// rather than chewing through bpt=1 timeouts.
if let Err(ref e) = one_result {
if !e.is_marginal_read() {
let err = one_result.err().unwrap();
tracing::trace!(
target: "freemkv::disc",
phase = "bail",
lba = s_lba,
error = %err,
"bpt=1 read failed with non-marginal sense; bailing"
);
return Err(err);
}
}
let one_ok = one_result.is_ok();
if one_ok {
read_ok_count += 1;
consecutive_good = consecutive_good.saturating_add(1);
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..one_bytes], &keys, 0)?;
}
file.seek(SeekFrom::Start(s_pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..one_bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(s_pos, 2048, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(2048);
if consecutive_good >= BPT1_EXIT_THRESHOLD {
tracing::trace!(
target: "freemkv::disc",
phase = "mode_change",
from = "Single",
to = "Block",
lba = s_lba + 1,
consecutive_good,
"exit threshold reached; returning to bpt=batch"
);
mode_single = false;
consecutive_good = 0;
}
} else {
read_err_count += 1;
consecutive_good = 0;
buf[..one_bytes].fill(0);
file.seek(SeekFrom::Start(s_pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..one_bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(s_pos, 2048, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
}
// Wallclock-cadence progress callback during
// single-mode grinding. Without this, the UI
// appears frozen for tens of seconds while we
// chew through bad sectors.
if last_progress_t.elapsed() >= PROGRESS_TICK {
last_progress_t = std::time::Instant::now();
if let Some(reporter) = opts.progress {
let stats = map.stats();
reporter.report(&crate::progress::PassProgress {
kind: crate::progress::PassKind::Sweep,
work_done: pos + (s as u64 + 1) * 2048,
work_total: total_bytes,
bytes_good_total: stats.bytes_good,
bytes_total_disc: total_bytes,
});
}
}
}
}
pos += block_bytes;
// Throttled iter telemetry — every 100 inner iterations.
if iter_count - last_log_iter >= 100 {
last_log_iter = iter_count;
let stats = map.stats();
tracing::trace!(
target: "freemkv::disc",
phase = "iter_progress",
iter_count,
read_ok_count,
read_err_count,
pos,
region_end,
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(cb) = opts.on_progress {
if let Some(reporter) = opts.progress {
let stats = map.stats();
cb(stats.bytes_good, total_bytes);
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,
});
last_progress_t = std::time::Instant::now();
}
}
}
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 +1621,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 +1633,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 +1671,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 +1696,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 +1739,106 @@ 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;
let read_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], true)
.is_ok();
blocks_attempted += 1;
let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery);
// 0.13.23: parity with Disc::copy — bail immediately on a
// non-marginal SCSI sense (transport failure, HARDWARE
// ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY,
// ILLEGAL REQUEST, kernel IoError). The wedged_threshold
// counter is a defense-in-depth backstop for ~50
// consecutive marginal failures, but a single
// non-marginal error already proves the drive can't
// produce data this pass — don't waste 50× the timeout
// budget proving it again.
if let Err(ref e) = read_result {
if !e.is_marginal_read() {
let err = read_result.err().unwrap();
tracing::trace!(
target: "freemkv::disc",
phase = "patch_bail",
lba,
error = %err,
"patch read failed with non-marginal sense; bailing"
);
return Err(err);
}
}
let read_ok = read_result.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 +1849,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 +1929,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,
})
}
}
@@ -1558,6 +1941,80 @@ const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS: u16 = 60;
const MIN_BATCH_SECTORS: u16 = 3;
/// Number of consecutive good single-sector reads required to exit
/// `Single` mode (bpt=1) and return to `Block` mode (bpt=batch). 10 000
/// sectors ≈ 20 MB of clean data — long enough that we don't bounce in
/// and out of bpt=1 inside a sparse-bad cluster, short enough that we
/// don't waste much time reading clean territory at bpt=1 after the
/// damaged region ends. Tunable; calibrated from the 2026-04-26 BU40N
/// live test.
const BPT1_EXIT_THRESHOLD: u64 = 10_000;
/// Coarse damage tier for a finished or in-progress rip. Maps the
/// observable signals (bad sector count + lost wallclock playback time)
/// onto a small discrete classification so UIs can render a colored badge
/// and operators can decide whether to rescan / replug / accept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DamageSeverity {
/// No bad sectors at all.
Clean,
/// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable.
Cosmetic,
/// 51–500 sectors OR 1–30 sec lost. Visible artifacts possible.
Moderate,
/// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan
/// or different drive.
Serious,
}
/// Classify damage severity from raw counters. `bad_sectors` is the
/// number of sectors marked unreadable (or NonTrimmed pending Pass 2);
/// `lost_ms` is the cumulative wallclock playback time those sectors
/// represent (computed from the title's bytes-per-sec).
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
if bad_sectors == 0 {
return DamageSeverity::Clean;
}
if bad_sectors >= 500 || lost_ms >= 30_000.0 {
return DamageSeverity::Serious;
}
if bad_sectors >= 51 || lost_ms >= 1_000.0 {
return DamageSeverity::Moderate;
}
DamageSeverity::Cosmetic
}
#[cfg(test)]
mod severity_tests {
use super::*;
#[test]
fn clean_when_no_damage() {
assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
}
#[test]
fn cosmetic_for_a_handful() {
assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
}
#[test]
fn moderate_threshold_by_sectors() {
assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
}
#[test]
fn moderate_threshold_by_time() {
assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
}
#[test]
fn serious_threshold_by_sectors() {
assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
}
#[test]
fn serious_threshold_by_time() {
assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
}
}
/// Detect the maximum transfer size in sectors for a device.
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
/// For sg devices, resolves the corresponding block device via sysfs.
+70 -232
View File
@@ -1,11 +1,14 @@
//! Drive session — open, identify, and read from optical drives.
//!
//! Three-step open:
//! 1. `open()` — open device, identify drive. Always OEM.
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
//! 3. `init()` — activate custom firmware. Removes riplock.
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
match e {
Error::ScsiError { status, sense, .. } => (*status, *sense),
_ => (0, None),
}
}
pub mod capture;
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
@@ -53,9 +56,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 +64,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 +93,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 +118,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 +131,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
@@ -170,11 +163,6 @@ impl Drive {
self.unlock_tray();
}
// NOTE: Debug aid — remove after fd issue is resolved
pub fn device_path_owned(&self) -> String {
self.device_path.clone()
}
/// Whether this drive has a known profile (unlock parameters available).
pub fn has_profile(&self) -> bool {
self.profile.is_some()
@@ -187,31 +175,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));
}
@@ -266,94 +239,18 @@ impl Drive {
5_000,
) {
Ok(_) => DriveStatus::DiscPresent,
Err(Error::ScsiError { sense_key: 2, .. }) => DriveStatus::NotReady,
Err(Error::ScsiError { sense_key: 6, .. }) => DriveStatus::NotReady, // UNIT ATTENTION
Err(ref e)
if e.scsi_sense()
.is_some_and(|s| s.is_not_ready() || s.is_unit_attention()) =>
{
DriveStatus::NotReady
}
_ => DriveStatus::Unknown,
}
}
}
}
/// 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,28 +416,32 @@ 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` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s,
/// matches sg_dd) for the `Disc::patch` pass; `recovery=false` uses
/// [`crate::scsi::READ_TIMEOUT_MS`] (30 s, matches the kernel's
/// `/sys/block/sr*/device/timeout` default) for `Disc::copy`'s fast
/// skip-forward sweep. Both budgets are generous enough that the drive
/// can finish ECC recovery on a marginal sector — pre-0.13.21 this was
/// 1.5 s on the fast path which forced the kernel mid-layer to time
/// out and escalate while we waited anyway. On any failure returns
/// `Err(DiscRead)` immediately; orchestration (`Disc::patch` multi-pass,
/// `DiscStream` adaptive batch halving) handles retry policy.
///
/// 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
let timeout_ms = if recovery {
crate::scsi::READ_RECOVERY_TIMEOUT_MS
} else {
10_000
crate::scsi::READ_TIMEOUT_MS
};
let cdb = [
crate::scsi::SCSI_READ_10,
@@ -555,82 +456,23 @@ 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 */ }
}
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);
Ok(result) => Ok(result.bytes_transferred),
Err(Error::Halted) => Err(Error::Halted),
Err(e) => {
let (status, sense) = extract_scsi_context(&e);
Err(Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
})
}
}
// 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 +584,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 +616,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")]
+102 -9
View File
@@ -155,10 +155,24 @@ pub enum Error {
},
// SCSI (4xxx)
/// SCSI command failed.
///
/// `opcode` is the failing CDB byte 0. `status` is the raw SCSI
/// status byte: `0x02` = CHECK CONDITION (drive replied with sense
/// data), `0xFF` = libfreemkv-synthesised sentinel meaning "no SCSI
/// status delivered" (kernel timeout, USB bridge wedge, IOKit
/// service failure). `sense` carries the drive's SPC-4 sense triple
/// when the drive replied; `None` for transport-layer failures.
///
/// Recommended dispatch (callers shouldn't pattern-match raw
/// fields):
/// - [`Error::is_scsi_transport_failure`] — bail; bridge/transport wedge
/// - [`Error::is_marginal_read`] — drive said this read was marginal; smaller block may recover
/// - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`] etc.)
ScsiError {
opcode: u8,
status: u8,
sense_key: u8,
sense: Option<crate::scsi::ScsiSense>,
},
// I/O (5xxx)
@@ -169,6 +183,8 @@ pub enum Error {
// Disc format (6xxx)
DiscRead {
sector: u64,
status: Option<u8>,
sense: Option<crate::scsi::ScsiSense>,
},
/// Drive was halted by caller.
Halted,
@@ -363,19 +379,46 @@ impl std::fmt::Display for Error {
Error::ScsiError {
opcode,
status,
sense_key,
} => {
write!(
sense,
} => match sense {
Some(s) => write!(
f,
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}",
self.code(),
opcode,
status,
sense_key
)
}
s.sense_key,
s.asc,
s.ascq,
),
None => write!(f, "E{}: 0x{:02x}/0x{:02x}", self.code(), opcode, status,),
},
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
Error::DiscRead {
sector,
status,
sense,
} => match (status, sense) {
(Some(st), Some(s)) => write!(
f,
"E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}",
self.code(),
sector,
st,
s.sense_key,
s.asc,
),
(Some(st), None) => write!(f, "E{}: {} 0x{:02x}", self.code(), sector, st,),
(None, Some(s)) => write!(
f,
"E{}: {} 0x{:02x}/0x{:02x}",
self.code(),
sector,
s.sense_key,
s.asc,
),
(None, None) => write!(f, "E{}: {}", self.code(), sector),
},
Error::Halted => write!(f, "E{}", self.code()),
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::DiscTitleRange { index, count } => {
@@ -438,6 +481,56 @@ impl From<Error> for std::io::Error {
/// Convenience alias for `Result<T, Error>`.
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
/// Borrow the drive-returned SPC-4 sense triple if this error is a
/// [`Error::ScsiError`] carrying sense data. `None` for any other
/// variant **and** for `ScsiError`s that represent a transport-layer
/// failure (where the device never delivered a SCSI status reply, so
/// no sense data exists).
pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> {
match self {
Error::ScsiError { sense: Some(s), .. } => Some(s),
_ => None,
}
}
/// True if this is a [`Error::ScsiError`] representing a transport-layer
/// failure — kernel timeout, USB bridge wedge, IOKit service error.
/// The device never delivered a SCSI status reply, so there is no
/// sense data to inspect; retrying typically requires physical
/// intervention (replug).
pub fn is_scsi_transport_failure(&self) -> bool {
matches!(
self,
Error::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
..
}
)
}
/// True if the underlying SCSI failure is a *marginal read* — the
/// drive returned an error category in which smaller-granularity
/// retries can sometimes recover the data:
///
/// - MEDIUM ERROR (sense key 3) — canonical bad-sector signal
/// - ABORTED COMMAND (sense key B) — transient; retry usually works
/// - RECOVERED ERROR (sense key 1) / NO SENSE (sense key 0) — not
/// classified as fatal; treat as recoverable
///
/// Returns `false` for transport failures (no sense data delivered),
/// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY, ILLEGAL
/// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant.
/// Caller-agnostic predicate — describes a property of the *error*,
/// not what one specific call site should do with it. Used by
/// `Disc::copy`'s hysteresis dispatch.
pub fn is_marginal_read(&self) -> bool {
self.scsi_sense()
.map(crate::scsi::ScsiSense::is_marginal)
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
//! Smoke tests for the error code → variant mapping. Each new variant
+6 -4
View File
@@ -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 ─────────────────────────────────────────────────────────────────
//
@@ -133,9 +134,10 @@ pub use decrypt::{DecryptKeys, decrypt_sectors};
// background. The codec / channel / resolution enums are the canonical
// structured representation; never compare against display strings.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
classify_damage,
};
// ─── Streams ────────────────────────────────────────────────────────────────
@@ -167,7 +169,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
// out-of-tree platform backends. `SectorReader` lets callers feed any byte
// source (test harness, network image, SMB share) into the disc scan
// pipeline; `FileSectorReader` is the standard ISO-on-disk implementation.
pub use scsi::{DriveInfo, ScsiTransport, drive_has_disc, list_drives};
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use sector::{FileSectorReader, SectorReader};
pub use speed::DriveSpeed;
pub use udf::{UdfFs, read_filesystem};
+29 -1
View File
@@ -6,6 +6,7 @@
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use crate::disc::{Disc, DiscTitle, Extent};
use crate::drive::extract_scsi_context;
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::sector::SectorReader;
use std::io;
@@ -127,6 +128,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 +158,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 +204,8 @@ impl DiscStream {
halt: None,
event_fn: None,
eof: false,
bytes_read_total: 0,
bytes_total_extents,
ts_demuxer,
ps_demuxer,
parsers,
@@ -287,6 +299,11 @@ impl DiscStream {
}
self.buf_valid = bytes;
self.current_offset += sectors as u32;
self.bytes_read_total = self.bytes_read_total.saturating_add(bytes as u64);
self.emit(EventKind::BytesRead {
bytes: self.bytes_read_total,
total: self.bytes_total_extents,
});
break;
}
@@ -301,7 +318,18 @@ impl DiscStream {
self.current_offset += 1;
break;
} else {
return Err(crate::error::Error::DiscRead { sector: lba as u64 }.into());
let err = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..2048], false)
.err();
let (status, sense) =
err.as_ref().map(extract_scsi_context).unwrap_or((0, None));
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
}
+6 -6
View File
@@ -96,8 +96,8 @@ impl Mt1959 {
if result.bytes_transferred != expected {
return Err(Error::ScsiError {
opcode: SCSI_READ_BUFFER,
status: 0xFF,
sense_key: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
Ok(result.bytes_transferred)
@@ -169,8 +169,8 @@ impl Mt1959 {
}
Err(Error::ScsiError {
opcode: SCSI_READ_BUFFER,
status: 0xFF,
sense_key: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
})
}
@@ -275,8 +275,8 @@ impl Mt1959 {
{
return Err(Error::ScsiError {
opcode: SCSI_READ_BUFFER,
status: 0xFF,
sense_key: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
addr = addr.wrapping_add(PROBE_STEP);
+70
View File
@@ -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)
}
}
+253 -404
View File
@@ -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,33 @@ 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 OR `(hdr.driver_status & ~DRIVER_SENSE)` != 0
/// → `Error::ScsiError { status: 0xFF, sense_key: 0, asc: 0, ascq: 0 }`
/// (real transport-layer failure: kernel timeout, bridge wedge, bus error)
/// - `hdr.status` != 0 (typically `0x02` CHECK CONDITION) →
/// `Error::ScsiError { status, sense_key, asc, ascq }` carrying the
/// drive's full SPC-4 sense triple. Callers route on
/// `is_medium_error()`, `is_unit_attention()`, etc.
///
/// Note: SG's `DRIVER_SENSE` (0x08) bit indicates *sense data is
/// attached* — it's set on every CHECK CONDITION reply. It is **not**
/// a transport failure; pre-0.13.23 we conflated it with one and
/// silently lost every drive-reported error reason. The mask in the
/// transport-error check below is the fix.
///
/// 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,30 +266,34 @@ 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 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 {
opcode: cdb[0],
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
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,
};
if data.len() > u32::MAX as usize {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
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,109 +306,92 @@ 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.
//
// 0.13.23: mask out `DRIVER_SENSE` (0x08) before treating
// `driver_status` as a transport failure. That bit is set on
// *every* CHECK CONDITION reply just to flag "sense data is
// attached in `sbp`" — it's not an error of its own. Pre-fix
// we collapsed every drive-reported error into a synthetic
// 0xFF wedge signature and discarded the sense data, which
// killed the rip's classification logic on damaged discs.
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
if hdr.host_status != 0 || driver_status_real != 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,
sense_key: 0,
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
// 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(),
// SCSI-level failure: device responded, returned non-zero status
// (typically 0x02 CHECK CONDITION). Parse the full SPC-4 sense
// triple so callers can route on `ScsiSense::is_medium_error()`
// etc.
if hdr.status != 0 {
let parsed = super::parse_sense(&sense, hdr.sb_len_wr);
tracing::trace!(
target: "freemkv::scsi",
phase = "scsi_err",
opcode = opcode,
status = hdr.status,
sense_key = parsed.sense_key,
asc = parsed.asc,
ascq = parsed.ascq,
exec_elapsed_ms,
"SCSI status non-zero"
);
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense: Some(parsed),
});
}
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
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
};
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense_key,
});
}
tracing::trace!(
target: "freemkv::scsi",
phase = "ok",
opcode = opcode,
bytes_transferred,
exec_elapsed_ms,
"execute() success"
);
Ok(ScsiResult {
status: hdr.status,
bytes_transferred,
@@ -544,35 +407,20 @@ 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.
const SCSI_TYPE_OPTICAL: &str = "5";
/// SCSI sense key 2 = "NOT READY". Sub-codes distinguish "medium not present"
/// (no disc) from other not-ready states (loading, etc.); for poll-loop
/// purposes any sense-key 2 means "no disc to act on".
const SENSE_KEY_NOT_READY: u8 = 2;
/// Maximum sg index probed in the fallback path when sysfs is unavailable.
/// Linux assigns `/dev/sgN` sequentially per host adapter; 16 covers any
/// 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 +429,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 +468,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 +524,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];
@@ -664,58 +564,7 @@ fn probe_tur(path: &Path) -> Result<bool> {
crate::scsi::TUR_TIMEOUT_MS,
) {
Ok(_) => Ok(true),
Err(Error::ScsiError {
sense_key: SENSE_KEY_NOT_READY,
..
}) => Ok(false),
Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false),
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;
+53 -217
View File
@@ -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,32 @@ 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),
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false),
Err(_) => recover_then_probe(path),
}
}
const K_SENSE_KEY_NOT_READY: u8 = 2;
fn probe_tur(path: &Path) -> Result<bool> {
let mut transport = MacScsiTransport::open(path)?;
let 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);
match transport.execute(
&cdb,
crate::scsi::DataDirection::None,
&mut buf,
crate::scsi::TUR_TIMEOUT_MS,
) {
Ok(_) => Ok(true),
Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false),
Err(e) => Err(e),
}
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;
@@ -549,8 +375,8 @@ impl ScsiTransport for MacScsiTransport {
if task.is_null() {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
@@ -617,19 +443,29 @@ 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,
sense_key: 0,
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
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 inspect byte 0's response code to pick
// descriptor-vs-fixed format.
//
// 0.13.23: carry the full SPC-4 sense triple in
// `Error::ScsiError::sense` so callers can route on
// `ScsiSense::is_medium_error()` etc.
let parsed = super::parse_sense(&sense, sense.len() as u8);
return Err(Error::ScsiError {
opcode: cdb[0],
status: task_status as u8,
sense_key,
sense: Some(parsed),
});
}
+371 -185
View File
@@ -43,6 +43,239 @@ pub const AACS_KEY_CLASS: u8 = 0x02;
/// a poll-loop tick.
pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
/// Timeout for content READ commands (READ_10 / READ_12) on the fast
/// path — the [`disc::Disc::copy`] sweep that bisects-on-failure.
///
/// 10 s is calibrated from live empirical data on an LG BU40N + Initio
/// 1618L bridge ripping a UHD with marginal sectors:
///
/// - Sustained sequential reads: 3 – 7 ms
/// - Cold-start seek + read: up to ~1500 ms
/// - Successful ECC recovery: 1.6 – 2.6 sec
/// - Confirmed unreadable sector: 3.6 – 8.8 sec (kernel timeout)
///
/// 10 s catches every legitimate slow read with comfortable margin and
/// short-circuits truly bad sectors at ~10 s rather than letting the
/// kernel mid-layer escalate for 30 s+. See run log in
/// `(internal)/docs/TEST_PLAN.md` and the audit at
/// `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`.
///
/// Pre-0.13.21 this was 1.5 s, which forced the kernel mid-layer to
/// time out *normal* reads (cold-start often takes ~1.5 s) and run its
/// full ABORT TASK / LUN RESET / BUS RESET escalation while userspace
/// kept submitting fresh reads. The Initio bridge couldn't drain the
/// resulting command queue and entered a wedge state that only physical
/// replug recovered — proven by the v0.13.18 + v0.13.20 live tests.
pub(crate) const READ_TIMEOUT_MS: u32 = 10_000;
/// Timeout for content READ commands on the recovery path —
/// [`disc::Disc::patch`]'s targeted retries on bad ranges. Doubles
/// the fast-path budget so a sector that fails at 30 s gets one more
/// honest attempt. Matches sg_dd's default per-command timeout
/// (`DEF_TIMEOUT = 60000`).
pub(crate) const READ_RECOVERY_TIMEOUT_MS: u32 = 60_000;
// ── SCSI status bytes (SPC-4 §4.5.5) ────────────────────────────────────────
/// Status byte 0x00 — `GOOD`. Command completed successfully.
pub const SCSI_STATUS_GOOD: u8 = 0x00;
/// Status byte 0x02 — `CHECK CONDITION`. Drive completed the command
/// reply and attached sense data describing the failure.
pub const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
/// libfreemkv-synthesised sentinel: the transport never delivered a
/// SCSI status byte (kernel timeout, USB bridge wedge, IOKit service
/// failure). Distinct from any drive-returned value. Carriers
/// [`Error::ScsiError`] with `sense = None`.
pub const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
// ── SPC-4 sense keys (§4.5.6 Table 28) ─────────────────────────────────────
//
// Broad failure category returned in a CHECK CONDITION reply's sense data.
// Names match the SCSI spec; predicate methods on [`ScsiSense`] (e.g.
// `is_medium_error`, `is_unit_attention`) read more fluently than raw
// constant comparisons at call sites.
pub const SENSE_KEY_NO_SENSE: u8 = 0x00;
pub const SENSE_KEY_RECOVERED_ERROR: u8 = 0x01;
pub const SENSE_KEY_NOT_READY: u8 = 0x02;
pub const SENSE_KEY_MEDIUM_ERROR: u8 = 0x03;
pub const SENSE_KEY_HARDWARE_ERROR: u8 = 0x04;
pub const SENSE_KEY_ILLEGAL_REQUEST: u8 = 0x05;
pub const SENSE_KEY_UNIT_ATTENTION: u8 = 0x06;
pub const SENSE_KEY_DATA_PROTECT: u8 = 0x07;
pub const SENSE_KEY_BLANK_CHECK: u8 = 0x08;
pub const SENSE_KEY_ABORTED_COMMAND: u8 = 0x0B;
// ── Sense parsing ───────────────────────────────────────────────────────────
/// Decoded SPC-4 sense triple — the precise reason a SCSI command failed.
///
/// Returned by [`parse_sense`] and embedded inside [`Error::ScsiError`]
/// (`sense: Option<ScsiSense>`). Predicate methods (`is_medium_error`,
/// `is_unit_attention`, `is_marginal`, …) read more fluently at call
/// sites than raw `sense_key` comparisons.
///
/// `Default::default()` and the [`ScsiSense::NONE`] constant both
/// produce the all-zero "no sense info" triple. Per SPC-4 §4.5.3, an
/// empty sense buffer is reported as NO SENSE (key 0); use the constant
/// for explicit intent at construction sites.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScsiSense {
/// Sense key — broad failure category (SPC-4 §4.5.6 Table 28).
/// See the `SENSE_KEY_*` constants for named values.
pub sense_key: u8,
/// Additional Sense Code — narrows the cause within a sense key
/// (SPC-4 §4.5.6 Table 29). E.g. `0x11` = UNRECOVERED READ ERROR.
pub asc: u8,
/// Additional Sense Code Qualifier — finest-grain disambiguation.
/// E.g. `0x05` (with `asc=0x11`) = L-EC UNCORRECTABLE.
pub ascq: u8,
}
impl ScsiSense {
/// Sense reply with all-zero fields — explicit "no sense info"
/// constructor for sites where `Default::default()` would be opaque.
pub const NONE: ScsiSense = ScsiSense {
sense_key: 0,
asc: 0,
ascq: 0,
};
/// `true` when the sense key indicates a *marginal-read* failure —
/// the kind of error where the same read at smaller granularity
/// (or a brief retry) sometimes succeeds:
///
/// - `MEDIUM ERROR` (3) — canonical bad-sector signal
/// - `ABORTED COMMAND` (B) — transient; retry usually works
/// - `RECOVERED ERROR` (1) / `NO SENSE` (0) — drive is healthy and
/// either recovered the data or has no specific fault to report
///
/// `false` for HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, NOT
/// READY, ILLEGAL REQUEST, BLANK CHECK, and any unknown key. Used
/// by [`Error::is_marginal_read`] / `Disc::copy`'s hysteresis
/// dispatch.
pub fn is_marginal(&self) -> bool {
matches!(
self.sense_key,
SENSE_KEY_NO_SENSE
| SENSE_KEY_RECOVERED_ERROR
| SENSE_KEY_MEDIUM_ERROR
| SENSE_KEY_ABORTED_COMMAND
)
}
/// `true` if `sense_key == MEDIUM ERROR (3)` — canonical "bad sector"
/// signal from the drive.
pub fn is_medium_error(&self) -> bool {
self.sense_key == SENSE_KEY_MEDIUM_ERROR
}
/// `true` if `sense_key == HARDWARE ERROR (4)` — drive itself is
/// failing. Not recoverable by retry.
pub fn is_hardware_error(&self) -> bool {
self.sense_key == SENSE_KEY_HARDWARE_ERROR
}
/// `true` if `sense_key == NOT READY (2)` — medium not present /
/// drive becoming ready / etc.
pub fn is_not_ready(&self) -> bool {
self.sense_key == SENSE_KEY_NOT_READY
}
/// `true` if `sense_key == UNIT ATTENTION (6)` — disc/drive state
/// changed since the prior command (media inserted/removed,
/// power-on reset, parameters changed). Caller should rescan rather
/// than retry the read.
pub fn is_unit_attention(&self) -> bool {
self.sense_key == SENSE_KEY_UNIT_ATTENTION
}
/// `true` if `sense_key == DATA PROTECT (7)` — read blocked by
/// AACS / region / write-protect. Retry won't help.
pub fn is_data_protect(&self) -> bool {
self.sense_key == SENSE_KEY_DATA_PROTECT
}
/// `true` if `sense_key == ILLEGAL REQUEST (5)` — typically a bug
/// in the CDB we sent (LBA out of range, reserved bit, etc.). Don't
/// retry.
pub fn is_illegal_request(&self) -> bool {
self.sense_key == SENSE_KEY_ILLEGAL_REQUEST
}
/// `true` if `sense_key == ABORTED COMMAND (B)` — transient; one
/// retry is usually safe.
pub fn is_aborted_command(&self) -> bool {
self.sense_key == SENSE_KEY_ABORTED_COMMAND
}
}
/// Decode an SPC-4 sense buffer into the structured triple
/// `(sense_key, asc, ascq)`.
///
/// Handles both response-code formats SPC-4 mandates:
///
/// - **Descriptor format** (response code `0x72` / `0x73`):
/// - sense key = `sense[1] & 0x0F`
/// - asc = `sense[2]`
/// - ascq = `sense[3]`
/// - **Fixed format** (response code `0x70` / `0x71` and any unknown
/// code per SPC-4 §4.5.3):
/// - sense key = `sense[2] & 0x0F`
/// - asc = `sense[12]`
/// - ascq = `sense[13]`
///
/// `sb_len_wr` is the number of bytes the transport actually wrote into
/// `sense`. When the buffer is too short for the relevant fields we
/// return [`ScsiSense::NONE`] for the missing pieces rather than reading
/// uninitialised memory. The minimum useful sense reply per SPC-4 is 8
/// bytes (descriptor) or 14 bytes (fixed, to reach ASC/ASCQ at offsets
/// 12/13).
///
/// Pure function — same parse on every platform backend (Linux SG_IO,
/// macOS IOKit, Windows SPTI) so a regression here would silently
/// mis-route SCSI errors on all three OSes simultaneously.
pub(crate) fn parse_sense(sense: &[u8], sb_len_wr: u8) -> ScsiSense {
let n = (sb_len_wr as usize).min(sense.len());
if n < 3 {
return ScsiSense::NONE;
}
let response_code = sense[0] & 0x7F;
let descriptor = response_code == 0x72 || response_code == 0x73;
if descriptor {
// Descriptor format: key/asc/ascq are at fixed offsets 1/2/3.
let asc = if n >= 3 { sense[2] } else { 0 };
let ascq = if n >= 4 { sense[3] } else { 0 };
ScsiSense {
sense_key: sense[1] & 0x0F,
asc,
ascq,
}
} else {
// Fixed format: key at byte 2, ASC/ASCQ at bytes 12/13.
let asc = if n >= 13 { sense[12] } else { 0 };
let ascq = if n >= 14 { sense[13] } else { 0 };
ScsiSense {
sense_key: sense[2] & 0x0F,
asc,
ascq,
}
}
}
// ── SG_IO driver_status bits ────────────────────────────────────────────────
/// `DRIVER_SENSE` (0x08) — bit set in `driver_status` to indicate that
/// sense data was attached to a CHECK CONDITION reply. **Not** a transport
/// failure on its own. Mask this off before deciding whether `driver_status`
/// represents a real bus/host problem.
///
/// Used by Linux SG_IO (`sg_io_hdr.driver_status`); macOS IOKit and
/// Windows SPTI carry the equivalent signal in different fields and
/// don't need the same masking — the misclassification was Linux-only.
#[cfg(target_os = "linux")]
pub(crate) const DRIVER_SENSE: u16 = 0x08;
// ── Types ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -98,192 +331,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 +559,116 @@ 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;
fn parse_sense_key(sense: &[u8], sb_len_wr: u8) -> u8 {
parse_sense(sense, sb_len_wr).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);
}
}
+46 -39
View File
@@ -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];
@@ -238,15 +228,17 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
crate::scsi::TUR_TIMEOUT_MS,
) {
Ok(_) => Ok(true),
Err(Error::ScsiError { sense_key: 2, .. }) => Ok(false),
Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false),
Err(e) => Err(e),
}
}
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,23 +299,34 @@ 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,
sense_key: 0,
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
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 keys off byte 0's response code
// to handle descriptor (0x72/0x73) vs fixed (0x70/0x71).
//
// 0.13.23: carry the full SPC-4 sense triple in
// `Error::ScsiError::sense` so callers can route on
// `ScsiSense::is_medium_error()` etc.
let parsed = super::parse_sense(&sptwb.sense, K_SENSE_SIZE as u8);
return Err(Error::ScsiError {
opcode: cdb[0],
status: sptwb.spt.ScsiStatus,
sense_key,
sense: Some(parsed),
});
}
+42 -9
View File
@@ -278,10 +278,11 @@ impl UdfFs {
/// The data_lba is partition-relative.
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
let extents = self.read_icb_extents(reader, meta_lba)?;
extents
.first()
.copied()
.ok_or(Error::DiscRead { sector: 0 })
extents.first().copied().ok_or(Error::DiscRead {
sector: 0,
status: None,
sense: None,
})
}
/// Read ALL allocation extents for a file from its ICB.
@@ -307,6 +308,8 @@ impl UdfFs {
if ad_offset + l_ad > icb.len() {
return Err(Error::DiscRead {
sector: self.meta_to_abs(meta_lba) as u64,
status: None,
sense: None,
});
}
(ad_offset, l_ad)
@@ -319,11 +322,19 @@ impl UdfFs {
if ad_offset + l_ad > icb.len() {
return Err(Error::DiscRead {
sector: self.meta_to_abs(meta_lba) as u64,
status: None,
sense: None,
});
}
(ad_offset, l_ad)
}
_ => return Err(Error::DiscRead { sector: 0 }),
_ => {
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
}
};
let mut extents = Vec::new();
@@ -414,7 +425,11 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
if tag_id != 2 {
return Err(Error::DiscRead { sector: 0 });
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
}
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
@@ -455,14 +470,22 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
}
if partition_start == 0 {
return Err(Error::DiscRead { sector: 0 });
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
}
// Step 3: Parse partition maps from LVD to find metadata partition
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
// The metadata file is stored at lba=0 of the physical partition
let metadata_start = if num_partition_maps >= 2 {
let lvd_sec = lvd_sector.ok_or(Error::DiscRead { sector: 0 })?;
let lvd_sec = lvd_sector.ok_or(Error::DiscRead {
sector: 0,
status: None,
sense: None,
})?;
// Read LVD to check partition map type
let mut lvd = [0u8; 2048];
@@ -497,6 +520,8 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
if ad_off + 8 > meta_icb.len() {
return Err(Error::DiscRead {
sector: meta_file_lba as u64,
status: None,
sense: None,
});
}
let ad_len = u32::from_le_bytes([
@@ -536,7 +561,11 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
if fsd_tag != 256 {
return Err(Error::DiscRead { sector: 0 });
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
}
// Root Directory ICB: long_ad at FSD offset 400
@@ -585,6 +614,8 @@ fn read_directory(
if ad_off + 8 > icb.len() {
return Err(Error::DiscRead {
sector: (meta_start + meta_lba) as u64,
status: None,
sense: None,
});
}
let len = u32::from_le_bytes([
@@ -607,6 +638,8 @@ fn read_directory(
if ad_off + 8 > icb.len() {
return Err(Error::DiscRead {
sector: (meta_start + meta_lba) as u64,
status: None,
sense: None,
});
}
let len = u32::from_le_bytes([
+656
View File
@@ -0,0 +1,656 @@
//! 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);
}
// Model what a real damaged-disc read returns: CHECK CONDITION +
// MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR,
// ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must
// engage on this — `Error::DiscRead` is libfreemkv's own
// post-classification signal, not what a real reader emits.
Err(libfreemkv::error::Error::ScsiError {
opcode: libfreemkv::scsi::SCSI_READ_10,
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(libfreemkv::ScsiSense {
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
asc: 0x11,
ascq: 0x05,
}),
})
}
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"
);
}
// ── 8. Hysteresis recovers data the drive can read individually ──────────
//
// Empirically observed on the LG BU40N: in damaged regions the drive fails
// multi-sector READ commands but reads each sector cleanly when asked one
// at a time. Disc::copy's hysteresis state machine (0.13.22, replaces the
// 0.13.21 bisect-on-fail) drops to bpt=1 on the first multi-sector failure
// and stays there until BPT1_EXIT_THRESHOLD consecutive good single-sector
// reads, then returns to bpt=batch.
//
// Fixture: a reader that returns Err for any read with count > 1, and Ok
// for count == 1. The full disc must recover via the bpt=1 path with
// 100 % bytes_good outcome.
struct BlockSizeFailingReader {
capacity: u32,
}
impl SectorReader for BlockSizeFailingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
if count == 1 {
// Single-sector reads succeed — fill the sector with a marker.
for chunk in buf.chunks_mut(SECTOR_SIZE) {
chunk.fill((lba & 0xff) as u8);
}
Ok(buf.len())
} else {
// Multi-sector reads fail with the BU40N's signature: CHECK
// CONDITION + MEDIUM ERROR. The hysteresis must dispatch on
// this as marginal-read and drop to bpt=1.
Err(libfreemkv::error::Error::ScsiError {
opcode: libfreemkv::scsi::SCSI_READ_10,
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(libfreemkv::ScsiSense {
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
asc: 0x11,
ascq: 0x00,
}),
})
}
}
fn capacity(&self) -> u32 {
self.capacity
}
}
#[test]
fn test_disc_copy_hysteresis_recovers_via_single_sector_reads() {
// 256 sectors = 0.5 MB. Reader fails any multi-sector read but
// succeeds on bpt=1. The hysteresis path must drop to Single mode on
// the first multi-sector failure and recover every sector at bpt=1.
// Stays in Single mode until BPT1_EXIT_THRESHOLD reached (10 000
// sectors); since this disc is only 256 sectors we never re-enter
// Block mode, which is fine — every sector still recovers.
let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
let mut reader = BlockSizeFailingReader {
capacity: 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 result = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Bisect must recover every sector — the drive could read each one
// individually, and our algorithm must descend to that.
assert_eq!(
result.bytes_good, total_bytes,
"bisect-on-fail must recover every sector via single-sector reads. \
Got bytes_good={} of total {}",
result.bytes_good, total_bytes
);
assert_eq!(
result.bytes_pending, 0,
"no sectors should be left NonTrimmed after a successful bisect"
);
assert!(
result.complete,
"complete=true expected when every sector recovered"
);
}
+391
View File
@@ -0,0 +1,391 @@
//! Integration tests for the SCSI error-decoding contract.
//!
//! v0.13.20 rewrote `scsi/linux.rs` to a synchronous blocking SG_IO and
//! consolidated sense parsing into the `parse_sense` helper that every
//! platform backend now shares. v0.13.23 replaced the `Error::ScsiError`
//! flat-fields shape with `{ opcode, status, sense: Option<ScsiSense> }`
//! so callers can route on structured sense data (key + ASC + ASCQ) via
//! [`Error::scsi_sense`] / [`Error::is_marginal_read`] /
//! [`ScsiSense::is_*`].
//!
//! The actual `ioctl(SG_IO, ...)` call is impossible to mock without a
//! kernel — see
//! `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`
//! for why the audit recommends against libc shims here. These tests
//! therefore pin the *contract* every backend must satisfy via a mock
//! `ScsiTransport`:
//!
//! 1. Healthy result → `Ok(ScsiResult { bytes_transferred = data.len() - resid })`.
//! 2. Transport-level failure (no SCSI status delivered: kernel
//! timeout, USB bridge wedge, IOKit service error) →
//! `Error::ScsiError { status: SCSI_STATUS_TRANSPORT_FAILURE, sense: None }`.
//! `Error::is_scsi_transport_failure()` returns `true`. Used by
//! `drive_has_disc` to detect the wedge signature.
//! 3. SCSI-level failure (drive replied CHECK CONDITION with sense) →
//! `Error::ScsiError { status: 0x02, sense: Some(ScsiSense {…}) }`
//! with the parsed key/ASC/ASCQ.
//! 4. `Error::is_marginal_read()` is `true` for MEDIUM ERROR /
//! ABORTED COMMAND / RECOVERED ERROR / NO SENSE; `false` for
//! HARDWARE / DATA PROTECT / UNIT ATTENTION / NOT READY / ILLEGAL
//! REQUEST and for transport failures.
//!
//! Inline `parse_sense_tests` in `src/scsi/mod.rs` cover the pure parse
//! logic (descriptor 0x72/0x73 vs fixed 0x70/0x71, short-buffer, VALID
//! bit masking, unknown response codes, ASC/ASCQ offsets); this file
//! covers the consumer side — a real transport feeding a real Error
//! variant to a real call site (`scsi::inquiry`).
use libfreemkv::error::Error;
use libfreemkv::scsi::{
DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE,
SENSE_KEY_ABORTED_COMMAND, SENSE_KEY_DATA_PROTECT, SENSE_KEY_HARDWARE_ERROR,
SENSE_KEY_ILLEGAL_REQUEST, SENSE_KEY_MEDIUM_ERROR, SENSE_KEY_NOT_READY,
SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, ScsiResult, ScsiSense, ScsiTransport,
};
/// A scripted ScsiTransport. Each `execute()` consumes the next entry
/// from `script` and returns the corresponding outcome.
///
/// Outcomes mirror what each backend's `execute()` should produce after
/// the v0.13.23 sense plumbing: `Option<ScsiSense>` carrying the full
/// SPC-4 triple for drive-reported failures, `None` for transport-level
/// failures.
struct MockTransport {
script: Vec<MockOutcome>,
next: usize,
}
#[derive(Clone)]
enum MockOutcome {
/// Healthy completion. `data` is what the transport wrote into the
/// caller's data buffer (truncated to the buffer length); `resid`
/// is reported back as `data.len() - bytes_transferred`.
Ok { data: Vec<u8>, resid: i32 },
/// Transport-level failure: `hdr.host_status = DID_TIME_OUT` on
/// Linux, `kIOReturnError` on macOS, `DeviceIoControl` returning 0
/// on Windows. Backends synthesise `SCSI_STATUS_TRANSPORT_FAILURE`
/// with `sense = None`.
TransportFailure,
/// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION`
/// + a populated sense buffer).
ScsiFailure { status: u8, sense: ScsiSense },
}
impl MockTransport {
fn new(script: Vec<MockOutcome>) -> Self {
Self { script, next: 0 }
}
}
impl ScsiTransport for MockTransport {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> libfreemkv::error::Result<ScsiResult> {
let i = self.next;
self.next += 1;
let outcome = self
.script
.get(i)
.cloned()
.expect("MockTransport script ran out — test wrote fewer outcomes than calls");
match outcome {
MockOutcome::Ok { data: d, resid } => {
let n = d.len().min(data.len());
data[..n].copy_from_slice(&d[..n]);
let bytes = (data.len() as i32).saturating_sub(resid).max(0) as usize;
Ok(ScsiResult {
status: 0,
bytes_transferred: bytes,
sense: [0u8; 32],
})
}
MockOutcome::TransportFailure => Err(Error::ScsiError {
opcode: cdb[0],
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
MockOutcome::ScsiFailure { status, sense } => Err(Error::ScsiError {
opcode: cdb[0],
status,
sense: Some(sense),
}),
}
}
}
/// Helper — build a CHECK CONDITION outcome with a given sense key
/// and zero ASC/ASCQ. Most consumer-side tests only care about the key.
fn check_cond(sense_key: u8) -> MockOutcome {
MockOutcome::ScsiFailure {
status: SCSI_STATUS_CHECK_CONDITION,
sense: ScsiSense {
sense_key,
asc: 0,
ascq: 0,
},
}
}
// ── 1. Healthy read ───────────────────────────────────────────────────────
#[test]
fn test_healthy_inquiry_returns_ok_with_full_transfer() {
// Build a fake INQUIRY response (96 bytes per scsi::inquiry).
// Vendor "TEST-VND", model "TEST-MODEL ", firmware "1.00".
let mut payload = vec![0u8; 96];
payload[8..16].copy_from_slice(b"TEST-VND");
payload[16..32].copy_from_slice(b"TEST-MODEL ");
payload[32..36].copy_from_slice(b"1.00");
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
data: payload,
resid: 0,
}]);
let r = libfreemkv::scsi::inquiry(&mut transport).expect("inquiry should succeed");
assert_eq!(r.vendor_id, "TEST-VND");
assert_eq!(r.model, "TEST-MODEL");
assert_eq!(r.firmware, "1.00");
}
// ── 2. Transport-level failure: no SCSI status, no sense data ─────────────
#[test]
fn test_transport_failure_surfaces_with_sense_none() {
let mut transport = MockTransport::new(vec![MockOutcome::TransportFailure]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
assert!(
err.is_scsi_transport_failure(),
"TransportFailure must satisfy is_scsi_transport_failure()"
);
assert!(
err.scsi_sense().is_none(),
"transport failure has no sense data"
);
assert!(
!err.is_marginal_read(),
"transport failure must not be classified as marginal-read"
);
match err {
Error::ScsiError { status, sense, .. } => {
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(sense.is_none());
}
other => panic!("expected ScsiError, got {other:?}"),
}
}
// ── 3. CHECK CONDITION + ILLEGAL REQUEST ──────────────────────────────────
#[test]
fn test_check_cond_illegal_request_carries_sense() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ILLEGAL_REQUEST)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert_eq!(sense.sense_key, SENSE_KEY_ILLEGAL_REQUEST);
assert!(sense.is_illegal_request());
assert!(!err.is_marginal_read(), "ILLEGAL REQUEST is not marginal");
assert!(!err.is_scsi_transport_failure());
}
// ── 4. CHECK CONDITION + NOT READY (drive_has_disc relies on this) ────────
#[test]
fn test_check_cond_not_ready_predicate() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_NOT_READY)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_not_ready(), "sense_key 2 ⇒ is_not_ready");
assert!(!err.is_marginal_read(), "NOT READY is not marginal");
}
// ── 5. CHECK CONDITION + MEDIUM ERROR (canonical marginal-read) ───────────
#[test]
fn test_check_cond_medium_error_is_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_MEDIUM_ERROR)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_medium_error());
assert!(sense.is_marginal());
assert!(
err.is_marginal_read(),
"MEDIUM ERROR is the canonical marginal-read signal"
);
}
// ── 6. CHECK CONDITION + ABORTED COMMAND (also marginal) ──────────────────
#[test]
fn test_check_cond_aborted_command_is_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ABORTED_COMMAND)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_aborted_command());
assert!(err.is_marginal_read(), "ABORTED COMMAND is marginal");
}
// ── 7. CHECK CONDITION + RECOVERED ERROR (drive recovered; marginal) ──────
#[test]
fn test_check_cond_recovered_error_is_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_RECOVERED_ERROR)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
assert!(
err.is_marginal_read(),
"RECOVERED ERROR is treated as marginal (drive recovered, retry-friendly class)"
);
}
// ── 8. CHECK CONDITION + HARDWARE ERROR (NOT marginal — bail) ─────────────
#[test]
fn test_check_cond_hardware_error_not_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_HARDWARE_ERROR)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_hardware_error());
assert!(
!err.is_marginal_read(),
"HARDWARE ERROR must not be marginal — drive failing, retry can't help"
);
}
// ── 9. CHECK CONDITION + DATA PROTECT (NOT marginal — bail) ───────────────
#[test]
fn test_check_cond_data_protect_not_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_DATA_PROTECT)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_data_protect());
assert!(
!err.is_marginal_read(),
"DATA PROTECT (AACS / region) must not be marginal — retry can't help"
);
}
// ── 10. CHECK CONDITION + UNIT ATTENTION (NOT marginal — caller rescans) ──
#[test]
fn test_check_cond_unit_attention_not_marginal() {
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_UNIT_ATTENTION)]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
assert!(sense.is_unit_attention());
assert!(
!err.is_marginal_read(),
"UNIT ATTENTION must not be marginal — caller should rescan, not retry"
);
}
// ── 11. ASC/ASCQ propagate from sense buffer to ScsiError.sense ───────────
#[test]
fn test_asc_ascq_round_trip_through_error() {
let outcome = MockOutcome::ScsiFailure {
status: SCSI_STATUS_CHECK_CONDITION,
sense: ScsiSense {
sense_key: SENSE_KEY_MEDIUM_ERROR,
asc: 0x11,
ascq: 0x05, // L-EC UNCORRECTABLE
},
};
let mut transport = MockTransport::new(vec![outcome]);
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
let sense = err.scsi_sense().expect("must carry sense");
assert_eq!(sense.sense_key, SENSE_KEY_MEDIUM_ERROR);
assert_eq!(sense.asc, 0x11);
assert_eq!(sense.ascq, 0x05);
}
// ── 12. Healthy short transfer (resid > 0) ────────────────────────────────
#[test]
fn test_healthy_short_transfer_reports_partial_bytes() {
// Transport reported back fewer bytes than requested (resid = 16).
// For a 96-byte buffer that means bytes_transferred = 80.
let payload = vec![0u8; 96];
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
data: payload,
resid: 16,
}]);
let cdb = [libfreemkv::scsi::SCSI_INQUIRY, 0, 0, 0, 0x60, 0];
let mut buf = [0u8; 96];
let r = transport
.execute(&cdb, DataDirection::FromDevice, &mut buf, 1_000)
.expect("ok");
assert_eq!(r.status, 0);
assert_eq!(
r.bytes_transferred, 80,
"bytes_transferred must equal data.len() - resid"
);
}
// ── 13. Error::Display does not leak English in the SCSI variant ──────────
//
// Library rule (project docs): no English in error display, only "E{code}"
// + structured data. Regression guard for the SCSI variant specifically
// because it's the most-emitted error in the rip path.
#[test]
fn test_scsi_error_display_format_is_codes_only() {
let err = Error::ScsiError {
opcode: 0x12,
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some(ScsiSense {
sense_key: SENSE_KEY_ILLEGAL_REQUEST,
asc: 0x24,
ascq: 0x00,
}),
};
let s = err.to_string();
assert!(
s.starts_with("E4000:"),
"ScsiError must lead with E4000: {s}"
);
assert!(
s.contains("0x12") && s.contains("0x02") && s.contains("0x05") && s.contains("0x24"),
"ScsiError must show opcode/status/key/asc in hex: {s}"
);
// Crude English filter — same as the inline error.rs::display test.
for word in s.split(|c: char| !c.is_ascii_alphabetic()) {
assert!(
word.len() <= 4,
"ScsiError display contains suspicious word `{word}`: {s}"
);
}
}
// ── 14. Transport-failure Display omits sense fields ──────────────────────
#[test]
fn test_scsi_transport_failure_display_short_form() {
let err = Error::ScsiError {
opcode: 0x28,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
let s = err.to_string();
assert!(s.starts_with("E4000:"));
assert!(s.contains("0x28") && s.contains("0xff"));
// No sense triple should appear in the no-sense form.
assert!(
!s.contains("0x00/0x00/0x00"),
"transport failure must not carry phantom sense: {s}"
);
}