Disc::sweep, Disc::patch, Disc::copy, SweepOptions and PatchOptions have zero occurrences in src/ — recovery moved to freemkv-engine — but they were still documented in 30 places across README.md, TROUBLESHOOTING.md, six files under docs/, seven src/ doc comments and a Cargo.toml comment. README.md is the crate's GitHub front page and carried a full multi-pass code example that cannot compile. Two of the src/ references were intra-doc LINKS to deleted items ([`disc::Disc::copy`], [`disc::Disc::patch`] in scsi/mod.rs). They produced no warning on a normal `cargo doc` only because they sit on pub(crate) items; `--document-private-items` reports both, and they are gone now. The README example is deleted rather than rewritten against the engine's API: libfreemkv documenting a downstream crate's API on its own front page is the drift that produced this, and it cannot even depend on it. The src/ references become plain code spans naming freemkv_engine::recovery::* — deliberately not links, for the same reason. docs/rip-recovery.md was 202 lines about relocated code. It now documents only what this crate owns — Drive::read, SenseFamily, DiscStream's adaptive batch halving — plus the read-path design constraints, which belong with the code that enforces them, and points at freemkv-engine/src/recovery/ for the strategy. api-design.md's module tree is regenerated from the real src/disc/ and src/drive/ layouts instead of hand-patched; it had listed sweep.rs, patch.rs, mapfile.rs and read_error.rs, none of which exist. Three stale facts surfaced while rewriting and are corrected: the read timeouts are 10 s / 60 s, not the documented 1.5 s / 30 s; Drive::reset and SgIoTransport::reset no longer exist at all, so "no SCSI reset from any read path" is now stated as the stronger fact it has become; and verify_title, listed as a progress-emitting operation, was removed entirely. CHANGELOG.md keeps its references — those are the historical record of the releases that shipped the API.
5.5 KiB
Rip recovery — what libfreemkv owns
Recovery strategy moved OUT of this crate in 1.6.0. The forward sweep, the
targeted retry pass, the ddrescue mapfile, damage classification and the
multipass loop now live in the freemkv-engine crate as
freemkv_engine::recovery::{copy, sweep, patch}. The dependency runs
engine → libfreemkv, so this crate cannot call into the engine; front-ends
(freemkv CLI, autorip) get recovery from the engine directly.
What stayed here are the two layers underneath the strategy: the single-shot read primitive, and the in-stream request-size adaptation that sits in front of it. This document covers those, plus the design constraints they encode — the constraints are the reason the strategy above them looks the way it does, so they belong with the code that enforces them.
For the strategy itself — damage-jump thresholds, pass ordering, mapfile status
state machine, wedge detection — read freemkv-engine/src/recovery/.
| Layer | Where it lives | What it does |
|---|---|---|
| 1 — Bad-range retry | freemkv-engine (recovery::patch) |
Re-reads non-+ ranges with the long timeout. Idempotent; caller invokes 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 in src/mux/disc.rs |
Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
Layer 2 also translates drive facts: SenseFamily
classifies SCSI sense data into the categories the engine's strategy routes on
(marginal vs. hardware vs. not-ready). Getting that classification wrong
silently misroutes recovery, which is why it lives next to the transport rather
than in the strategy.
Layer 3 runs inside any consumer of DiscStream — direct PES pipeline, ISO
playback — without caller involvement, and applies whether or not the engine's
recovery is in play.
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:
- Try the current preferred batch size (e.g. 32 sectors, one BD ECC block).
- On failure: halve the batch and retry at the same LBA. Emit
EventKind::BatchSizeChanged { reason: Shrunk }. - On a clean-read streak: probe back up toward the preferred size. Emit
EventKind::BatchSizeChanged { reason: Probed }. - If a single-sector read fails: skip (zero-fill, emit
EventKind::SectorSkipped) whenskip_errorsis set, otherwise returnErr(DiscRead).
This exists so a transient single-sector glitch inside a 32-sector batch can be
isolated and read individually without the caller implementing retry logic. See
src/event.rs for the emitted events.
Design choices
These are constraints on the read path, enforced here and relied on by the engine's strategy.
Drive::read is single-shot. No inline retry phases, no SCSI reset, no
eject cycle. The recovery flag controls only the per-CDB timeout (10 s vs.
60 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 the stop-wedge postmortem (2026-04-25) 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 nothing to show for it. Recovery responsibility
is layered instead: layer 1 handles ranges, layer 3 handles request size,
neither touches the wedge-prone reset path.
No MODE SELECT to disable drive retries. Neither ddrescue nor any
consumer ripper does this. Drive firmware has access to raw analog signal,
laser power control and drive-specific ECC tuning that userspace cannot
replicate — disabling it throws away recovery headroom on marginal sectors. The
fast pass fails quickly via short SG_IO timeouts and lets the firmware work the
long timeout during retry.
No SCSI reset from any read path. There is no reset escape hatch on
Drive at all: the SG_SCSI_RESET ioctl and STOP/START UNIT escalation went in
0.13.6, the macOS reset (always a no-op) was removed entirely, and the
top-level scsi::reset() wrappers went with their last callers. The only
remaining reset is a Windows-specific device-level helper in
src/scsi/windows.rs, never reached from a read.
ISO intermediate, even for single-pass. The engine's Pass 1 always writes
an ISO, and the mux stage reads it back via FileSectorSource. For a
no-retry rip this costs a few minutes but buys resumability across crashes,
re-muxability without re-ripping, and a persistent forensic artifact. Callers
who need pure speed can bypass it with DiscStream::new(Box::new(drive), …) —
nothing forbids it, and layer 3 still applies there.
References
- ddrescue manual, Algorithm chapter
- ddrescue optical media notes
- Recovery strategy and mapfile:
freemkv-engine/src/recovery/ - In this crate:
src/drive/mod.rs(Drive::read),src/scsi/mod.rs(SenseFamily),src/mux/disc.rs(DiscStream::fill_extents),src/event.rs(progress events).