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.
99 lines
5.5 KiB
Markdown
99 lines
5.5 KiB
Markdown
# 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`](../src/scsi/mod.rs)
|
||
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`:
|
||
|
||
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 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`](../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`](../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](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)
|
||
- Recovery strategy and mapfile: `freemkv-engine/src/recovery/`
|
||
- In this crate: [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`),
|
||
[`src/scsi/mod.rs`](../src/scsi/mod.rs) (`SenseFamily`),
|
||
[`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`),
|
||
[`src/event.rs`](../src/event.rs) (progress events).
|