mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec97af104 | ||
|
|
3a6c1aa5a3 | ||
|
|
1ba3264747 | ||
|
|
ae2909fe8d | ||
|
|
6a3d19a453 | ||
|
|
a35596d2d1 | ||
|
|
318f654fed | ||
|
|
5e82ef65de | ||
|
|
8534607329 | ||
|
|
97e1a4cad3 | ||
|
|
3f6bf33de5 | ||
|
|
359301e8fc | ||
|
|
bfa527162a | ||
|
|
45defd47f3 | ||
|
|
fe8933dad2 | ||
|
|
0a3c1b7b70 | ||
|
|
2bcdc97341 | ||
|
|
4863e9c545 | ||
|
|
03db038dd9 | ||
|
|
95001ba2c3 | ||
|
|
a1f4dff6f6 | ||
|
|
7dd5001d45 | ||
|
|
67fe93c0b8 | ||
|
|
5c4f575c46 | ||
|
|
e2cb7a78bb | ||
|
|
a6f1bd19bc | ||
|
|
9e3d0f1383 | ||
|
|
1e6eb0698d | ||
|
|
bd744171e4 | ||
|
|
7de9d4d42c | ||
|
|
9ac0c2fcdf | ||
|
|
3aa27d5d1b | ||
|
|
5083f70cff | ||
|
|
477cf7ee0e | ||
|
|
0b079e27ce | ||
|
|
5f69fd2a44 | ||
|
|
befe6ef69b | ||
|
|
29491d6eaf | ||
|
|
2e602f7e37 | ||
|
|
a126f31b70 | ||
|
|
6ff10df83a | ||
|
|
52b8522a75 | ||
|
|
13eb4331b6 | ||
|
|
1a3f7ce676 | ||
|
|
e1a1f730be | ||
|
|
646c22ae93 | ||
|
|
2f6092a686 | ||
|
|
e5a90a6567 | ||
|
|
afce1031d5 | ||
|
|
2c0bff1303 | ||
|
|
2067ebe485 | ||
|
|
2ce4c20221 | ||
|
|
da3004b120 | ||
|
|
b5ed97801b | ||
|
|
ae76aaf0fa | ||
|
|
2cd4fbead7 | ||
|
|
ebffc6eb88 | ||
|
|
424d3cd4f2 | ||
|
|
d2905ba7bb | ||
|
|
b4de5d343d | ||
|
|
8beeac7df9 | ||
|
|
b33f41e219 | ||
|
|
e85e20f436 | ||
|
|
7bc54be8d3 | ||
|
|
6d9083743b | ||
|
|
c49a68054f | ||
|
|
4bd38787c6 | ||
|
|
b4951b1c5b | ||
|
|
92dc145864 | ||
|
|
36b65f526a | ||
|
|
ca8ebf418f | ||
|
|
34182d956a | ||
|
|
0f967a2084 | ||
|
|
0341995c0d | ||
|
|
fe0ec0c5cf | ||
|
|
022657d481 | ||
|
|
fc8eca44e1 | ||
|
|
43836865be | ||
|
|
5e22199441 |
@@ -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
|
||||
|
||||
+1032
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.13.4"
|
||||
version = "0.17.13"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -24,6 +24,10 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under project docs ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -31,6 +35,9 @@ libc = "0.2"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# libfreemkv — local dev helper.
|
||||
# Mirrors the cross-crate scripts in (internal)/scripts/test-all.sh
|
||||
# but scoped to this single crate.
|
||||
|
||||
.PHONY: test build check ci clean
|
||||
|
||||
test:
|
||||
cargo test --tests
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
check:
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
ci: check build test
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.11"
|
||||
libfreemkv = "0.17"
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
Common problems and solutions for optical drive ripping with freemkv.
|
||||
|
||||
---
|
||||
|
||||
## 1. USB-SATA Bridge Issues
|
||||
|
||||
This is the single most common source of problems when ripping discs over USB.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The drive disappears mid-rip. The ripping tool reports the device is gone, and `ls /dev/sg*` no longer shows it.
|
||||
- The device re-enumerates under a different name: `sg4` becomes `sg5`, then `sg7`, then `sg11` after each USB port reset.
|
||||
- `dmesg` shows USB port resets: `usb X-Y: reset high-speed USB device`, `xhci_hcd 0000:00:14.0: Cannot enable. Maybe the USB cable is bad?`, or `usb-storage: device reset failed`.
|
||||
- The SCSI layer reports `host_status=7` (Linux USB transport error) in sense data.
|
||||
- The drive works fine for reading data discs or burning, but crashes when hitting damaged sectors during a rip.
|
||||
- After the crash, the drive is completely invisible until physically unplugged and reconnected.
|
||||
|
||||
### Root Cause
|
||||
|
||||
USB-SATA bridges translate between the USB Mass Storage protocol (BOT or UAS) and the drive's native SATA interface. When the optical drive encounters an unreadable sector, it returns a SCSI CHECK CONDITION with sense key 0x03 (MEDIUM ERROR). Some bridge chipsets -- particularly the Initio INIC-36xx family -- have buggy firmware that mishandles this error response.
|
||||
|
||||
Specific failure modes:
|
||||
|
||||
- **Incorrect residue reporting.** The bridge claims a different number of bytes transferred than what actually occurred. The Linux USB storage driver sees this discrepancy as a protocol violation and resets the port to recover. The `US_FL_IGNORE_RESIDUE` quirk exists specifically for this class of bug (see `drivers/usb/storage/transport.c` in the Linux kernel).
|
||||
- **Bridge firmware crash.** On some Initio bridges, a malformed SCSI error response from the drive causes the bridge MCU to hang entirely. The USB controller sees the device stop responding and initiates a port reset. The bridge recovers (it re-enumerates), but the rip is dead -- all state is lost.
|
||||
- **Sense data corruption.** The bridge forwards garbled or truncated sense data to the host, which the SCSI midlayer cannot parse, leading to a transport reset.
|
||||
|
||||
This is a hardware + firmware problem, not a software bug. The same drive connected via direct SATA does not exhibit these symptoms.
|
||||
|
||||
### Known Affected Bridges
|
||||
|
||||
| Chipset | USB IDs | Notes |
|
||||
|---------|---------|-------|
|
||||
| Initio INIC-3609 | `13fd:3609` | Very common in cheap SATA-to-USB enclosures. Highly problematic. |
|
||||
| Initio INIC-3619 | `13fd:3940` | Same firmware family as INIC-3609. |
|
||||
| Initio INIC-3069 | `13fd:0840` | Older variant, same residue bug. |
|
||||
| ASMedia ASM1051 | `174c:5106` | Early ASM SATA bridge. Residue issues on error paths. |
|
||||
| JMicron JMB36x | `152d:0561` | Some firmware versions. Not all JMicroon chips are affected. |
|
||||
|
||||
If your drive came in a pre-built external enclosure (Vantec, Sabrent, OWC, etc.), it almost certainly uses one of these bridge chips internally.
|
||||
|
||||
### The Fix: USB Storage Quirk
|
||||
|
||||
Apply the `US_FL_IGNORE_RESIDUE` kernel quirk for your bridge. This tells the Linux USB storage driver to ignore the residue field in SCSI response frames, preventing the port reset on mismatched byte counts.
|
||||
|
||||
**Step 1: Identify your bridge's vendor:product ID.**
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for your drive's entry. Example output:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation INIC-3609
|
||||
```
|
||||
|
||||
Here the vendor ID is `13fd` and the product ID is `0840`.
|
||||
|
||||
**Step 2: Apply the quirk at runtime.**
|
||||
|
||||
```bash
|
||||
echo "13fd:0840:i" > /sys/module/usb_storage/parameters/quirks
|
||||
```
|
||||
|
||||
Replace `13fd:0840` with your device's actual IDs. The `:i` flag means `US_FL_IGNORE_RESIDUE`.
|
||||
|
||||
You can combine multiple flags. Common additions:
|
||||
|
||||
- `:i` -- ignore residue (`US_FL_IGNORE_RESIDUE`)
|
||||
- `:u` -- force BOT mode instead of UAS, for bridges with UAS bugs
|
||||
|
||||
**Step 3: Reconnect the drive.** Unplug and replug the USB cable, or bind/unbind the device. The quirk is applied per-module-load, so existing sessions may need the drive reconnected.
|
||||
|
||||
### Making It Persistent
|
||||
|
||||
Add the quirk to your kernel boot parameters so it survives reboots.
|
||||
|
||||
Edit `/etc/default/grub` (GRUB) and add to `GRUB_CMDLINE_LINUX_DEFAULT`:
|
||||
|
||||
```
|
||||
GRUB_CMDLINE_LINUX_DEFAULT="quiet usb_storage.quirks=13fd:0840:i"
|
||||
```
|
||||
|
||||
Then rebuild the GRUB config:
|
||||
|
||||
```bash
|
||||
sudo update-grub
|
||||
```
|
||||
|
||||
For systemd-boot, add to your loader entry or `/etc/kernel/cmdline`:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i
|
||||
```
|
||||
|
||||
Multiple devices can be separated by commas:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i,174c:5106:u
|
||||
```
|
||||
|
||||
### Recommended Bridges
|
||||
|
||||
If you are buying a USB-SATA adapter or enclosure for optical drive use:
|
||||
|
||||
| Bridge | USB IDs | Notes |
|
||||
|--------|---------|-------|
|
||||
| ASMedia ASM1153 | `174c:1153` | Reliable. Widely available in SATA-USB 3.0 cables. |
|
||||
| JMicron JMS578 | `152d:0578` | Good firmware. Supports UASP. |
|
||||
| Icy Box IB-AC640-C3 | N/A | Uses a known-good bridge internally. Plug-and-play. |
|
||||
|
||||
Avoid any enclosure or adapter listing an Initio chipset.
|
||||
|
||||
### Best Solution: Direct SATA
|
||||
|
||||
Connect your optical drive directly to a motherboard SATA port. This eliminates the USB-SATA bridge entirely and is the most reliable configuration:
|
||||
|
||||
- No USB protocol overhead or translation errors.
|
||||
- No bridge firmware bugs.
|
||||
- No port resets or re-enumeration.
|
||||
- Full SATA error recovery handled natively by the kernel's libata driver.
|
||||
- Sustained read speeds are limited only by the drive, not the USB bus.
|
||||
|
||||
If your machine has a free SATA port, use it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Damaged Disc Handling
|
||||
|
||||
### Symptoms
|
||||
|
||||
- SCSI MEDIUM ERROR (sense key 0x03) at specific LBAs. `dmesg` shows `sr X:0:0:0: [srY] Unrecoverable read error` or similar.
|
||||
- Read speed drops to near zero when approaching a damaged area.
|
||||
- The drive makes audible retrying noises (laser repositioning, spindle speed changes).
|
||||
- On USB-connected drives: the bridge crashes (see section 1 above) when the drive returns the error.
|
||||
|
||||
### How freemkv Handles This
|
||||
|
||||
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||
|
||||
- **Pass 1 (Disc::copy):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (Disc::patch):** Targeted re-reads of bad ranges with a long 30-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||
|
||||
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||
|
||||
### The Drive Taint Issue (LG BU40N)
|
||||
|
||||
Some drives, notably the LG BU40N, exhibit a "taint" behavior after encountering MEDIUM ERRORs:
|
||||
|
||||
1. The drive hits a damaged sector and returns a MEDIUM ERROR.
|
||||
2. From that point forward, **all subsequent reads fail** -- even reads to sectors that were previously successful.
|
||||
3. The only recovery is to physically unplug and reconnect the drive (or power-cycle it).
|
||||
|
||||
This is not a freemkv bug. It is a drive firmware behavior triggered by the interaction between the drive's internal error recovery and the USB-SATA bridge's handling of the error response. The drive firmware enters a degraded state that it does not recover from without a power cycle.
|
||||
|
||||
Workarounds:
|
||||
|
||||
- **Use a direct SATA connection.** This eliminates the bridge interaction that triggers the taint.
|
||||
- **Use a different bridge.** The ASM1153 and JMS578 are less likely to trigger this behavior.
|
||||
- **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas.
|
||||
- **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state.
|
||||
|
||||
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware (see the design rationale in [`docs/rip-recovery.md`](docs/rip-recovery.md)).
|
||||
|
||||
---
|
||||
|
||||
## 3. Drive Not Detected
|
||||
|
||||
### Check Hardware Visibility
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Verify the drive appears in the USB device list. If it does not show up, the drive is not visible to the host at all -- check cables, power, and USB port.
|
||||
|
||||
```bash
|
||||
ls /dev/sg*
|
||||
```
|
||||
|
||||
On Linux, optical drives appear as `/dev/sg*` devices (the SCSI Generic interface). freemkv uses `/dev/sg*`, not `/dev/sr*`. If `lsusb` shows the device but no `/dev/sg*` entry exists, the `sg` kernel module may not be loaded:
|
||||
|
||||
```bash
|
||||
sudo modprobe sg
|
||||
```
|
||||
|
||||
### Check Kernel Messages
|
||||
|
||||
```bash
|
||||
dmesg | grep -i usb | tail -30
|
||||
dmesg | grep -i sg | tail -10
|
||||
```
|
||||
|
||||
Look for:
|
||||
- USB enumeration errors or failed port resets.
|
||||
- `sg_add` messages confirming the sg device was registered.
|
||||
- Permission denied or access errors.
|
||||
|
||||
### Permission Issues
|
||||
|
||||
On most Linux distributions, `/dev/sg*` devices are owned by `root:disk` or `root:cdrom` with restricted permissions. Running freemkv as an unprivileged user will fail with permission errors.
|
||||
|
||||
Options:
|
||||
|
||||
- Add your user to the appropriate group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG disk $USER
|
||||
```
|
||||
|
||||
Then log out and back in for the change to take effect. On some distributions the group is `cdrom` or `optical` instead of `disk`.
|
||||
|
||||
- Run with elevated privileges:
|
||||
|
||||
```bash
|
||||
sudo freemkv ...
|
||||
```
|
||||
|
||||
- Install a udev rule for persistent per-device permissions. Create `/etc/udev/rules.d/99-sg-optical.rules`:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="scsi_generic", ATTRS{type}=="5", MODE="0666"
|
||||
```
|
||||
|
||||
Then reload udev rules:
|
||||
|
||||
```bash
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
|
||||
### Spin-Up Delay
|
||||
|
||||
Optical drives take 30-60 seconds to spin up and become ready after hot-plug or disc insertion. During this window, SCSI commands may return NOT READY or timeout.
|
||||
|
||||
freemkv's `Drive::wait_ready()` handles this automatically by polling with TEST UNIT READY until the drive responds. If you are writing your own code using the library, always call `wait_ready()` before `init()`:
|
||||
|
||||
```rust
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // blocks until disc is ready, up to 30s
|
||||
drive.init()?;
|
||||
```
|
||||
|
||||
If the drive was just plugged in, wait a full minute before concluding it is not detected.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Identify Your USB-SATA Bridge
|
||||
|
||||
If you are experiencing the issues described in section 1, you need to know which bridge chipset your adapter or enclosure uses.
|
||||
|
||||
### Step 1: Find the Device
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for entries matching your drive or enclosure. Bridges may appear under their own manufacturer name or as a generic SATA device. Common examples:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation
|
||||
Bus 002 Device 006: ID 174c:1153 ASMedia Technology Inc. ASM1153
|
||||
Bus 002 Device 007: ID 152d:0578 JMicron Technology Corp. JMS578
|
||||
```
|
||||
|
||||
### Step 2: Match the IDs
|
||||
|
||||
| Vendor | Product ID | Chipset | Status |
|
||||
|--------|-----------|---------|--------|
|
||||
| `13fd` | `3609` | Initio INIC-3609 | Affected. Apply quirk. |
|
||||
| `13fd` | `3940` | Initio INIC-3619 | Affected. Apply quirk. |
|
||||
| `13fd` | `0840` | Initio INIC-3069 | Affected. Apply quirk. |
|
||||
| `174c` | `5106` | ASMedia ASM1051 | Affected (early firmware). Apply quirk. |
|
||||
| `174c` | `1153` | ASMedia ASM1153 | Good. No quirk needed. |
|
||||
| `152d` | `0561` | JMicron JMB36x | Affected (some firmware). Apply quirk if issues occur. |
|
||||
| `152d` | `0578` | JMicron JMS578 | Good. No quirk needed. |
|
||||
|
||||
### Step 3: Check dmesg for the Bridge Name
|
||||
|
||||
```bash
|
||||
dmesg | grep -i "usb-storage\|uas\|initio\|asmedia\|jmicron"
|
||||
```
|
||||
|
||||
This often reveals the bridge chipset even when `lsusb` shows a generic name.
|
||||
|
||||
### Step 4: If the Enclosure Is Sealed
|
||||
|
||||
Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not advertise the bridge chipset on the packaging. In this case:
|
||||
|
||||
1. Check `lsusb` while the enclosure is connected.
|
||||
2. Search the vendor:product ID online -- there are community-maintained lists of which chipsets popular enclosures use.
|
||||
3. If you cannot determine the chipset and are experiencing bridge crashes, assume it is an Initio and apply the quirk with its IDs.
|
||||
4. The definitive test: connect the bare drive to a motherboard SATA port. If the problems disappear, the bridge was the cause.
|
||||
|
||||
---
|
||||
|
||||
## 5. General Debugging Checklist
|
||||
|
||||
When something goes wrong during a rip, gather this information before filing an issue:
|
||||
|
||||
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info`.
|
||||
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||
4. **Operating system and kernel:** `uname -a`.
|
||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||
6. **SCSI device:** which `/dev/sg*` the drive was on, and whether it changed after the failure.
|
||||
7. **The disc:** title, format (BD/DVD/UHD), condition.
|
||||
|
||||
Include all of the above in bug reports. SCSI transport errors that resolve with the `US_FL_IGNORE_RESIDUE` quirk or by switching to direct SATA are bridge firmware bugs, not freemkv bugs.
|
||||
@@ -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| {
|
||||
|
||||
@@ -3,5 +3,34 @@ fn main() {
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
let lib = format!("{out_dir}/libmacos_scsi.a");
|
||||
|
||||
std::process::Command::new("cc")
|
||||
.args([
|
||||
"-c",
|
||||
"src/scsi/macos_shim.c",
|
||||
"-o",
|
||||
&obj,
|
||||
"-framework",
|
||||
"IOKit",
|
||||
"-framework",
|
||||
"CoreFoundation",
|
||||
"-Wall",
|
||||
"-O2",
|
||||
])
|
||||
.status()
|
||||
.expect("failed to compile macos_shim.c");
|
||||
|
||||
std::process::Command::new("ar")
|
||||
.args(["rcs", &lib, &obj])
|
||||
.status()
|
||||
.expect("failed to create static lib");
|
||||
|
||||
println!("cargo:rustc-link-search=native={out_dir}");
|
||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Rip Recovery](rip-recovery.md) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
|
||||
+33
-3
@@ -126,15 +126,45 @@ pub struct Event {
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
// Init / scan
|
||||
DriveOpened { device: String },
|
||||
DriveReady,
|
||||
InitComplete { success: bool },
|
||||
ProbeComplete { success: bool },
|
||||
ScanComplete { titles: usize },
|
||||
|
||||
// Read pipeline
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
SectorSkipped { sector: u64 },
|
||||
BatchSizeChanged { new_size: u16, reason: BatchSizeReason },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
|
||||
// Kept for forward-compat; not emitted in 0.13.6+
|
||||
Retry { attempt: u32 },
|
||||
SectorRecovered { sector: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
Emission notes:
|
||||
|
||||
- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents`
|
||||
after each successful sector read. `bytes` is the cumulative running
|
||||
total; `total` is the precomputed extent sum (0 if unknown).
|
||||
- `SpeedChange` is emitted from the public `Drive::set_speed` API path.
|
||||
It is no longer emitted from a recovery hot loop (recovery loop removed
|
||||
in 0.13.6).
|
||||
- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink
|
||||
(read failed at a larger size) and on probe-up (clean-read streak hit
|
||||
the threshold). Consumers use it to display a "recovering" state
|
||||
distinct from "ripping normally".
|
||||
- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were
|
||||
tied to the inline `Drive::read` recovery phases that were removed; the
|
||||
variants are kept for forward compatibility so consumers' match arms
|
||||
don't need conditional compilation.
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||
prints a line. Server logs to file.
|
||||
|
||||
@@ -145,8 +175,8 @@ libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive/ Drive (open, init, read with recovery)
|
||||
│ ├── mod.rs Drive struct, init, read, reset, eject
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||
│ ├── capture.rs Drive profile capture for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
|
||||
@@ -41,7 +41,7 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||
│ ├── drive Drive — open, identify, init, unlock, single-shot read
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
@@ -93,10 +93,17 @@ Drive::open(Path::new("/dev/sg4"))
|
||||
After open:
|
||||
- `init()` -- unlock + firmware upload + speed calibration
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf)` -- single read method with built-in error recovery
|
||||
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (1.5 s vs. 30 s)
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
- `eject()` -- eject tray
|
||||
|
||||
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
||||
(`Disc::patch`) handles bad-range retry by replaying the ddrescue mapfile.
|
||||
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
||||
the stop-wedge postmortem (2026-04-25).
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
|
||||
+4
-2
@@ -77,7 +77,9 @@ Insert disc
|
||||
10. Stream content (mux/disc.rs → DiscStream)
|
||||
│ Read sectors → decrypt → TS demux → PES frames
|
||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||
│ Drive::read() handles error recovery (min speed → reset → retry)
|
||||
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
||||
│ 1 above this — Disc::patch re-runs against the mapfile.
|
||||
│
|
||||
▼
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
@@ -110,7 +112,7 @@ output.finish()?;
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read |
|
||||
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
|
||||
+48
-16
@@ -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 the stop-wedge postmortem (2026-04-25)
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -88,7 +99,6 @@ descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|----------|---------------|--------|
|
||||
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
|
||||
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
|
||||
|
||||
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
|
||||
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
|
||||
@@ -96,9 +106,31 @@ retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
|
||||
background thread) and a fresh fd opened — the kernel's USB error recovery
|
||||
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
|
||||
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
|
||||
The shim handles:
|
||||
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
|
||||
then walks the IOKit registry to find the `IOBDServices` matching the
|
||||
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
|
||||
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
|
||||
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
|
||||
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
|
||||
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
|
||||
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
|
||||
|
||||
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:
|
||||
|
||||
+96
-43
@@ -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,66 +62,107 @@ 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:
|
||||
`Disc::copy()` auto-detects the pass from mapfile state:
|
||||
|
||||
```rust
|
||||
CopyOptions {
|
||||
skip_on_error: true, // zero-fill bad blocks, continue
|
||||
skip_forward: true, // exponential skip-forward after a failure
|
||||
resume: true, // pick up from an existing ISO + mapfile
|
||||
decrypt: false, // keep the ISO a raw disc image
|
||||
..Default::default()
|
||||
decrypt: true, // decrypt AACS/CSS sectors
|
||||
multipass: true, // enable mapfile + skip-on-error + damage-jump
|
||||
progress: Some(&reporter), // progress callback
|
||||
halt: Some(flag), // halt flag for graceful stop
|
||||
}
|
||||
```
|
||||
|
||||
Dispatch logic:
|
||||
- No mapfile → sweep (fresh Pass 1)
|
||||
- Mapfile with NonTried (`?`) → sweep with resume
|
||||
- Mapfile covering full disc, only NonTrimmed/NonScraped/Unreadable → patch
|
||||
- Mapfile clean → no-op
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep
|
||||
### Pass 1 — fast sweep (`Disc::copy` → `sweep_internal`)
|
||||
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
|
||||
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.
|
||||
4. If `skip_forward` is set: after a failure, jump ahead by an exponentially
|
||||
growing amount (256 KB initial, doubling on consecutive failures, capped at
|
||||
1% of disc). The skipped bytes are also marked `*` — `patch` will visit
|
||||
them later.
|
||||
5. Reset the skip size to 256 KB on the first success after a failure.
|
||||
1. Read one ECC block (32 sectors for UHD, 16 for BD/DVD) at the current LBA.
|
||||
2. On success: write data to ISO, mark `+`, advance.
|
||||
3. On failure (with `multipass`): zero-fill, mark `*`, advance.
|
||||
4. Track a sliding window of the last 16 ECC block results. When ≥12% are failures
|
||||
→ **damage-jump**: skip ahead by `256×batch×multiplier` sectors (8 MB base for
|
||||
UHD). Double the multiplier on each jump (8→16→32→64 MB...). Zero-fill the gap as `*`.
|
||||
5. On 16 consecutive good reads: reset jump multiplier to 1, restore max read speed.
|
||||
6. Speed control: damage zone entry → minimum speed, exit → maximum speed.
|
||||
7. Only transport failures (USB bridge crash) abort the pass.
|
||||
|
||||
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
|
||||
bails via the halt flag).
|
||||
Pass 1 completes when every byte has been visited (either `+` or `*`).
|
||||
|
||||
### Pass 2+ — patch
|
||||
### Pass 2+ — patch (`Disc::copy` → `patch_internal`)
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. Default: **reverse** mode
|
||||
(walks ranges from highest LBA to lowest, within each range from end to start).
|
||||
|
||||
1. Issue a drive read with full recovery enabled (SCSI-level retries,
|
||||
ECC recovery, the lot).
|
||||
2. On success: write the good bytes into the ISO at the exact byte offset,
|
||||
mark `+`.
|
||||
3. On failure: mark `-`.
|
||||
4. Update the mapfile after every block — crash-safe resume.
|
||||
1. Issue a single-sector read with 60 s timeout (`recovery=true`). Drive firmware
|
||||
does its own ECC recovery inside that window.
|
||||
2. On success: write the good bytes into the ISO, mark `+`.
|
||||
3. On failure with non-marginal SCSI sense: bail immediately (drive won't produce data).
|
||||
4. On failure with marginal sense: mark `-`, continue.
|
||||
5. Update the mapfile after every block — crash-safe resume.
|
||||
6. Wedged-drive exit: 50 consecutive failures with zero recovery → bail this pass.
|
||||
|
||||
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 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 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 +170,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`).
|
||||
|
||||
+172
-15
@@ -17,11 +17,22 @@
|
||||
//!
|
||||
//! Status chars: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
|
||||
//!
|
||||
//! The mapfile is flushed to disk on every `record()` call so a crashed
|
||||
//! rip loses at most one block of recorded state.
|
||||
//! The mapfile is flushed to disk at most once per `FLUSH_INTERVAL`
|
||||
//! during `record()` calls, plus on explicit `flush()` and on `Drop`.
|
||||
//! This bounds atomic-rename RPC rate on networked staging (e.g. NFS)
|
||||
//! where per-record persists otherwise serialize the rip pipeline.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Minimum interval between mapfile persists. `record()` updates in-memory
|
||||
/// state every call but only writes to disk when this interval has elapsed
|
||||
/// since the last persist (or when `flush()` is called explicitly, or on
|
||||
/// `Drop`). Bounds RPC rate on NFS staging where atomic-rename per record
|
||||
/// otherwise dominates throughput. On crash the worst-case progress loss
|
||||
/// is one interval's worth of records.
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Status of a byte range in the mapfile. ddrescue-compatible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -69,21 +80,56 @@ 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,
|
||||
/// Number of unreadable ranges (for UI display). Computed from
|
||||
/// `ranges_with(&[Unreadable])`.
|
||||
pub num_bad_ranges: u32,
|
||||
/// Largest gap among unreadable ranges in milliseconds. Computed as
|
||||
/// largest range size / bytes_per_sec * 1000. Set by caller (autorip)
|
||||
/// since bytes_per_sec is application-specific.
|
||||
pub main_lost_ms: f64,
|
||||
}
|
||||
|
||||
/// Write-through mapfile. Every `record()` persists to disk immediately
|
||||
/// so a crash during rip loses at most one block.
|
||||
/// Time-batched mapfile. `record()` keeps in-memory state up-to-date on
|
||||
/// every call; persists to disk at most once per `FLUSH_INTERVAL`.
|
||||
/// Explicit `flush()` and `Drop` guarantee state is on disk after a sweep
|
||||
/// or patch finishes. On hard crash the worst-case loss is one flush
|
||||
/// interval of records — the file's payload bytes are unaffected.
|
||||
pub struct Mapfile {
|
||||
path: PathBuf,
|
||||
entries: Vec<MapEntry>,
|
||||
total_size: u64,
|
||||
version: String,
|
||||
/// Incrementally maintained stats — updated on every `record()` call
|
||||
/// so `stats()` is O(1) instead of O(n).
|
||||
stats: MapStats,
|
||||
/// True when in-memory state has changed but `write_to_disk` has not
|
||||
/// yet captured it.
|
||||
dirty: bool,
|
||||
/// Wall-clock timestamp of the last successful `write_to_disk` (or
|
||||
/// the moment the mapfile was constructed, whichever is later).
|
||||
last_flushed: Instant,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -91,7 +137,7 @@ impl Mapfile {
|
||||
/// Writes to disk immediately so a resume can pick up even if the caller
|
||||
/// never records anything.
|
||||
pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
|
||||
let mf = Self {
|
||||
let mut mf = Self {
|
||||
path: path.to_path_buf(),
|
||||
entries: vec![MapEntry {
|
||||
pos: 0,
|
||||
@@ -100,8 +146,19 @@ impl Mapfile {
|
||||
}],
|
||||
total_size,
|
||||
version: version.to_string(),
|
||||
stats: MapStats {
|
||||
bytes_total: total_size,
|
||||
bytes_pending: total_size,
|
||||
bytes_nontried: total_size,
|
||||
..Default::default()
|
||||
},
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
mf.write_to_disk()?;
|
||||
mf.last_flushed = Instant::now();
|
||||
Ok(mf)
|
||||
}
|
||||
|
||||
@@ -161,11 +218,15 @@ impl Mapfile {
|
||||
}
|
||||
entries.sort_by_key(|e| e.pos);
|
||||
let total_size = entries.last().map(|e| e.pos + e.size).unwrap_or(0);
|
||||
let stats = Self::compute_stats(&entries, total_size);
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
entries,
|
||||
total_size,
|
||||
version,
|
||||
stats,
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -228,8 +289,30 @@ impl Mapfile {
|
||||
merged.push(e);
|
||||
}
|
||||
|
||||
// Recompute stats from merged entries. record() is already O(n) due to
|
||||
// drain-and-rebuild, so this is a constant-factor overhead. The critical
|
||||
// win is that stats() is now O(1) — called millions of times in the hot
|
||||
// path during sweep/patch, it just returns the cached value.
|
||||
self.stats = Self::compute_stats(&merged, self.total_size);
|
||||
self.entries = merged;
|
||||
self.write_to_disk()?;
|
||||
self.dirty = true;
|
||||
if self.last_flushed.elapsed() >= FLUSH_INTERVAL {
|
||||
self.write_to_disk()?;
|
||||
self.dirty = false;
|
||||
self.last_flushed = Instant::now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist any pending in-memory changes to disk. No-op if clean.
|
||||
/// Callers (sweep/patch finalisation) invoke this after their last
|
||||
/// `record()` to guarantee state is durable before returning.
|
||||
pub fn flush(&mut self) -> io::Result<()> {
|
||||
if self.dirty {
|
||||
self.write_to_disk()?;
|
||||
self.dirty = false;
|
||||
self.last_flushed = Instant::now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -267,16 +350,25 @@ impl Mapfile {
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> MapStats {
|
||||
self.stats
|
||||
}
|
||||
|
||||
fn compute_stats(entries: &[MapEntry], total_size: u64) -> MapStats {
|
||||
let mut s = MapStats {
|
||||
bytes_total: self.total_size,
|
||||
bytes_total: total_size,
|
||||
..Default::default()
|
||||
};
|
||||
for e in &self.entries {
|
||||
for e in entries {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,11 +387,7 @@ impl Mapfile {
|
||||
{
|
||||
let file = std::fs::File::create(&tmp)?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(
|
||||
w,
|
||||
"# Rescue Logfile. Created by libfreemkv v{}",
|
||||
self.version
|
||||
)?;
|
||||
writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
writeln!(w, "0x000000000 ? 1 0")?;
|
||||
writeln!(w, "# pos size status")?;
|
||||
@@ -319,6 +407,16 @@ impl Mapfile {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Mapfile {
|
||||
/// Best-effort flush on drop so a sweep / patch that returns early
|
||||
/// (or unwinds) doesn't lose its in-memory state. Errors here are
|
||||
/// swallowed because Drop has no way to surface them; explicit
|
||||
/// `flush()` on the success path gives callers proper error handling.
|
||||
fn drop(&mut self) {
|
||||
let _ = self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -419,6 +517,8 @@ mod tests {
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
// record() batches; explicit flush before reading back from disk.
|
||||
mf.flush().unwrap();
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
@@ -450,4 +550,61 @@ mod tests {
|
||||
assert_eq!(bad, vec![(100, 50), (300, 50)]);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_overlapping_records() {
|
||||
let p = tmpfile("stats_consistent_after_overlapping");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
// Record some finished, some unreadable, some nontrimmed
|
||||
mf.record(0, 300, SectorStatus::Finished).unwrap();
|
||||
mf.record(300, 200, SectorStatus::NonTrimmed).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(600, 400, SectorStatus::Finished).unwrap();
|
||||
|
||||
// Final entries: [0..300 Finished, 300..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished]
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_good, 700); // 300 + 400
|
||||
assert_eq!(s.bytes_unreadable, 100); // 100
|
||||
assert_eq!(s.bytes_pending, 200); // NonTrimmed only (NonTried=0)
|
||||
assert_eq!(s.bytes_nontried, 0);
|
||||
assert_eq!(s.bytes_retryable, 200); // NonTrimmed
|
||||
assert_eq!(s.bytes_total, 1000);
|
||||
|
||||
// Overwrite a NonTrimmed range with Finished
|
||||
mf.record(300, 100, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..400 Finished, 400..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished]
|
||||
let s2 = mf.stats();
|
||||
assert_eq!(s2.bytes_good, 800); // 400 + 400
|
||||
assert_eq!(s2.bytes_unreadable, 100);
|
||||
assert_eq!(s2.bytes_pending, 100); // NonTrimmed only
|
||||
assert_eq!(s2.bytes_retryable, 100);
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_split_record() {
|
||||
let p = tmpfile("stats_consistent_after_split");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
// Mark middle as NonTrimmed
|
||||
mf.record(200, 400, SectorStatus::NonTrimmed).unwrap();
|
||||
// Entries: [0..200 NonTried, 200..600 NonTrimmed, 600..1000 NonTried]
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_pending, 1000); // NonTried(600) + NonTrimmed(400)
|
||||
assert_eq!(s.bytes_retryable, 400); // NonTrimmed only
|
||||
assert_eq!(s.bytes_nontried, 600); // 200 + 400
|
||||
|
||||
// Overwrite the NonTrimmed with Finished (splitting the remaining NonTried)
|
||||
mf.record(200, 400, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..200 NonTried, 200..600 Finished, 600..1000 NonTried]
|
||||
let s2 = mf.stats();
|
||||
assert_eq!(s2.bytes_good, 400);
|
||||
assert_eq!(s2.bytes_pending, 600); // NonTried(200 + 400)
|
||||
assert_eq!(s2.bytes_nontried, 600);
|
||||
assert_eq!(s2.bytes_retryable, 0);
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
|
||||
+1925
-178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
//! Single source of truth for what to do when a sector read fails.
|
||||
//!
|
||||
//! Both Pass 1 (`Disc::sweep`) and Pass 2-N (`Disc::patch`) call into
|
||||
//! `handle_read_error` after every failed `read_sectors`. The handler
|
||||
//! classifies the error, updates the in-flight context (counters,
|
||||
//! damage window, retry budgets), and returns a `ReadAction` the caller
|
||||
//! dispatches on. Every read goes through the same gate — no path can
|
||||
//! silently skip pause/skip/jump/abort logic.
|
||||
//!
|
||||
//! Adding a new error class = add one arm in `handle_read_error`.
|
||||
//! Adding new logging on errors = one place.
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::scsi;
|
||||
|
||||
/// In-flight bookkeeping a read loop must keep across iterations. The
|
||||
/// handler reads and mutates this. Caller owns the storage.
|
||||
pub struct ReadCtx {
|
||||
/// Number of sectors per read attempt. The handler uses this to
|
||||
/// decide whether to bisect (only worthwhile when batch > 1).
|
||||
pub batch: u16,
|
||||
/// Successful reads since the last failure. Resets to 0 on failure.
|
||||
/// Used by callers to drive damage-zone exit / speed restoration.
|
||||
pub consecutive_good: u64,
|
||||
/// Failed reads since the last success. Resets to 0 on success.
|
||||
/// Drives long-pause escalation on persistent failure.
|
||||
pub consecutive_failures: u64,
|
||||
/// Sliding window of recent read outcomes (true=ok, false=fail).
|
||||
/// Capped at `damage_window_max`. Drives damage-jump decisions.
|
||||
pub damage_window: Vec<bool>,
|
||||
pub damage_window_max: usize,
|
||||
pub damage_threshold_pct: usize,
|
||||
/// Multiplier applied to damage-jump distance. Doubles each jump,
|
||||
/// resets to 1 after `damage_window_max` consecutive good reads.
|
||||
pub jump_multiplier: u64,
|
||||
/// NOT_READY retries used so far for the current LBA. Reset to 0
|
||||
/// on any non-NOT_READY response.
|
||||
pub not_ready_retries: u32,
|
||||
/// Bridge-degradation cooldowns used so far.
|
||||
pub bridge_degradation_count: u32,
|
||||
/// Whether we're currently inside a damage-jump bisect attempt.
|
||||
/// Caller sets this true when entering single-sector mode for a
|
||||
/// failed batch, so the handler doesn't recursively request another
|
||||
/// bisect on the inner-sector failures.
|
||||
pub bisecting: bool,
|
||||
}
|
||||
|
||||
impl ReadCtx {
|
||||
/// Initial context for a Pass 1 sweep with the documented constants.
|
||||
pub fn for_sweep(batch: u16) -> Self {
|
||||
Self {
|
||||
batch,
|
||||
consecutive_good: 0,
|
||||
consecutive_failures: 0,
|
||||
damage_window: Vec::with_capacity(16),
|
||||
damage_window_max: 16,
|
||||
damage_threshold_pct: 12,
|
||||
jump_multiplier: 1,
|
||||
not_ready_retries: 0,
|
||||
bridge_degradation_count: 0,
|
||||
bisecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial context for a Pass 2-N patch.
|
||||
pub fn for_patch(batch: u16) -> Self {
|
||||
Self {
|
||||
batch,
|
||||
consecutive_good: 0,
|
||||
consecutive_failures: 0,
|
||||
damage_window: Vec::with_capacity(16),
|
||||
damage_window_max: 16,
|
||||
damage_threshold_pct: 12,
|
||||
jump_multiplier: 1,
|
||||
not_ready_retries: 0,
|
||||
bridge_degradation_count: 0,
|
||||
bisecting: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller calls this after every successful read.
|
||||
pub fn on_success(&mut self) {
|
||||
self.consecutive_good += 1;
|
||||
self.consecutive_failures = 0;
|
||||
self.not_ready_retries = 0;
|
||||
self.damage_window.push(true);
|
||||
if self.damage_window.len() > self.damage_window_max {
|
||||
self.damage_window.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller should do after a read failure. The caller owns the
|
||||
/// I/O side-effects (sleep, write zeros, advance pos) — the handler
|
||||
/// only decides which side-effects.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReadAction {
|
||||
/// Pause `pause_secs` then retry the same LBA / batch. Used for
|
||||
/// transient conditions (NOT_READY, bridge degradation) that the
|
||||
/// drive may recover from on its own.
|
||||
Retry { pause_secs: u64 },
|
||||
/// Re-issue the failed batch as `batch` single-sector reads. Each
|
||||
/// inner read is itself dispatched through `handle_read_error` with
|
||||
/// `bisecting = true` so it cannot recurse.
|
||||
Bisect,
|
||||
/// Mark the failed range NonTrimmed (zero-fill, retry in Pass N+),
|
||||
/// then pause `pause_secs` before resuming the next LBA.
|
||||
SkipBlock { pause_secs: u64 },
|
||||
/// Mark the failed range NonTrimmed AND advance position by
|
||||
/// `sectors` (zero-filling the gap as NonTrimmed). Then pause
|
||||
/// `pause_secs`. Used when the damage-window threshold is crossed.
|
||||
JumpAhead { sectors: u64, pause_secs: u64 },
|
||||
/// Unrecoverable at this layer. Caller propagates `Err` up to the
|
||||
/// outer pass loop / autorip, which can attempt USB re-enumeration,
|
||||
/// drop session, etc.
|
||||
AbortPass,
|
||||
}
|
||||
|
||||
// Pause budget constants. Tuned from 2026-05-07 BU40N traces showing
|
||||
// bridge wedges 524 ms after a 5.4-second internal ECC retry. The
|
||||
// post-failure pauses give the drive — and the bridge — time to settle.
|
||||
const POST_FAILURE_PAUSE_SECS: u64 = 1;
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_SECS: u64 = 5;
|
||||
const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10;
|
||||
const POST_JUMP_EXTRA_PAUSE_SECS: u64 = 2;
|
||||
const NOT_READY_PAUSE_SECS: u64 = 3;
|
||||
const NOT_READY_MAX_RETRIES: u32 = 3;
|
||||
const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 15;
|
||||
const BRIDGE_DEGRADATION_MAX_RETRIES: u32 = 5;
|
||||
|
||||
/// THE single error-handling entry point. Updates `ctx`, returns the
|
||||
/// action the caller must apply.
|
||||
///
|
||||
/// New error class = add a new arm here. New logging on errors = add
|
||||
/// it once at the top. New retry policy = adjust the constants. No
|
||||
/// other read site needs to change.
|
||||
pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction {
|
||||
ctx.consecutive_failures += 1;
|
||||
ctx.consecutive_good = 0;
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_error",
|
||||
consecutive_failures = ctx.consecutive_failures,
|
||||
batch = ctx.batch,
|
||||
bisecting = ctx.bisecting,
|
||||
sense_key = err.scsi_sense().map(|s| s.sense_key),
|
||||
asc = err.scsi_sense().map(|s| s.asc),
|
||||
ascq = err.scsi_sense().map(|s| s.ascq),
|
||||
error = %err,
|
||||
"read failed; classifying"
|
||||
);
|
||||
|
||||
// 1. Transport failure: bridge crash / USB disconnect. The outer
|
||||
// pass loop knows how to handle this (rediscover sg path,
|
||||
// re-open drive). Inline single-sector retry here was tried in
|
||||
// pre-v0.17.0 builds and observed to make wedges worse.
|
||||
if err.is_scsi_transport_failure() {
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
|
||||
// 2. Bridge degradation: NOT_READY with the well-known signature
|
||||
// (sense_key=2, ASC=0x04, ASCQ=0x3E). Drive's bridge is in a
|
||||
// semi-stuck state but typically recovers after a long cooldown.
|
||||
// If we've exhausted our retry budget, fall through to the
|
||||
// marginal/skip path below.
|
||||
if err.is_bridge_degradation() && ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES
|
||||
{
|
||||
ctx.bridge_degradation_count += 1;
|
||||
return ReadAction::Retry {
|
||||
pause_secs: BRIDGE_DEGRADATION_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
let sense_key = err.scsi_sense().map(|s| s.sense_key).unwrap_or(0);
|
||||
|
||||
// 3. Generic NOT_READY (other ASC codes): drive's mechanical
|
||||
// pickup may be moving. Pause and retry briefly.
|
||||
if sense_key == scsi::SENSE_KEY_NOT_READY && ctx.not_ready_retries < NOT_READY_MAX_RETRIES {
|
||||
ctx.not_ready_retries += 1;
|
||||
return ReadAction::Retry {
|
||||
pause_secs: NOT_READY_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
if sense_key != scsi::SENSE_KEY_NOT_READY {
|
||||
ctx.not_ready_retries = 0;
|
||||
}
|
||||
|
||||
// 4. Hardware / illegal request: the drive said "no, won't do it".
|
||||
// Retrying won't change the answer. Surface to the outer layer
|
||||
// so it can eject + prompt user.
|
||||
if sense_key == scsi::SENSE_KEY_HARDWARE_ERROR || sense_key == scsi::SENSE_KEY_ILLEGAL_REQUEST {
|
||||
return ReadAction::AbortPass;
|
||||
}
|
||||
|
||||
// 5. Marginal media (MEDIUM_ERROR / ABORTED_COMMAND) on a multi-
|
||||
// sector batch: the drive can often read the same sectors
|
||||
// individually. Bisect into single-sector reads (gentler on the
|
||||
// bridge too — shorter SCSI transactions). Avoid recursive
|
||||
// bisect.
|
||||
let is_marginal = matches!(
|
||||
sense_key,
|
||||
scsi::SENSE_KEY_MEDIUM_ERROR | scsi::SENSE_KEY_ABORTED_COMMAND
|
||||
);
|
||||
if is_marginal && ctx.batch > 1 && !ctx.bisecting {
|
||||
return ReadAction::Bisect;
|
||||
}
|
||||
|
||||
// 6. Single-sector failure or unbisectable error — record in
|
||||
// damage window, decide between skip-in-place vs damage-jump.
|
||||
//
|
||||
// SKIP damage-window updates while bisecting: the window
|
||||
// represents per-batch outcomes, not per-sector. Updating it
|
||||
// inside a bisect inner loop (potentially 32+ sector failures
|
||||
// per batch) would over-weight the window and cause runaway
|
||||
// JumpAhead distance via excessive multiplier doublings.
|
||||
if !ctx.bisecting {
|
||||
ctx.damage_window.push(false);
|
||||
if ctx.damage_window.len() > ctx.damage_window_max {
|
||||
ctx.damage_window.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
let bad_count = ctx.damage_window.iter().filter(|&&b| !b).count();
|
||||
let bad_pct = if ctx.damage_window.is_empty() {
|
||||
0
|
||||
} else {
|
||||
bad_count * 100 / ctx.damage_window.len()
|
||||
};
|
||||
|
||||
let pause_secs = if ctx.consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD {
|
||||
CONSECUTIVE_FAIL_LONG_PAUSE_SECS
|
||||
} else {
|
||||
POST_FAILURE_PAUSE_SECS
|
||||
};
|
||||
|
||||
// 7. Damage-jump: too many failures in window → skip ahead by an
|
||||
// escalating gap. Multiplier capped so we can't accidentally
|
||||
// skip the entire rest of the disc (observed 2026-05-07: a
|
||||
// saturated multiplier produced a 56 GB jump). Saturating
|
||||
// arithmetic on the sector calc as defence in depth.
|
||||
const MAX_JUMP_MULTIPLIER: u64 = 64; // 64 × 256 × batch sectors
|
||||
if ctx.damage_window.len() >= ctx.damage_window_max && bad_pct >= ctx.damage_threshold_pct {
|
||||
let mult = ctx.jump_multiplier.min(MAX_JUMP_MULTIPLIER);
|
||||
let sectors = 256u64.saturating_mul(ctx.batch as u64).saturating_mul(mult);
|
||||
ctx.jump_multiplier = (ctx.jump_multiplier.saturating_mul(2)).min(MAX_JUMP_MULTIPLIER);
|
||||
return ReadAction::JumpAhead {
|
||||
sectors,
|
||||
pause_secs: pause_secs + POST_JUMP_EXTRA_PAUSE_SECS,
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Default: zero-fill the failed batch as NonTrimmed and pause
|
||||
// before the next read.
|
||||
ReadAction::SkipBlock { pause_secs }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::scsi::ScsiSense;
|
||||
|
||||
fn medium_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(2),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
||||
asc: 0x11,
|
||||
ascq: 0x05,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn hardware_err() -> Error {
|
||||
Error::DiscRead {
|
||||
sector: 100,
|
||||
status: Some(2),
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: scsi::SENSE_KEY_HARDWARE_ERROR,
|
||||
asc: 0x44,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_error_with_batch_gt_1_bisects() {
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
assert_eq!(action, ReadAction::Bisect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_error_with_batch_1_skips() {
|
||||
let mut ctx = ReadCtx::for_sweep(1);
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { pause_secs } => assert!(pause_secs >= 1),
|
||||
other => panic!("expected SkipBlock, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_error_while_bisecting_does_not_recurse() {
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
ctx.bisecting = true;
|
||||
let action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match action {
|
||||
ReadAction::SkipBlock { .. } => {}
|
||||
other => panic!("expected SkipBlock, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_error_aborts() {
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
let action = handle_read_error(&hardware_err(), &mut ctx);
|
||||
assert_eq!(action, ReadAction::AbortPass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_failure_streak_extends_pause() {
|
||||
let mut ctx = ReadCtx::for_sweep(1);
|
||||
for _ in 0..15 {
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
}
|
||||
// After many consecutive failures we should be in the long-pause regime
|
||||
let final_action = handle_read_error(&medium_err(), &mut ctx);
|
||||
match final_action {
|
||||
ReadAction::SkipBlock { pause_secs } => {
|
||||
assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
|
||||
}
|
||||
ReadAction::JumpAhead { pause_secs, .. } => {
|
||||
assert!(pause_secs >= CONSECUTIVE_FAIL_LONG_PAUSE_SECS);
|
||||
}
|
||||
other => panic!("expected long-pause action, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_window_fills_then_jumps() {
|
||||
let mut ctx = ReadCtx::for_sweep(1);
|
||||
ctx.damage_window_max = 4;
|
||||
ctx.damage_threshold_pct = 50;
|
||||
let mut saw_jump = false;
|
||||
for _ in 0..6 {
|
||||
let a = handle_read_error(&medium_err(), &mut ctx);
|
||||
if matches!(a, ReadAction::JumpAhead { .. }) {
|
||||
saw_jump = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_jump,
|
||||
"expected at least one JumpAhead in 6 failures with 50% threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_success_resets_failure_counters_and_pushes_window() {
|
||||
let mut ctx = ReadCtx::for_sweep(32);
|
||||
for _ in 0..3 {
|
||||
handle_read_error(&medium_err(), &mut ctx);
|
||||
}
|
||||
assert!(ctx.consecutive_failures > 0);
|
||||
ctx.bisecting = false;
|
||||
ctx.on_success();
|
||||
assert_eq!(ctx.consecutive_good, 1);
|
||||
assert_eq!(ctx.consecutive_failures, 0);
|
||||
assert_eq!(*ctx.damage_window.last().unwrap(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Producer / consumer split for `Disc::sweep`.
|
||||
//!
|
||||
//! Background: the original sweep loop runs strictly serialised —
|
||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
||||
//! during the post-read work; throughput tops out at the *sum* of
|
||||
//! both costs.
|
||||
//!
|
||||
//! This module decouples them. A consumer thread owns the
|
||||
//! [`crate::io::Writer`] (the ISO file) and the
|
||||
//! [`super::mapfile::Mapfile`]. The producer thread (the caller of
|
||||
//! `Disc::sweep`) keeps the [`crate::sector::SectorReader`], the
|
||||
//! [`super::read_error`] state machine, and decrypt — so what enters
|
||||
//! the channel is already-clean cleartext bytes, matching the "disc
|
||||
//! hands plaintext to its consumers" semantic that
|
||||
//! [`crate::mux::DiscStream`] already follows. Producer and consumer
|
||||
//! run concurrently; with a healthy disc the drive can read the next
|
||||
//! batch while the previous one is being written and recorded.
|
||||
//!
|
||||
//! Correctness invariants preserved:
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
//! owns the `SectorReader`). No new SCSI concurrency.
|
||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||
//! intact in the consumer (write before record, same as today), so
|
||||
//! the on-disk invariant "mapfile only marks Finished what the file
|
||||
//! has received" survives a crash mid-pass.
|
||||
//! - The BU40N+Initio bridge wedge concern is unchanged: only one
|
||||
//! SCSI command in flight at a time, error-path timing identical,
|
||||
//! no new retry logic.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||
|
||||
/// Channel depth for in-flight work items. 4 is enough to absorb a
|
||||
/// mapfile-flush burst on the consumer without growing memory
|
||||
/// unboundedly. Producer back-pressure is the natural rate limiter:
|
||||
/// `SyncSender::send` blocks when the channel is full.
|
||||
const CHANNEL_DEPTH: usize = 4;
|
||||
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||
/// matches the existing zero_gap chunk size used by the pre-split
|
||||
/// sweep loop.
|
||||
const ZERO_CHUNK: usize = 65 * 1024;
|
||||
|
||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||
/// order; ordering of file writes and mapfile records across items is
|
||||
/// preserved.
|
||||
pub(super) enum WorkItem {
|
||||
/// Successful batch read. Producer has already decrypted `buf` if
|
||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
||||
/// records the range as `Finished`.
|
||||
Good { pos: u64, buf: Vec<u8> },
|
||||
|
||||
/// Bisect inner-loop good single sector (already decrypted by the
|
||||
/// producer). 2048 bytes.
|
||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
||||
|
||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
||||
BisectBad { pos: u64 },
|
||||
|
||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
||||
SkipFill { pos: u64, len: u64 },
|
||||
|
||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
||||
/// distinguished only so future logging / instrumentation can
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
/// drained the previous snapshot, the new one is silently
|
||||
/// dropped — the producer's local cache stays current enough.
|
||||
StatsRequest,
|
||||
|
||||
/// Producer is done. Consumer drains, runs `sync_all` on the
|
||||
/// file, and exits. Final stats are returned via the
|
||||
/// `JoinHandle<ConsumerSummary>` from `spawn_consumer`.
|
||||
Finish,
|
||||
}
|
||||
|
||||
/// Snapshot the consumer sends back to the producer for the progress
|
||||
/// callback.
|
||||
pub(super) struct ProgressSnapshot {
|
||||
pub stats: MapStats,
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Final summary returned by the consumer thread on shutdown. The
|
||||
/// producer reads it via the `JoinHandle` returned from
|
||||
/// [`spawn_consumer`].
|
||||
pub(super) struct ConsumerSummary {
|
||||
pub stats: MapStats,
|
||||
/// First mapfile/write error the consumer hit, if any. The
|
||||
/// producer treats this as fatal on the way back up.
|
||||
pub error: Option<Error>,
|
||||
}
|
||||
|
||||
/// Owned bundle the consumer thread takes ownership of. Decrypt
|
||||
/// happens on the producer side before send, so the consumer never
|
||||
/// sees keys.
|
||||
pub(super) struct ConsumerInputs {
|
||||
pub file: crate::io::Writer,
|
||||
pub map: Mapfile,
|
||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
||||
/// a real error.
|
||||
pub is_regular: bool,
|
||||
}
|
||||
|
||||
/// Spawn the consumer thread. The producer keeps the work-tx and
|
||||
/// prog-rx; the join handle yields the final summary on `Finish` (or
|
||||
/// on channel close).
|
||||
pub(super) fn spawn_consumer(
|
||||
inputs: ConsumerInputs,
|
||||
) -> (
|
||||
SyncSender<WorkItem>,
|
||||
Receiver<ProgressSnapshot>,
|
||||
JoinHandle<ConsumerSummary>,
|
||||
) {
|
||||
let (work_tx, work_rx) = sync_channel::<WorkItem>(CHANNEL_DEPTH);
|
||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
||||
|
||||
let handle = thread::Builder::new()
|
||||
.name("freemkv-sweep-consumer".into())
|
||||
.spawn(move || consumer_loop(inputs, work_rx, prog_tx))
|
||||
.expect("spawning sweep consumer thread should not fail");
|
||||
|
||||
(work_tx, prog_rx, handle)
|
||||
}
|
||||
|
||||
/// Send a work item, translating a `SendError` (consumer thread died
|
||||
/// / panicked) into a useful library error so the caller can
|
||||
/// propagate cleanly.
|
||||
pub(super) fn send_or_abort(tx: &SyncSender<WorkItem>, item: WorkItem) -> Result<()> {
|
||||
tx.send(item).map_err(|_| Error::IoError {
|
||||
source: std::io::Error::other("sweep consumer terminated unexpectedly"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort `StatsRequest` send. If the channel is full, skip —
|
||||
/// the producer's cached snapshot is fine for one more iteration.
|
||||
pub(super) fn try_request_stats(tx: &SyncSender<WorkItem>) {
|
||||
if let Err(TrySendError::Full(_)) = tx.try_send(WorkItem::StatsRequest) {
|
||||
// expected when the consumer is busy; cached snapshot is
|
||||
// still recent enough.
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any pending progress snapshots from the consumer. Returns
|
||||
/// the most recent one, if any. The producer caches it and uses it
|
||||
/// for subsequent progress callbacks until a fresh one arrives.
|
||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
||||
let mut latest = None;
|
||||
while let Ok(snap) = rx.try_recv() {
|
||||
latest = Some(snap);
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
fn consumer_loop(
|
||||
mut inputs: ConsumerInputs,
|
||||
work_rx: Receiver<WorkItem>,
|
||||
prog_tx: SyncSender<ProgressSnapshot>,
|
||||
) -> ConsumerSummary {
|
||||
let zero = [0u8; ZERO_CHUNK];
|
||||
let mut first_error: Option<Error> = None;
|
||||
|
||||
// Channel closed without a Finish == treat as Finish (producer
|
||||
// dropped tx without explicit teardown — should not happen in
|
||||
// normal operation but be defensive).
|
||||
while let Ok(item) = work_rx.recv() {
|
||||
// Once we have an error, drain remaining items without
|
||||
// applying side-effects so the producer never blocks on a
|
||||
// dead consumer. Loop until Finish or channel close.
|
||||
if first_error.is_some() {
|
||||
if matches!(item, WorkItem::Finish) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match apply_item(&mut inputs, item, &zero, &prog_tx) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => break, // Finish received
|
||||
Err(e) => first_error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush — drain the writeback pipeline + fsync the ISO,
|
||||
// then persist any pending mapfile state.
|
||||
if first_error.is_none() {
|
||||
if let Err(e) = inputs.file.sync_all() {
|
||||
if inputs.is_regular {
|
||||
first_error = Some(Error::IoError { source: e });
|
||||
}
|
||||
// Non-regular outputs (/dev/null, pipes) always fail
|
||||
// sync_all; that's not a real error.
|
||||
}
|
||||
if let Err(e) = inputs.map.flush() {
|
||||
first_error = Some(Error::IoError { source: e });
|
||||
}
|
||||
}
|
||||
|
||||
ConsumerSummary {
|
||||
stats: inputs.map.stats(),
|
||||
error: first_error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a single `WorkItem`. Returns `Ok(true)` to continue, `Ok(false)`
|
||||
/// to break the consumer loop on `Finish`, `Err(_)` on first failure
|
||||
/// (caller captures and continues draining).
|
||||
fn apply_item(
|
||||
inputs: &mut ConsumerInputs,
|
||||
item: WorkItem,
|
||||
zero: &[u8; ZERO_CHUNK],
|
||||
prog_tx: &SyncSender<ProgressSnapshot>,
|
||||
) -> Result<bool> {
|
||||
match item {
|
||||
WorkItem::Good { pos, buf } => {
|
||||
// Decrypt is on the producer; consumer assumes plaintext.
|
||||
let len = buf.len() as u64;
|
||||
inputs
|
||||
.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.file
|
||||
.write_all(&buf)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.map
|
||||
.record(pos, len, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::BisectGood { pos, buf } => {
|
||||
inputs
|
||||
.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.file
|
||||
.write_all(&buf[..])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.map
|
||||
.record(pos, 2048, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::BisectBad { pos } => {
|
||||
inputs
|
||||
.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.file
|
||||
.write_all(&zero[..2048])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
inputs
|
||||
.map
|
||||
.record(pos, 2048, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||
inputs
|
||||
.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
// Subsequent writes are sequential; `crate::io::Writer`'s
|
||||
// seek-elision keeps them on the writeback pipeline path.
|
||||
let mut filled = 0u64;
|
||||
while filled < len {
|
||||
let chunk = (len - filled).min(zero.len() as u64) as usize;
|
||||
inputs
|
||||
.file
|
||||
.write_all(&zero[..chunk])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
filled += chunk as u64;
|
||||
}
|
||||
inputs
|
||||
.map
|
||||
.record(pos, len, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = inputs.map.stats();
|
||||
let bad_ranges = inputs.map.ranges_with(&[
|
||||
SectorStatus::NonTrimmed,
|
||||
SectorStatus::Unreadable,
|
||||
SectorStatus::NonScraped,
|
||||
SectorStatus::NonTried,
|
||||
]);
|
||||
// Best-effort: drop on backpressure; producer's cache
|
||||
// stays current enough.
|
||||
let _ = prog_tx.try_send(ProgressSnapshot { stats, bad_ranges });
|
||||
}
|
||||
WorkItem::Finish => return Ok(false),
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
+10
-11
@@ -1,26 +1,25 @@
|
||||
//! macOS drive discovery and device resolution.
|
||||
//!
|
||||
//! `find_drives` uses IOKit registry enumeration (via `scsi::list_drives`)
|
||||
//! to discover optical drives without exclusive access or unmounts. Only
|
||||
//! the returned paths are then opened for INQUIRY to build full `DriveId`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DriveId;
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/disk{}", i);
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
match crate::scsi::open(std::path::Path::new(&path)) {
|
||||
let discovered = crate::scsi::list_drives();
|
||||
for info in discovered {
|
||||
let path = std::path::Path::new(&info.path);
|
||||
match crate::scsi::open(path) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Device exists but can't be opened (likely mounted).
|
||||
// Use `diskutil unmountDisk /dev/diskN` to unmount before accessing.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+206
-229
@@ -1,11 +1,15 @@
|
||||
//! 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),
|
||||
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
||||
_ => (0, None),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod capture;
|
||||
|
||||
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
|
||||
@@ -53,9 +57,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,12 +65,20 @@ 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>>,
|
||||
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
|
||||
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
|
||||
/// an error. The kernel `sr_mod` driver auto-retries failed reads
|
||||
/// (~5× per command) — historically the reason `dd if=/dev/sr0`
|
||||
/// recovers ~50% of bad sectors that single-shot `SG_IO` READ
|
||||
/// misses on the same drive. `None` when the block device couldn't
|
||||
/// be resolved or opened (no fallback in that case; SCSI read
|
||||
/// errors propagate as before).
|
||||
#[cfg(target_os = "linux")]
|
||||
block_dev_fd: Option<std::os::unix::io::RawFd>,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
@@ -88,6 +97,9 @@ impl Drive {
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let block_dev_fd = open_block_device_for_sg(device);
|
||||
|
||||
Ok(Drive {
|
||||
scsi: transport,
|
||||
driver,
|
||||
@@ -95,9 +107,10 @@ 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,
|
||||
#[cfg(target_os = "linux")]
|
||||
block_dev_fd,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,6 +134,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 +147,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 +179,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 +191,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 +255,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,29 +432,41 @@ 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
|
||||
/// the stop-wedge postmortem (2026-04-25),
|
||||
/// 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
|
||||
};
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
recovery,
|
||||
timeout_ms,
|
||||
"Drive::read enter"
|
||||
);
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
@@ -555,82 +480,79 @@ 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);
|
||||
Ok(result) => Ok(result.bytes_transferred),
|
||||
Err(Error::Halted) => Err(Error::Halted),
|
||||
Err(e) => {
|
||||
let (status, sense) = extract_scsi_context(&e);
|
||||
tracing::warn!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
inner_error = %e,
|
||||
scsi_status = status,
|
||||
"Drive::read checked_exec failed"
|
||||
);
|
||||
|
||||
// /dev/sr0 pread fallback (Linux only). The kernel
|
||||
// sr_mod driver auto-retries failed reads (~5× per
|
||||
// command). Empirically (BU40N + Dune Part 2 UHD,
|
||||
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
|
||||
// sectors that a single-shot SG_IO READ misses.
|
||||
#[cfg(target_os = "linux")]
|
||||
if recovery {
|
||||
if let Some(fd) = self.block_dev_fd {
|
||||
let len = count as usize * 2048;
|
||||
if buf.len() >= len {
|
||||
let offset = lba as i64 * 2048;
|
||||
// Drop kernel cache for this region so we get
|
||||
// a fresh device read, not stale page-cache
|
||||
// data from a prior successful neighbour read.
|
||||
let _ = unsafe {
|
||||
libc::posix_fadvise(
|
||||
fd,
|
||||
offset,
|
||||
len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
)
|
||||
};
|
||||
let n = unsafe {
|
||||
libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset)
|
||||
};
|
||||
if n == len as isize {
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
bytes = len,
|
||||
"Drive::read recovered via /dev/sr0 pread fallback"
|
||||
);
|
||||
return Ok(len);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
pread_ret = n as i64,
|
||||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||||
"/dev/sr0 pread fallback also failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
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).
|
||||
@@ -727,6 +649,61 @@ impl Drop for Drive {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(fd) = self.block_dev_fd.take() {
|
||||
unsafe { libc::close(fd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `/dev/sg*` path to the corresponding `/dev/sr*` block
|
||||
/// device by walking sysfs, then open it for read (no `O_DIRECT` —
|
||||
/// `posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each
|
||||
/// pread, which avoids buffer-alignment requirements while still
|
||||
/// forcing fresh device reads).
|
||||
///
|
||||
/// Returns `None` on any error (sysfs not present, no matching block
|
||||
/// device, open failed). Callers treat that as "no fallback available"
|
||||
/// and propagate the original SCSI READ error.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> {
|
||||
let basename = sg_path.file_name()?.to_str()?;
|
||||
if !basename.starts_with("sg") {
|
||||
return None;
|
||||
}
|
||||
let sysfs_dir = format!("/sys/class/scsi_generic/{}/device/block", basename);
|
||||
let entries = std::fs::read_dir(&sysfs_dir).ok()?;
|
||||
let block_name = entries
|
||||
.flatten()
|
||||
.find_map(|e| e.file_name().into_string().ok())?;
|
||||
let block_path = format!("/dev/{}", block_name);
|
||||
|
||||
let mut bytes = block_path.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
bytes.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDONLY | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
sg = basename,
|
||||
block_path,
|
||||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||||
"Failed to open block device for fallback; sr0 fallback disabled"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
sg = basename,
|
||||
block_path,
|
||||
fd,
|
||||
"Opened /dev/sr* as recovery fallback for failed SCSI reads"
|
||||
);
|
||||
Some(fd)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,13 +717,28 @@ impl SectorReader for Drive {
|
||||
) -> Result<usize> {
|
||||
self.read(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
Drive::set_speed(self, kbs);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +755,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")]
|
||||
|
||||
+126
-9
@@ -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,80 @@ 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),
|
||||
Error::DiscRead { 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,
|
||||
..
|
||||
}
|
||||
) || matches!(
|
||||
self,
|
||||
Error::DiscRead {
|
||||
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// True if this error indicates bridge degradation — the SCSI status
|
||||
/// is neither GOOD (0x00), CHECK CONDITION (0x02), nor transport failure
|
||||
/// (0xFF). Observed on the Initio INIC-1618L USB bridge preceding a full
|
||||
/// crash: the bridge firmware returns non-standard status bytes (e.g.
|
||||
/// 0x04, 0x05) with empty sense data. The caller should cool down
|
||||
/// (10 s pause) and retry rather than hammering the bridge.
|
||||
pub fn is_bridge_degradation(&self) -> bool {
|
||||
let status = match self {
|
||||
Error::ScsiError { status, .. } => *status,
|
||||
Error::DiscRead { status, .. } => status.unwrap_or(0),
|
||||
_ => return false,
|
||||
};
|
||||
status != crate::scsi::SCSI_STATUS_GOOD
|
||||
&& status != crate::scsi::SCSI_STATUS_CHECK_CONDITION
|
||||
&& 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, 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! File I/O helpers that bound kernel cache pressure on big writes.
|
||||
//!
|
||||
//! `Writer` is a drop-in wrapper around `std::fs::File` for any call
|
||||
//! site that performs large sequential writes (sweep, mux, etc.). It
|
||||
//! implements `Write` and `Seek` so existing code paths can swap
|
||||
//! `File` for `Writer` with no body changes. Internally it drives a
|
||||
//! `WritebackPipeline` that, on Linux, drains dirty pages continuously
|
||||
//! at 32 MB granularity to avoid the kernel's accumulate-then-burst
|
||||
//! flush behaviour. macOS and Windows use a no-op pipeline — their
|
||||
//! default cache policies have not been shown to exhibit the same
|
||||
//! pathology for this access pattern.
|
||||
|
||||
mod writeback;
|
||||
mod writer;
|
||||
|
||||
pub(crate) use writer::Writer;
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Per-platform writeback pipeline. On Linux, drains dirty pages
|
||||
//! continuously at chunk granularity to keep the kernel's writeback
|
||||
//! queue bounded. On macOS and Windows, a no-op stub.
|
||||
//!
|
||||
//! The platform decision lives entirely in this file (the cfg-gated
|
||||
//! `pub use` below). Callers — and `DiskWriter` itself — are
|
||||
//! platform-independent.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod noop;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) use linux::WritebackPipeline;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) use noop::WritebackPipeline;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Linux writeback pipeline using `sync_file_range` + `posix_fadvise`.
|
||||
//!
|
||||
//! Pathology this fixes: the kernel's default `vm.dirty_ratio` (~20 %
|
||||
//! of RAM) lets dirty pages accumulate to hundreds of MB during a
|
||||
//! big sequential write, then bursts a flush at 99 % disk utilisation.
|
||||
//! While the burst runs, app writes block on the writeback queue —
|
||||
//! observed empirically as instantaneous speed dropping from ~15 MB/s
|
||||
//! to ~1 MB/s every ~30 s during a Pass 1 sweep.
|
||||
//!
|
||||
//! Strategy: every `chunk_bytes` of new sequential output, kick async
|
||||
//! writeback (`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and
|
||||
//! finalise the *previous* chunk via `WAIT_AFTER` + `posix_fadvise
|
||||
//! (DONTNEED)`. By the time we finalise, that previous chunk has had
|
||||
//! a full chunk's worth of work to flush — the wait is near-instant.
|
||||
//! Dirty cache stays bounded at ~2 × `chunk_bytes` and writes drain
|
||||
//! continuously instead of in bursts.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
|
||||
pub(crate) struct WritebackPipeline {
|
||||
fd: RawFd,
|
||||
chunk_bytes: u64,
|
||||
last_flush_pos: u64,
|
||||
pending: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl WritebackPipeline {
|
||||
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self {
|
||||
Self {
|
||||
fd: file.as_raw_fd(),
|
||||
chunk_bytes,
|
||||
last_flush_pos: start_pos,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller advanced the file position to `pos`. If a chunk boundary
|
||||
/// was crossed, kick async writeback for the just-completed chunk
|
||||
/// and finalise the previous one.
|
||||
pub(crate) fn note_progress(&mut self, pos: u64) {
|
||||
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
let chunk_off = self.last_flush_pos as i64;
|
||||
let chunk_len = (pos - self.last_flush_pos) as i64;
|
||||
libc::sync_file_range(self.fd, chunk_off, chunk_len, libc::SYNC_FILE_RANGE_WRITE);
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
libc::sync_file_range(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::SYNC_FILE_RANGE_WAIT_AFTER,
|
||||
);
|
||||
libc::posix_fadvise(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
);
|
||||
}
|
||||
self.pending = Some((chunk_off as u64, chunk_len as u64));
|
||||
}
|
||||
self.last_flush_pos = pos;
|
||||
}
|
||||
|
||||
/// Caller is about to seek away from the current write region.
|
||||
/// Drain any in-flight chunk and reset tracking.
|
||||
pub(crate) fn handle_seek(&mut self, new_pos: u64) {
|
||||
self.finalize();
|
||||
self.last_flush_pos = new_pos;
|
||||
}
|
||||
|
||||
/// Drain any in-flight chunk. Idempotent. Call before `sync_all()`
|
||||
/// or when discarding the pipeline.
|
||||
pub(crate) fn finalize(&mut self) {
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
unsafe {
|
||||
libc::sync_file_range(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::SYNC_FILE_RANGE_WAIT_AFTER,
|
||||
);
|
||||
libc::posix_fadvise(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! No-op writeback pipeline for non-Linux targets. macOS and Windows
|
||||
//! page cache policies have not been shown to exhibit the Linux
|
||||
//! accumulate-then-burst flush pathology for our access pattern.
|
||||
//! If that changes, replace this stub with a real implementation
|
||||
//! (e.g. `F_NOCACHE` on macOS, `FILE_FLAG_WRITE_THROUGH` on Windows).
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
pub(crate) struct WritebackPipeline;
|
||||
|
||||
impl WritebackPipeline {
|
||||
pub(crate) fn new(_file: &File, _start_pos: u64, _chunk_bytes: u64) -> Self {
|
||||
Self
|
||||
}
|
||||
pub(crate) fn note_progress(&mut self, _pos: u64) {}
|
||||
pub(crate) fn handle_seek(&mut self, _new_pos: u64) {}
|
||||
pub(crate) fn finalize(&mut self) {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `Writer` — a drop-in `File` wrapper that keeps the kernel page
|
||||
//! cache bounded during large sequential output.
|
||||
//!
|
||||
//! Implements `Write` and `Seek`, so any call site that uses `File`
|
||||
//! through those traits (sweep, mux, patch) can swap to `Writer`
|
||||
//! without touching the body of the loop. The wrapper tracks the
|
||||
//! current file position and forwards each write to a
|
||||
//! [`super::writeback::WritebackPipeline`], which on Linux schedules
|
||||
//! incremental `sync_file_range` + `posix_fadvise(DONTNEED)` calls
|
||||
//! to drain dirty pages continuously instead of letting the kernel
|
||||
//! burst-flush hundreds of MB at a time.
|
||||
//!
|
||||
//! See `super::writeback::linux` for the pathology and the strategy.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
const CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub(crate) struct Writer {
|
||||
file: File,
|
||||
pipeline: WritebackPipeline,
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Wrap an open `File`. The current OS file position is queried
|
||||
/// once so the pipeline starts tracking from wherever the file
|
||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, CHUNK_BYTES);
|
||||
Ok(Self {
|
||||
file,
|
||||
pipeline,
|
||||
pos,
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain in-flight writeback then issue a full fsync. Use this in
|
||||
/// place of `File::sync_all`.
|
||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
||||
self.pipeline.finalize();
|
||||
self.file.sync_all()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for Writer {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let n = self.file.write(buf)?;
|
||||
self.pos += n as u64;
|
||||
self.pipeline.note_progress(self.pos);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||
self.file.write_all(buf)?;
|
||||
self.pos += buf.len() as u64;
|
||||
self.pipeline.note_progress(self.pos);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for Writer {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
let p = self.file.seek(from)?;
|
||||
// Only treat seeks that actually move the position as
|
||||
// boundaries — sweep does a redundant `seek(Current(pos))`
|
||||
// before every write, and we don't want that to drain the
|
||||
// pipeline on every iteration.
|
||||
if p != self.pos {
|
||||
self.pipeline.handle_seek(p);
|
||||
self.pos = p;
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -81,6 +81,7 @@ pub mod error;
|
||||
pub mod event;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub(crate) mod io;
|
||||
pub mod keydb;
|
||||
pub(crate) mod labels;
|
||||
pub(crate) mod mpls;
|
||||
@@ -88,12 +89,16 @@ 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;
|
||||
pub(crate) mod udf;
|
||||
pub mod verify;
|
||||
|
||||
// Re-export verify types at the crate root for ergonomic imports.
|
||||
pub use verify::{SectorRange, SectorStatus, VerifyResult, verify_title};
|
||||
|
||||
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive`
|
||||
@@ -102,7 +107,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 +138,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 +173,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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -242,15 +242,23 @@ pub fn output(
|
||||
match parsed {
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
// Wrap the raw `File` in `crate::io::Writer` (bounded-cache
|
||||
// writeback) so a UHD-scale MKV mux to slow / network-attached
|
||||
// staging doesn't hit the dirty-page burst pathology that
|
||||
// sweep already side-steps. BufWriter sits on top to coalesce
|
||||
// mux's many small EBML element writes.
|
||||
let file = std::fs::File::create(path)?;
|
||||
let writer: Box<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
let writer: Box<dyn super::WriteSeek> = Box::new(std::io::BufWriter::with_capacity(
|
||||
IO_BUF_SIZE,
|
||||
crate::io::Writer::new(file)?,
|
||||
));
|
||||
Ok(Box::new(MkvStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path)?;
|
||||
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
let writer =
|
||||
std::io::BufWriter::with_capacity(IO_BUF_SIZE, crate::io::Writer::new(file)?);
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
//! 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`, `verify_title`) 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,
|
||||
/// Sector verification — reads every sector and classifies health.
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// For `PassKind::Verify`, the fields map as follows:
|
||||
/// - `work_done` = sectors read so far
|
||||
/// - `work_total` = total sectors in title
|
||||
/// - `bytes_good_total` = good + slow + recovered sectors × 2048
|
||||
/// - `bytes_unreadable_total` = bad sectors × 2048
|
||||
/// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind,
|
||||
pub work_done: u64,
|
||||
pub work_total: u64,
|
||||
pub bytes_good_total: u64,
|
||||
pub bytes_unreadable_total: u64,
|
||||
pub bytes_pending_total: u64,
|
||||
pub bytes_total_disc: u64,
|
||||
pub disc_duration_secs: Option<f64>,
|
||||
/// How many bytes of the worst-case damage (unreadable + pending) fall
|
||||
/// within the main title's extents. Zero means none of the damage
|
||||
/// affects the main movie — it's all in extras/menus.
|
||||
pub bytes_bad_in_main_title: u64,
|
||||
/// Main title duration in seconds. Same as disc_duration_secs when the
|
||||
/// disc has one dominant title, but separate so consumers can show both.
|
||||
pub main_title_duration_secs: Option<f64>,
|
||||
/// Main title size in bytes (sum of extent sizes).
|
||||
pub main_title_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl PassProgress {
|
||||
/// Percentage of work completed for this pass (0..=100).
|
||||
///
|
||||
/// Returns `100.0` if `work_total` is zero to avoid division by zero.
|
||||
pub fn work_pct(&self) -> f64 {
|
||||
if self.work_total == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
self.work_done as f64 / self.work_total as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is confirmed clean (0..=100).
|
||||
///
|
||||
/// Computed from `bytes_good_total / bytes_total_disc`.
|
||||
pub fn good_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
self.bytes_good_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is unreadable (0..=100).
|
||||
pub fn bad_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.bytes_unreadable_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is still pending (not yet attempted or needs retry).
|
||||
pub fn pending_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.bytes_pending_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// Returns `true` to continue, `false` to request early stop.
|
||||
///
|
||||
/// No `Send`/`Sync` bound — `report` is always called from the same thread
|
||||
/// running the 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) -> bool;
|
||||
}
|
||||
|
||||
impl<F: Fn(&PassProgress) -> bool> Progress for F {
|
||||
fn report(&self, p: &PassProgress) -> bool {
|
||||
(self)(p)
|
||||
}
|
||||
}
|
||||
+254
-284
@@ -1,19 +1,29 @@
|
||||
//! 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
|
||||
//! the SCSI architecture audit (2026-04-26) for the full primary-source
|
||||
//! references.
|
||||
//!
|
||||
//! 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;
|
||||
@@ -47,23 +57,22 @@ 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")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
pub fd: i32,
|
||||
device_path: std::path::PathBuf,
|
||||
pub fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let device = Self::resolve_to_sg(device);
|
||||
Self::reset(&device)?;
|
||||
let c_path = Self::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
@@ -77,109 +86,32 @@ impl SgIoTransport {
|
||||
Ok(SgIoTransport {
|
||||
fd,
|
||||
device_path: device,
|
||||
fd_recovery: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(-1)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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
|
||||
let probe_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if probe_fd >= 0 {
|
||||
unsafe { libc::close(probe_fd) };
|
||||
}
|
||||
|
||||
// Step 3: let drive settle
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Step 4: open clean fd
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Self::open_error(device);
|
||||
}
|
||||
|
||||
// Step 5: unlock tray
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the BU40N wedge recovery postmortem (2026-04-25).
|
||||
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 {
|
||||
@@ -189,8 +121,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() };
|
||||
@@ -206,7 +137,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(())
|
||||
@@ -251,7 +182,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) };
|
||||
}
|
||||
@@ -259,21 +190,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],
|
||||
@@ -281,30 +224,50 @@ impl ScsiTransport for SgIoTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if self.fd < 0 {
|
||||
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"
|
||||
);
|
||||
|
||||
// Check if a background recovery has produced a new fd.
|
||||
let recovered = self
|
||||
.fd_recovery
|
||||
.swap(-1, std::sync::atomic::Ordering::Acquire);
|
||||
if recovered >= 0 {
|
||||
// Close the old fd if it's still valid.
|
||||
if self.fd >= 0 {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
self.fd = recovered;
|
||||
} else if self.fd < 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: self.device_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
if data.len() > u32::MAX as usize {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -317,109 +280,118 @@ 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;
|
||||
}
|
||||
};
|
||||
// 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)"
|
||||
);
|
||||
|
||||
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.
|
||||
// Spawn recovery: close old fd, open new one in background.
|
||||
// This prevents the main thread from blocking on close() while
|
||||
// the kernel finishes the previous ioctl.
|
||||
let old_fd = self.fd;
|
||||
self.fd = -1;
|
||||
let path = self.device_path.clone();
|
||||
let recovery = self.fd_recovery.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
unsafe { libc::close(old_fd) };
|
||||
if old_fd >= 0 {
|
||||
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 };
|
||||
std::thread::spawn(move || {
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
|
||||
let new_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
recovery.store(new_fd, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -435,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();
|
||||
@@ -483,8 +440,7 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
|
||||
// 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).
|
||||
// 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 {
|
||||
@@ -550,8 +506,8 @@ fn enumerate_sg_names() -> Vec<String> {
|
||||
let type_path = format!("/sys/class/scsi_generic/{name}/device/type");
|
||||
match std::fs::read_to_string(&type_path) {
|
||||
Ok(s) if s.trim() == SCSI_TYPE_OPTICAL => names.push(name),
|
||||
Ok(_) => {} // not optical
|
||||
Err(_) => names.push(name), // sysfs unreadable — let INQUIRY decide
|
||||
Ok(_) => {} // not optical
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -568,50 +524,64 @@ fn enumerate_sg_names() -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
/// `drive_has_disc` = single TEST UNIT READY. Any error (including our
|
||||
/// synthesised wedge signature, `ScsiError { status: 0xFF }`, when
|
||||
/// `execute()` times out) bubbles straight up to the caller.
|
||||
///
|
||||
/// ## No in-library wedge recovery — and why
|
||||
///
|
||||
/// Versions 0.13.1 – 0.13.3 layered `scsi::reset()` + `scsi::usb_reset()`
|
||||
/// (`USBDEVFS_RESET`) escalation inside `drive_has_disc`. Production
|
||||
/// testing on the LG BU40N USB BD-RE showed all three userspace recovery
|
||||
/// ladders succeed at the USB transport level (the kernel logs
|
||||
/// `usb 3-2: reset high-speed USB device`, the device re-authorises
|
||||
/// and re-attaches on a fresh `scsi_host`) **but the drive firmware
|
||||
/// below the USB bridge stays locked** — no LUN ever enumerates, TUR
|
||||
/// never succeeds, /dev/sg* never reappears. Physical power-cycle
|
||||
/// (unplug-replug or host reboot) is the only recovery.
|
||||
///
|
||||
/// Methods tried and discarded:
|
||||
/// - `SG_SCSI_RESET` (device-level SCSI bus reset)
|
||||
/// - `STOP UNIT` / `START UNIT` CDB pair
|
||||
/// - `USBDEVFS_RESET` ioctl on `/dev/bus/usb/BBB/DDD`
|
||||
/// - `/sys/bus/usb/devices/<port>/authorized` 0→1 toggle
|
||||
/// - `/sys/bus/usb/drivers/usb-storage/{unbind,bind}` driver rebind
|
||||
/// - Forced `echo "- - -" > /sys/class/scsi_host/hostN/scan`
|
||||
///
|
||||
/// Rolled back in 0.13.4. Callers (autorip, CLI) surface the error
|
||||
/// directly and prompt the user to physically reconnect the drive.
|
||||
/// If a future hardware class is found where USB-layer recovery
|
||||
/// actually works, the escalation belongs here, gated on the wedge
|
||||
/// signature — see git tag `v0.13.3` for the full implementation.
|
||||
/// Send TEST UNIT READY directly — no transport, no reset, no side effects.
|
||||
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
let mut transport = SgIoTransport::open(path)?;
|
||||
let device = SgIoTransport::resolve_to_sg(path);
|
||||
let c_path = SgIoTransport::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return SgIoTransport::open_error(&device);
|
||||
}
|
||||
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
match transport.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
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 = SG_DXFER_NONE;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = 0;
|
||||
hdr.dxferp = std::ptr::null_mut();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = crate::scsi::TUR_TIMEOUT_MS;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
unsafe { libc::close(fd) };
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
}
|
||||
|
||||
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
|
||||
if hdr.host_status != 0 || driver_status_real != 0 {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
if hdr.status == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let parsed = super::parse_sense(&sense, hdr.sb_len_wr);
|
||||
if parsed.is_not_ready() {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(Error::ScsiError {
|
||||
sense_key: SENSE_KEY_NOT_READY,
|
||||
..
|
||||
}) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense: Some(parsed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+128
-504
@@ -1,183 +1,55 @@
|
||||
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
|
||||
//! macOS SCSI transport: IOKit SCSITaskDeviceInterface with exclusive access.
|
||||
//!
|
||||
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
|
||||
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
|
||||
//! Single dispatch path: **all** CDBs (INQUIRY, READ, REPORT KEY, etc.) go
|
||||
//! through `SCSITaskDeviceInterface::ExecuteTaskSync` — 1:1 with the Linux
|
||||
//! SG_IO backend. The C shim (`macos_shim.c`) handles:
|
||||
//!
|
||||
//! Requires exclusive access to the device — unmount the disc first:
|
||||
//! `diskutil unmountDisk /dev/disk2`
|
||||
//! 1. `diskutil unmountDisk force` on the target device only
|
||||
//! 2. Find `IOBDServices` matching the requested BSD name (walks IOKit
|
||||
//! registry: IOBDServices → IOBDBlockStorageDriver → IOMedia → BSD Name)
|
||||
//! 3. Create `MMCDeviceInterface` → `SCSITaskDeviceInterface`
|
||||
//! 4. `ObtainExclusiveAccess`
|
||||
//! 5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync`
|
||||
//!
|
||||
//! Drive enumeration (`list_drives`) uses the IOKit registry directly via
|
||||
//! `shim_list_drives` — no exclusive access, no SCSI commands, no unmounts.
|
||||
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
|
||||
|
||||
type CFMutableDictionaryRef = *mut std::ffi::c_void;
|
||||
type IOObject = u32;
|
||||
type IOReturn = i32;
|
||||
type MachPort = u32;
|
||||
|
||||
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
|
||||
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
|
||||
/// to the function table.
|
||||
type ComRef = *mut *mut std::ffi::c_void;
|
||||
|
||||
const K_IO_RETURN_SUCCESS: IOReturn = 0;
|
||||
|
||||
// SCSI data transfer directions (SCSITaskLib.h)
|
||||
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
|
||||
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
|
||||
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
|
||||
|
||||
// SCSI task status values
|
||||
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
|
||||
|
||||
const K_MAX_CDB_SIZE: usize = 16;
|
||||
const K_SENSE_DATA_SIZE: usize = 32;
|
||||
|
||||
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
|
||||
// From IOKit/scsi/SCSITaskLib.h
|
||||
|
||||
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
|
||||
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
|
||||
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6, 0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
|
||||
];
|
||||
|
||||
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
|
||||
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
|
||||
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
|
||||
];
|
||||
|
||||
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
|
||||
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
|
||||
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6, 0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
// ── Scatter/gather element ──────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
struct SCSITaskSGElement {
|
||||
address: u64,
|
||||
length: u64,
|
||||
#[derive(Copy, Clone)]
|
||||
struct ShimDriveInfo {
|
||||
bsd_name: [u8; 32],
|
||||
vendor: [u8; 32],
|
||||
model: [u8; 48],
|
||||
firmware: [u8; 16],
|
||||
}
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
// Rust 2024: FFI blocks declaring extern fns must be `unsafe extern`.
|
||||
unsafe extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
options: u32,
|
||||
bsd_name: *const u8,
|
||||
) -> CFMutableDictionaryRef;
|
||||
fn IOServiceGetMatchingService(master: MachPort, matching: CFMutableDictionaryRef) -> IOObject;
|
||||
fn IOObjectRelease(object: IOObject) -> IOReturn;
|
||||
fn IORegistryEntryGetParentEntry(
|
||||
entry: IOObject,
|
||||
plane: *const u8,
|
||||
parent: *mut IOObject,
|
||||
) -> IOReturn;
|
||||
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
|
||||
fn IOCreatePlugInInterfaceForService(
|
||||
service: IOObject,
|
||||
plugin_type: *const [u8; 16],
|
||||
interface_type: *const [u8; 16],
|
||||
the_interface: *mut ComRef,
|
||||
the_score: *mut i32,
|
||||
) -> IOReturn;
|
||||
fn shim_open_exclusive(bsd_name: *const u8) -> i32;
|
||||
fn shim_close();
|
||||
fn shim_execute(
|
||||
cdb: *const u8,
|
||||
cdb_len: u8,
|
||||
buf: *mut u8,
|
||||
buf_len: u32,
|
||||
data_in: i32,
|
||||
sense_out: *mut u8,
|
||||
sense_len: u32,
|
||||
task_status_out: *mut u8,
|
||||
transfer_count: *mut u64,
|
||||
) -> i32;
|
||||
fn shim_list_drives(out: *mut ShimDriveInfo, max_entries: i32) -> i32;
|
||||
}
|
||||
|
||||
// ── COM vtable helpers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
|
||||
// dereferencing once gives the vtable pointer, then index into it for
|
||||
// individual function pointers.
|
||||
//
|
||||
// All vtable indices verified against Apple open source:
|
||||
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
|
||||
|
||||
/// Read a function pointer from a COM vtable at the given index.
|
||||
///
|
||||
/// # Safety
|
||||
/// `iface` must be a valid COM interface pointer (*mut *mut c_void), and
|
||||
/// `index` must be a valid vtable slot for the target type `T`.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
// Rust 2024: `unsafe fn` bodies are no longer implicitly unsafe.
|
||||
// Each unsafe op needs its own `unsafe { }` block.
|
||||
unsafe {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
fn com_release(iface: ComRef) {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> u32;
|
||||
unsafe {
|
||||
let f: Fn = vtable_fn(iface, 3);
|
||||
f(iface);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsExclusiveAccessAvailable
|
||||
// 5 AddCallbackDispatcherToRunLoop
|
||||
// 6 RemoveCallbackDispatcherFromRunLoop
|
||||
// 7 ObtainExclusiveAccess
|
||||
// 8 ReleaseExclusiveAccess
|
||||
// 9 CreateSCSITask
|
||||
|
||||
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
|
||||
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
|
||||
const VTIDX_CREATE_TASK: usize = 9;
|
||||
|
||||
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsTaskActive
|
||||
// 5 SetTaskAttribute
|
||||
// 6 GetTaskAttribute
|
||||
// 7 GetTaskState
|
||||
// 8 SetCommandDescriptorBlock
|
||||
// 9 GetCommandDescriptorBlockSize
|
||||
// 10 GetCommandDescriptorBlock
|
||||
// 11 SetScatterGatherEntries
|
||||
// 12 SetTimeoutDuration
|
||||
// 13 GetTimeoutDuration
|
||||
// 14 SetTaskCompletionCallback
|
||||
// 15 ExecuteTaskSync
|
||||
// 16 ExecuteTaskAsync
|
||||
// 17 AbortTask
|
||||
// 18 GetSCSIServiceResponse
|
||||
// 19 GetTaskStatus
|
||||
// 20 GetRealizedDataTransferCount
|
||||
// 21 GetAutoSenseData
|
||||
|
||||
const VTIDX_SET_CDB: usize = 8;
|
||||
const VTIDX_SET_SG: usize = 11;
|
||||
const VTIDX_SET_TIMEOUT: usize = 12;
|
||||
const VTIDX_EXECUTE_SYNC: usize = 15;
|
||||
|
||||
// ── Transport implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct MacScsiTransport {
|
||||
device_iface: ComRef,
|
||||
exclusive: bool,
|
||||
_bsd_name: String,
|
||||
}
|
||||
|
||||
// IOKit COM interface pointers are Mach port references — safe to send between threads.
|
||||
unsafe impl Send for MacScsiTransport {}
|
||||
|
||||
impl MacScsiTransport {
|
||||
@@ -186,7 +58,6 @@ impl MacScsiTransport {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
|
||||
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
|
||||
rest
|
||||
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
|
||||
@@ -195,144 +66,118 @@ impl MacScsiTransport {
|
||||
dev_str
|
||||
};
|
||||
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
bsd_c.push(0);
|
||||
|
||||
// Create IOKit plugin for the MMC device
|
||||
let mut plugin: ComRef = std::ptr::null_mut();
|
||||
let mut score: i32 = 0;
|
||||
let kr = unsafe {
|
||||
IOCreatePlugInInterfaceForService(
|
||||
service,
|
||||
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
|
||||
&K_IO_CFPLUGIN_INTERFACE_ID,
|
||||
&mut plugin,
|
||||
&mut score,
|
||||
)
|
||||
};
|
||||
unsafe { IOObjectRelease(service) };
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
return Err(Error::IoKitPluginFailed {
|
||||
path: dev_str.to_string(),
|
||||
kr: kr as u32,
|
||||
});
|
||||
}
|
||||
|
||||
// QueryInterface for SCSITaskDeviceInterface
|
||||
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_SCSI_TASK_DEVICE_INTERFACE_ID,
|
||||
&mut device_iface,
|
||||
)
|
||||
};
|
||||
com_release(plugin);
|
||||
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::ScsiInterfaceUnavailable {
|
||||
path: dev_str.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Obtain exclusive access
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
|
||||
f(device_iface)
|
||||
};
|
||||
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(),
|
||||
kr: kr as u32,
|
||||
let rc = unsafe { shim_open_exclusive(bsd_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: bsd_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
_bsd_name: bsd_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// callers — `list_drives()` in `scsi::mod` — never reach into
|
||||
/// `crate::drive::macos`.
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..K_DEV_DISK_MAX {
|
||||
let path = format!("/dev/disk{i}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
impl Drop for MacScsiTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe { shim_close() };
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MacScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let data_in = match direction {
|
||||
DataDirection::FromDevice => 1,
|
||||
DataDirection::ToDevice => 0,
|
||||
DataDirection::None => 0,
|
||||
};
|
||||
|
||||
let mut sense = [0u8; K_SENSE_DATA_SIZE];
|
||||
let mut task_status: u8 = 0xFF;
|
||||
let mut transfer_count: u64 = 0;
|
||||
|
||||
let kr = unsafe {
|
||||
shim_execute(
|
||||
cdb.as_ptr(),
|
||||
cdb.len() as u8,
|
||||
data.as_mut_ptr(),
|
||||
data.len() as u32,
|
||||
data_in,
|
||||
sense.as_mut_ptr(),
|
||||
K_SENSE_DATA_SIZE as u32,
|
||||
&mut task_status,
|
||||
&mut transfer_count,
|
||||
)
|
||||
};
|
||||
|
||||
if kr != 0 {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let mut transport = match MacScsiTransport::open(std::path::Path::new(&path)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let inquiry = match super::inquiry(&mut transport) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// SCSI peripheral type field is the lower 5 bits of byte 0.
|
||||
if inquiry.raw.is_empty()
|
||||
|| (inquiry.raw[K_INQUIRY_TYPE_BYTE] & K_INQUIRY_TYPE_MASK) != K_SCSI_TYPE_OPTICAL
|
||||
{
|
||||
|
||||
if task_status != 0 {
|
||||
let parsed = super::parse_sense(&sense, K_SENSE_DATA_SIZE as u8);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status,
|
||||
sense: Some(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: transfer_count as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drive enumeration (registry-based, no exclusive access) ──────────────
|
||||
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut buf = [ShimDriveInfo {
|
||||
bsd_name: [0; 32],
|
||||
vendor: [0; 32],
|
||||
model: [0; 48],
|
||||
firmware: [0; 16],
|
||||
}; 8];
|
||||
|
||||
let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) };
|
||||
|
||||
let mut out = Vec::new();
|
||||
for info in buf.iter().take((count as usize).min(buf.len())) {
|
||||
let bsd_name = cstr_to_str(&info.bsd_name);
|
||||
if bsd_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(super::DriveInfo {
|
||||
path,
|
||||
vendor: inquiry.vendor_id,
|
||||
model: inquiry.model,
|
||||
firmware: inquiry.firmware,
|
||||
path: format!("/dev/{bsd_name}"),
|
||||
vendor: cstr_to_str(&info.vendor).to_string(),
|
||||
model: cstr_to_str(&info.model).to_string(),
|
||||
firmware: cstr_to_str(&info.firmware).to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Maximum BSD disk index probed during enumeration. macOS assigns
|
||||
/// `/dev/diskN` sequentially per attached storage device; 16 covers
|
||||
/// any realistic homelab.
|
||||
const K_DEV_DISK_MAX: u8 = 16;
|
||||
fn cstr_to_str(bytes: &[u8]) -> &str {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
std::str::from_utf8(&bytes[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
/// SCSI INQUIRY response: peripheral device type lives in byte 0,
|
||||
/// lower 5 bits.
|
||||
const K_INQUIRY_TYPE_BYTE: usize = 0;
|
||||
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. 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> {
|
||||
let mut transport = MacScsiTransport::open(path)?;
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
@@ -344,228 +189,7 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_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),
|
||||
}
|
||||
}
|
||||
|
||||
const K_SENSE_KEY_NOT_READY: u8 = 2;
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.exclusive {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
|
||||
f(self.device_iface);
|
||||
}
|
||||
}
|
||||
com_release(self.device_iface);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MacScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
// Create a SCSI task
|
||||
let task: ComRef = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
|
||||
f(self.device_iface)
|
||||
};
|
||||
if task.is_null() {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Set CDB
|
||||
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
|
||||
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
|
||||
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
|
||||
f(task, cdb_padded.as_ptr(), cdb_len as u8);
|
||||
}
|
||||
|
||||
// Set scatter/gather and transfer direction
|
||||
let iokit_dir = match direction {
|
||||
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
|
||||
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
|
||||
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
|
||||
};
|
||||
|
||||
if direction != DataDirection::None && !data.is_empty() {
|
||||
let sg = SCSITaskSGElement {
|
||||
address: data.as_mut_ptr() as u64,
|
||||
length: data.len() as u64,
|
||||
};
|
||||
unsafe {
|
||||
type Fn =
|
||||
unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, &sg, 1, data.len() as u64, iokit_dir);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
type Fn =
|
||||
unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout (IOKit SCSITask takes milliseconds)
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, u32);
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
|
||||
f(task, timeout_ms);
|
||||
}
|
||||
|
||||
// Execute synchronously
|
||||
let mut sense = [0u8; K_SENSE_DATA_SIZE];
|
||||
let mut task_status: u32 = 0;
|
||||
let mut realized_count: u64 = 0;
|
||||
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *mut u8, *mut u32, *mut u64) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
|
||||
f(
|
||||
task,
|
||||
sense.as_mut_ptr(),
|
||||
&mut task_status,
|
||||
&mut realized_count,
|
||||
)
|
||||
};
|
||||
|
||||
com_release(task);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if task_status != K_SCSI_TASK_STATUS_GOOD as u32 {
|
||||
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status as u8,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: task_status as u8,
|
||||
bytes_transferred: realized_count as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
|
||||
/// BSD name → IOKit service for the SCSI device.
|
||||
///
|
||||
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
|
||||
///
|
||||
/// All failure paths surface as `Error::DeviceNotFound { path: bsd_name }` —
|
||||
/// the four internal stages (IOMasterPort / IOBSDNameMatching / IOMedia
|
||||
/// lookup / walk_to_authoring_device) collapse into one observable error
|
||||
/// because none of them are user-actionable individually. Pre-0.13 each
|
||||
/// stage stuffed an English description into `path:` ("…IOMasterPort
|
||||
/// failed", "…SCSITaskDeviceInterface not available", etc.) which broke
|
||||
/// the library's "no English text" rule.
|
||||
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
|
||||
let not_found = || Error::DeviceNotFound {
|
||||
path: bsd_name.to_string(),
|
||||
};
|
||||
|
||||
let mut master: MachPort = 0;
|
||||
let kr = unsafe { IOMasterPort(0, &mut master) };
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
bsd_c.push(0);
|
||||
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
|
||||
if matching.is_null() {
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
// Find the single IOMedia service (consumes the matching dict)
|
||||
let media = unsafe { IOServiceGetMatchingService(master, matching) };
|
||||
if media == 0 {
|
||||
return Err(not_found());
|
||||
}
|
||||
|
||||
// Walk up the IOService plane to find the authoring device.
|
||||
// The chain is typically:
|
||||
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
|
||||
// → IOSCSIPeripheralDeviceNub (this is what we want)
|
||||
//
|
||||
// We walk up until we find a service that IOCreatePlugInInterfaceForService
|
||||
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
|
||||
let service = walk_to_authoring_device(media);
|
||||
unsafe { IOObjectRelease(media) };
|
||||
|
||||
service.ok_or_else(not_found)
|
||||
}
|
||||
|
||||
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
|
||||
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
|
||||
let mut current = start;
|
||||
// Retain start so we can release uniformly in the loop
|
||||
// (IORegistryEntryGetParentEntry retains the parent for us)
|
||||
|
||||
// Target class names for authoring devices
|
||||
let target_classes: &[&[u8]] = &[
|
||||
b"IOSCSIPeripheralDeviceNub\0",
|
||||
b"IOBDBlockStorageDevice\0",
|
||||
b"IODVDBlockStorageDevice\0",
|
||||
b"IOCDBlockStorageDevice\0",
|
||||
b"IOBlockStorageDevice\0",
|
||||
];
|
||||
|
||||
// Walk up to 10 levels (more than enough)
|
||||
for _ in 0..10 {
|
||||
let mut parent: IOObject = 0;
|
||||
let kr = unsafe {
|
||||
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
|
||||
};
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if this parent matches any of our target classes
|
||||
for class in target_classes {
|
||||
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
|
||||
return Some(parent);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#include <IOKit/IOKitLib.h>
|
||||
#include <IOKit/IOCFPlugIn.h>
|
||||
#include <IOKit/scsi/SCSITaskLib.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
typedef struct {
|
||||
IOCFPlugInInterface **plugin;
|
||||
MMCDeviceInterface **mmc;
|
||||
SCSITaskDeviceInterface **scsi;
|
||||
int exclusive;
|
||||
} ShimHandle;
|
||||
|
||||
typedef struct {
|
||||
char bsd_name[32];
|
||||
char vendor[32];
|
||||
char model[48];
|
||||
char firmware[16];
|
||||
} ShimDriveInfo;
|
||||
|
||||
// ── Global handle (single-drive, same as before) ──────────────────────────
|
||||
|
||||
static ShimHandle g_handle = {NULL, NULL, NULL, 0};
|
||||
|
||||
// ── Registry helpers ──────────────────────────────────────────────────────
|
||||
|
||||
static int cfstring_to_cstr(CFStringRef cf, char *buf, size_t buflen) {
|
||||
if (!cf) return 0;
|
||||
if (!CFStringGetCString(cf, buf, buflen, kCFStringEncodingUTF8)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int registry_entry_bsd_name(io_registry_entry_t entry, char *buf, size_t buflen) {
|
||||
CFStringRef cf = IORegistryEntryCreateCFProperty(entry, CFSTR("BSD Name"),
|
||||
kCFAllocatorDefault, 0);
|
||||
if (!cf) return 0;
|
||||
int ok = cfstring_to_cstr(cf, buf, buflen);
|
||||
CFRelease(cf);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_iomedia_child(io_registry_entry_t parent) {
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_registry_entry_t child;
|
||||
while ((child = IOIteratorNext(iter)) != 0) {
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(child, cls);
|
||||
if (kr == KERN_SUCCESS) {
|
||||
if (strcmp(cls, "IOMedia") == 0 || strcmp(cls, "IOBDMedia") == 0) {
|
||||
IOObjectRelease(iter);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
IOObjectRelease(child);
|
||||
}
|
||||
IOObjectRelease(iter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_child_of_class(io_registry_entry_t parent, const char *target_class) {
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_registry_entry_t child;
|
||||
while ((child = IOIteratorNext(iter)) != 0) {
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(child, cls);
|
||||
if (kr == KERN_SUCCESS && strcmp(cls, target_class) == 0) {
|
||||
IOObjectRelease(iter);
|
||||
return child;
|
||||
}
|
||||
IOObjectRelease(child);
|
||||
}
|
||||
IOObjectRelease(iter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_parent_of_class(io_registry_entry_t entry, const char *target_class) {
|
||||
io_registry_entry_t parent;
|
||||
kern_return_t kr = IORegistryEntryGetParentEntry(entry, kIOServicePlane, &parent);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(parent, cls);
|
||||
if (kr == KERN_SUCCESS && strcmp(cls, target_class) == 0) {
|
||||
return parent;
|
||||
}
|
||||
IOObjectRelease(parent);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Given an IOBDServices, find the BSD name of its IOMedia child.
|
||||
// Chain: IOBDServices -> IOBDBlockStorageDriver -> IOMedia (has "BSD Name")
|
||||
static int bdsvc_to_bsd_name(io_registry_entry_t bdsvc, char *buf, size_t buflen) {
|
||||
io_registry_entry_t driver = find_child_of_class(bdsvc, "IOBDBlockStorageDriver");
|
||||
if (!driver) return 0;
|
||||
|
||||
io_registry_entry_t media = find_iomedia_child(driver);
|
||||
IOObjectRelease(driver);
|
||||
if (!media) return 0;
|
||||
|
||||
int ok = registry_entry_bsd_name(media, buf, buflen);
|
||||
IOObjectRelease(media);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Given an IOBDServices, extract Device Characteristics strings.
|
||||
static void bdsvc_device_info(io_registry_entry_t bdsvc, ShimDriveInfo *info) {
|
||||
CFDictionaryRef dc = IORegistryEntryCreateCFProperty(bdsvc,
|
||||
CFSTR("Device Characteristics"), kCFAllocatorDefault, 0);
|
||||
if (!dc) return;
|
||||
|
||||
CFStringRef val;
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Vendor Name"));
|
||||
if (val) cfstring_to_cstr(val, info->vendor, sizeof(info->vendor));
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Product Name"));
|
||||
if (val) cfstring_to_cstr(val, info->model, sizeof(info->model));
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Product Revision Level"));
|
||||
if (val) cfstring_to_cstr(val, info->firmware, sizeof(info->firmware));
|
||||
|
||||
CFRelease(dc);
|
||||
}
|
||||
|
||||
// Find the IOBDServices that owns the given BSD name.
|
||||
// Returns a retained io_service_t (caller must release), or 0.
|
||||
static io_service_t find_bdsvc_by_bsd_name(mach_port_t mp, const char *bsd_name) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_service_t result = 0;
|
||||
io_service_t svc;
|
||||
while ((svc = IOIteratorNext(iter)) != 0) {
|
||||
char name[64];
|
||||
if (bdsvc_to_bsd_name(svc, name, sizeof(name))) {
|
||||
if (strcmp(name, bsd_name) == 0) {
|
||||
result = svc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
IOIteratorReset(iter);
|
||||
while ((svc = IOIteratorNext(iter)) != 0) {
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find the IOBDServices that owns the given BSD name by walking from
|
||||
// IOMedia upward. Used as fallback when bdsvc_to_bsd_name fails
|
||||
// (e.g. disc under exclusive access, no IOMedia child).
|
||||
// Chain: IOMedia -> IOBDBlockStorageDriver -> IOBDServices
|
||||
static io_service_t find_bdsvc_from_iomedia(mach_port_t mp, const char *bsd_name) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOMedia");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_service_t result = 0;
|
||||
io_service_t media;
|
||||
while ((media = IOIteratorNext(iter)) != 0) {
|
||||
char name[64];
|
||||
if (registry_entry_bsd_name(media, name, sizeof(name))
|
||||
&& strcmp(name, bsd_name) == 0)
|
||||
{
|
||||
io_registry_entry_t driver = find_parent_of_class(media, "IOBDBlockStorageDriver");
|
||||
if (driver) {
|
||||
io_registry_entry_t bdsvc = find_parent_of_class(driver, "IOBDServices");
|
||||
IOObjectRelease(driver);
|
||||
if (bdsvc) {
|
||||
result = bdsvc;
|
||||
IOObjectRelease(media);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
IOObjectRelease(media);
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
int shim_open_exclusive(const char *bsd_name) {
|
||||
kern_return_t kr;
|
||||
HRESULT hr;
|
||||
SInt32 score = 0;
|
||||
|
||||
if (g_handle.exclusive && g_handle.scsi) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use a shell wrapper so the device path is not subject to buffer limits.
|
||||
// snprintf into 128 bytes could truncate long BSD names (e.g. disk12s3s1),
|
||||
// producing a broken command. sh -c with $1 passes the arg via argv.
|
||||
const char *shell_fmt = "sh -c 'diskutil unmountDisk force \"$1\" >/dev/null 2>&1' _ %s";
|
||||
char cmd[512];
|
||||
int written = snprintf(cmd, sizeof(cmd), shell_fmt, bsd_name);
|
||||
if (written < 0 || (size_t)written >= sizeof(cmd)) {
|
||||
return -1;
|
||||
}
|
||||
system(cmd);
|
||||
usleep(500000);
|
||||
|
||||
mach_port_t mp;
|
||||
IOMainPort(0, &mp);
|
||||
|
||||
io_service_t svc = find_bdsvc_by_bsd_name(mp, bsd_name);
|
||||
if (!svc) {
|
||||
svc = find_bdsvc_from_iomedia(mp, bsd_name);
|
||||
}
|
||||
if (!svc) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
svc = IOServiceGetMatchingService(mp, matching);
|
||||
}
|
||||
if (!svc) return -1;
|
||||
|
||||
kr = IOCreatePlugInInterfaceForService(svc,
|
||||
kIOMMCDeviceUserClientTypeID, kIOCFPlugInInterfaceID,
|
||||
&g_handle.plugin, &score);
|
||||
IOObjectRelease(svc);
|
||||
|
||||
if (kr != KERN_SUCCESS || !g_handle.plugin) return -2;
|
||||
|
||||
hr = (*g_handle.plugin)->QueryInterface(g_handle.plugin,
|
||||
CFUUIDGetUUIDBytes(kIOMMCDeviceInterfaceID), (LPVOID *)&g_handle.mmc);
|
||||
if (hr != S_OK || !g_handle.mmc) {
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.plugin = NULL;
|
||||
return -3;
|
||||
}
|
||||
|
||||
g_handle.scsi = (*g_handle.mmc)->GetSCSITaskDeviceInterface(g_handle.mmc);
|
||||
if (!g_handle.scsi) {
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.mmc = NULL;
|
||||
g_handle.plugin = NULL;
|
||||
return -4;
|
||||
}
|
||||
|
||||
for (int retry = 0; retry < 10; retry++) {
|
||||
kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi);
|
||||
if (kr == kIOReturnSuccess) break;
|
||||
usleep(500000);
|
||||
}
|
||||
if (kr != kIOReturnSuccess) {
|
||||
(*g_handle.scsi)->Release(g_handle.scsi);
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.scsi = NULL;
|
||||
g_handle.mmc = NULL;
|
||||
g_handle.plugin = NULL;
|
||||
return -5;
|
||||
}
|
||||
|
||||
g_handle.exclusive = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void shim_close(void) {
|
||||
if (g_handle.exclusive && g_handle.scsi) {
|
||||
(*g_handle.scsi)->ReleaseExclusiveAccess(g_handle.scsi);
|
||||
}
|
||||
if (g_handle.scsi) {
|
||||
(*g_handle.scsi)->Release(g_handle.scsi);
|
||||
g_handle.scsi = NULL;
|
||||
}
|
||||
if (g_handle.mmc) {
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
g_handle.mmc = NULL;
|
||||
}
|
||||
if (g_handle.plugin) {
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.plugin = NULL;
|
||||
}
|
||||
g_handle.exclusive = 0;
|
||||
}
|
||||
|
||||
int shim_execute(const unsigned char *cdb, unsigned char cdb_len,
|
||||
void *buf, unsigned int buf_len, int data_in,
|
||||
unsigned char *sense_out, unsigned int sense_len,
|
||||
unsigned char *task_status_out, unsigned long long *transfer_count) {
|
||||
if (!g_handle.scsi) return -1;
|
||||
|
||||
SCSITaskInterface **task = (*g_handle.scsi)->CreateSCSITask(g_handle.scsi);
|
||||
if (!task) return -2;
|
||||
|
||||
SCSICommandDescriptorBlock cdb_buf;
|
||||
memset(&cdb_buf, 0, sizeof(cdb_buf));
|
||||
memcpy(&cdb_buf, cdb, cdb_len);
|
||||
|
||||
(*task)->SetCommandDescriptorBlock(task, cdb_buf, cdb_len);
|
||||
|
||||
if (buf_len > 0 && buf) {
|
||||
SCSITaskSGElement sg;
|
||||
sg.address = (UInt64)(uintptr_t)buf;
|
||||
sg.length = buf_len;
|
||||
(*task)->SetScatterGatherEntries(task, &sg, 1, buf_len,
|
||||
data_in ? kSCSIDataTransfer_FromTargetToInitiator
|
||||
: kSCSIDataTransfer_FromInitiatorToTarget);
|
||||
} else {
|
||||
(*task)->SetScatterGatherEntries(task, NULL, 0, 0,
|
||||
kSCSIDataTransfer_NoDataTransfer);
|
||||
}
|
||||
|
||||
(*task)->SetTimeoutDuration(task, 30000);
|
||||
|
||||
SCSI_Sense_Data sense;
|
||||
memset(&sense, 0, sizeof(sense));
|
||||
SCSITaskStatus status = 0xFF;
|
||||
UInt64 count = 0;
|
||||
|
||||
IOReturn kr = (*task)->ExecuteTaskSync(task, &sense, &status, &count);
|
||||
|
||||
if (sense_out && sense_len > 0) {
|
||||
size_t copy = sense_len < sizeof(sense) ? sense_len : sizeof(sense);
|
||||
memcpy(sense_out, &sense, copy);
|
||||
}
|
||||
if (task_status_out) *task_status_out = (unsigned char)status;
|
||||
if (transfer_count) *transfer_count = count;
|
||||
|
||||
(*task)->Release(task);
|
||||
|
||||
return (int)kr;
|
||||
}
|
||||
|
||||
// ── Registry-based drive enumeration ──────────────────────────────────────
|
||||
//
|
||||
// Walks IOBDServices entries in the IOKit registry. No exclusive access,
|
||||
// no SCSI commands, no unmounts. Returns up to max_entries drives.
|
||||
|
||||
int shim_list_drives(ShimDriveInfo *out, int max_entries) {
|
||||
mach_port_t mp;
|
||||
IOReturn ret = IOMainPort(0, &mp);
|
||||
if (ret != kIOReturnSuccess) return 0;
|
||||
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
int count = 0;
|
||||
io_service_t svc;
|
||||
while ((svc = IOIteratorNext(iter)) != 0 && count < max_entries) {
|
||||
ShimDriveInfo *info = &out[count];
|
||||
memset(info, 0, sizeof(*info));
|
||||
|
||||
bdsvc_device_info(svc, info);
|
||||
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
|
||||
|
||||
if (info->bsd_name[0]) {
|
||||
count++;
|
||||
}
|
||||
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return count;
|
||||
}
|
||||
+372
-99
@@ -2,11 +2,11 @@
|
||||
//!
|
||||
//! Platform backends are in separate files:
|
||||
//! - `linux.rs` — SG_IO ioctl
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface (exclusive access)
|
||||
//! - `windows.rs` — SPTI (SCSI Pass-Through Interface)
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -43,6 +43,256 @@ 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 the SCSI architecture audit
|
||||
/// (2026-04-26) for primary-source references.
|
||||
///
|
||||
/// 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. Matches
|
||||
/// `sg_dd`'s 60 s ceiling: long enough that any sector the drive can
|
||||
/// recover at all gets the time to do so, short enough that an
|
||||
/// unresponsive bus is detected before the per-range watchdog fires.
|
||||
///
|
||||
/// In practice failed reads return in 1–4 s (the drive itself gives up
|
||||
/// on uncorrectable ECC before the timeout); the 60 s value is a
|
||||
/// safety ceiling, not a steady-state cost.
|
||||
///
|
||||
/// Historical note (2026-05-08): briefly lowered to 2 s with a 5×
|
||||
/// inline retry loop in `Disc::patch` to mimic the kernel `sr_mod`
|
||||
/// driver's auto-retry pattern. The synthetic logic worked but on the
|
||||
/// live drive each "2 s" read paid ~1.5 s of kernel SCSI mid-layer
|
||||
/// error escalation on top, so 5× retries took ~17 s per LBA and
|
||||
/// triggered MAX_RANGE_SECS after 4 sectors — pushing recovery to
|
||||
/// 0/22 ranges (worse than the 0/22 baseline of v0.17.3 single-shot
|
||||
/// at 60 s, since that at least visited every range). Reverted; the
|
||||
/// kernel-auto-retry approach is being pursued via a `/dev/sr0` pread
|
||||
/// fallback instead.
|
||||
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
|
||||
/// - `NOT READY` (2) — on many drives (notably BU40N), this is the
|
||||
/// dominant response for unreadable sectors (ASC 04/3E, 04/01, etc.)
|
||||
/// - `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,
|
||||
/// 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_NOT_READY
|
||||
| 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,103 +348,13 @@ 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;
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
// 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, available
|
||||
// for explicit opt-in) does pure userspace state cleanup with bounded
|
||||
// sleeps — no escape-hatch wrapper required.
|
||||
|
||||
// ── USB-layer recovery: rolled back in 0.13.4 ───────────────────────────────
|
||||
//
|
||||
@@ -416,3 +576,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);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-14
@@ -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(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,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 {
|
||||
@@ -280,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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ pub trait SectorReader: Send {
|
||||
fn capacity(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, _kbs: u16) {}
|
||||
}
|
||||
|
||||
/// SectorReader backed by a file (ISO image).
|
||||
|
||||
+42
-9
@@ -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([
|
||||
|
||||
+32
-12
@@ -1,6 +1,7 @@
|
||||
//! Disc sector verification — read every sector and classify health.
|
||||
|
||||
use crate::disc::{Chapter, DiscTitle};
|
||||
use crate::progress::Progress;
|
||||
use crate::sector::SectorReader;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -77,10 +78,6 @@ impl VerifyResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress callback: (sectors_done, total_sectors, current_status)
|
||||
/// Return false to stop verification early.
|
||||
pub type ProgressFn = Box<dyn FnMut(u64, u64, SectorStatus) -> bool>;
|
||||
|
||||
/// Verify all sectors in a title's extents.
|
||||
/// Reads in batches for speed, falls back to single-sector on failure.
|
||||
/// The progress callback returns false to request early stop.
|
||||
@@ -88,7 +85,7 @@ pub fn verify_title(
|
||||
reader: &mut dyn SectorReader,
|
||||
title: &DiscTitle,
|
||||
batch_sectors: u16,
|
||||
mut on_progress: Option<ProgressFn>,
|
||||
on_progress: Option<&dyn Progress>,
|
||||
) -> VerifyResult {
|
||||
let start = Instant::now();
|
||||
let mut good: u64 = 0;
|
||||
@@ -97,7 +94,6 @@ pub fn verify_title(
|
||||
let mut bad: u64 = 0;
|
||||
let mut ranges: Vec<SectorRange> = Vec::new();
|
||||
let mut sectors_done: u64 = 0;
|
||||
let mut _stopped = false;
|
||||
let mut byte_offset: u64 = 0;
|
||||
|
||||
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
|
||||
@@ -140,9 +136,21 @@ pub fn verify_title(
|
||||
}
|
||||
|
||||
sectors_done += count as u64;
|
||||
if let Some(ref mut cb) = on_progress {
|
||||
if !cb(sectors_done, total_sectors, status) {
|
||||
_stopped = true;
|
||||
if let Some(cb) = on_progress {
|
||||
let pp = crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Verify,
|
||||
work_done: sectors_done,
|
||||
work_total: total_sectors,
|
||||
bytes_good_total: (good + slow + recovered) * 2048,
|
||||
bytes_unreadable_total: bad * 2048,
|
||||
bytes_pending_total: 0,
|
||||
bytes_total_disc: total_sectors * 2048,
|
||||
disc_duration_secs: Some(title.duration_secs),
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: Some(title.duration_secs),
|
||||
main_title_size_bytes: Some(total_sectors * 2048),
|
||||
};
|
||||
if !cb.report(&pp) {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
@@ -214,9 +222,21 @@ pub fn verify_title(
|
||||
}
|
||||
|
||||
sectors_done += 1;
|
||||
if let Some(ref mut cb) = on_progress {
|
||||
if !cb(sectors_done, total_sectors, status) {
|
||||
_stopped = true;
|
||||
if let Some(cb) = on_progress {
|
||||
let pp = crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Verify,
|
||||
work_done: sectors_done,
|
||||
work_total: total_sectors,
|
||||
bytes_good_total: (good + slow + recovered) * 2048,
|
||||
bytes_unreadable_total: bad * 2048,
|
||||
bytes_pending_total: 0,
|
||||
bytes_total_disc: total_sectors * 2048,
|
||||
disc_duration_secs: Some(title.duration_secs),
|
||||
bytes_bad_in_main_title: 0,
|
||||
main_title_duration_secs: Some(title.duration_secs),
|
||||
main_title_size_bytes: Some(total_sectors * 2048),
|
||||
};
|
||||
if !cb.report(&pp) {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
//! 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) -> bool {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
}
|
||||
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` 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_on_error, 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,
|
||||
multipass: 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.
|
||||
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,
|
||||
multipass: 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 ──────────
|
||||
//
|
||||
// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked
|
||||
// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every
|
||||
// multi-sector read fails produces all NonTrimmed output with zero bytes_good.
|
||||
|
||||
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 {
|
||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
||||
chunk.fill((lba & 0xff) as u8);
|
||||
}
|
||||
Ok(buf.len())
|
||||
} else {
|
||||
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_marks_failed_ecc_blocks_as_nontrimmed() {
|
||||
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,
|
||||
multipass: 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));
|
||||
|
||||
// Pass 1 reads every batch at bpt=32 (no batch reduction, no skip-ahead).
|
||||
// BlockSizeFailingReader fails on multi-sector reads but succeeds on single-sector.
|
||||
// Bridge degradation handling retries failed batches as individual sectors,
|
||||
// so all data is recovered as Finished. bytes_good == total_bytes is correct.
|
||||
assert_eq!(
|
||||
result.bytes_good, total_bytes,
|
||||
"Pass 1 at bpt=32 recovers all sectors via single-sector retry on MEDIUM_ERROR"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_pending, 0,
|
||||
"no pending sectors after full recovery"
|
||||
);
|
||||
assert!(result.complete, "complete=true when all sectors recovered");
|
||||
}
|
||||
|
||||
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
|
||||
//
|
||||
// The video-damage-time display needs bytes_unreadable_total (confirmed dead)
|
||||
// separate from bytes_pending_total (might still recover). This test verifies
|
||||
// that a Pass 2 with some confirmed failures produces correct field values.
|
||||
|
||||
#[test]
|
||||
fn test_pass_progress_separates_unreadable_from_pending() {
|
||||
let capacity_sectors: u32 = 128;
|
||||
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,
|
||||
multipass: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok");
|
||||
|
||||
assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors");
|
||||
assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable");
|
||||
assert_eq!(
|
||||
pass1.bytes_pending, total_bytes,
|
||||
"pass1: all sectors NonTrimmed"
|
||||
);
|
||||
|
||||
let last_unreadable = Arc::new(AtomicU64::new(0));
|
||||
let last_pending = Arc::new(AtomicU64::new(0));
|
||||
let last_good = Arc::new(AtomicU64::new(0));
|
||||
let last_dur = Arc::new(AtomicU64::new(0));
|
||||
|
||||
struct SnapshotReporter {
|
||||
unreadable: Arc<AtomicU64>,
|
||||
pending: Arc<AtomicU64>,
|
||||
good: Arc<AtomicU64>,
|
||||
dur: Arc<AtomicU64>,
|
||||
}
|
||||
impl libfreemkv::progress::Progress for SnapshotReporter {
|
||||
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
|
||||
self.unreadable
|
||||
.store(p.bytes_unreadable_total, Ordering::Relaxed);
|
||||
self.pending.store(p.bytes_pending_total, Ordering::Relaxed);
|
||||
self.good.store(p.bytes_good_total, Ordering::Relaxed);
|
||||
if let Some(d) = p.disc_duration_secs {
|
||||
self.dur.store((d * 1000.0) as u64, Ordering::Relaxed);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
let reporter = SnapshotReporter {
|
||||
unreadable: last_unreadable.clone(),
|
||||
pending: last_pending.clone(),
|
||||
good: last_good.clone(),
|
||||
dur: last_dur.clone(),
|
||||
};
|
||||
|
||||
let pass2_opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
progress: Some(&reporter),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let pass2 = disc
|
||||
.copy(&mut reader, &iso_path, &pass2_opts)
|
||||
.expect("pass2 ok");
|
||||
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
assert_eq!(
|
||||
pass2.bytes_good, 0,
|
||||
"pass2: still no good sectors (reader always fails)"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_unreadable > 0,
|
||||
"pass2: some sectors confirmed unreadable"
|
||||
);
|
||||
assert!(
|
||||
pass2.bytes_pending < pass1.bytes_pending,
|
||||
"pass2: fewer pending sectors than pass1"
|
||||
);
|
||||
|
||||
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
|
||||
let observed_pending = last_pending.load(Ordering::Relaxed);
|
||||
assert!(
|
||||
observed_unreadable > 0,
|
||||
"progress should report confirmed unreadable bytes"
|
||||
);
|
||||
assert!(
|
||||
observed_pending == 0 || observed_pending < total_bytes,
|
||||
"pending should shrink as sectors are confirmed unreadable"
|
||||
);
|
||||
|
||||
// Video damage time: unreadable / total * duration
|
||||
// With no titles on synthetic disc, disc_duration_secs = None
|
||||
assert_eq!(
|
||||
last_dur.load(Ordering::Relaxed),
|
||||
0,
|
||||
"synthetic disc has no titles, duration should be None/0"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 10. Damage time calculation (unit test) ────────────────────────────────
|
||||
//
|
||||
// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration
|
||||
// This mirrors the CLI's print_disc_progress logic.
|
||||
|
||||
#[test]
|
||||
fn test_damage_time_calculation() {
|
||||
// 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable
|
||||
let disc_bytes: u64 = 78_800_000_000;
|
||||
let duration_secs: f64 = 9900.0;
|
||||
|
||||
let cases: Vec<(u64, &str)> = vec![
|
||||
(74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible
|
||||
(10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s
|
||||
(100 * 1024 * 1024, "~13s"), // 100 MB → ~13s
|
||||
(1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s
|
||||
];
|
||||
|
||||
for (bad_bytes, label) in cases {
|
||||
let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs;
|
||||
match label {
|
||||
"~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"),
|
||||
"~1.3s" => assert!(
|
||||
(damage_secs - 1.3).abs() < 0.2,
|
||||
"{label}: {damage_secs:.2}s"
|
||||
),
|
||||
"~13s" => assert!(
|
||||
(damage_secs - 13.0).abs() < 1.0,
|
||||
"{label}: {damage_secs:.1}s"
|
||||
),
|
||||
"~134s" => assert!(
|
||||
(damage_secs - 134.0).abs() < 2.0,
|
||||
"{label}: {damage_secs:.0}s"
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 0.25s threshold: how many bad bytes = 0.25s of damage?
|
||||
let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64;
|
||||
assert!(
|
||||
threshold_bytes > 0,
|
||||
"0.25s damage threshold should be > 0 bytes"
|
||||
);
|
||||
// At 9900s / 78.8 GB ≈ 0.25s = ~2 MB
|
||||
let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0);
|
||||
assert!(
|
||||
(expected_mb - 2.0).abs() < 0.5,
|
||||
"0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Regression tests for Pass N (patch) fix — decrypt key inversion bug.
|
||||
//!
|
||||
//! Tests that decrypt_sectors is invoked correctly when opts.decrypt=true.
|
||||
//! The 2026-05-03 bug at `libfreemkv/src/disc/mod.rs:1938-1942` inverted
|
||||
//! the decrypt key arms, causing patch to pass DecryptKeys::None on encrypted discs.
|
||||
|
||||
use libfreemkv::{aacs, decrypt::DecryptKeys};
|
||||
|
||||
/// Test: decrypt_sectors with AACS keys actually decrypts units.
|
||||
#[test]
|
||||
fn decrypt_sectors_with_aacs_keys_works() {
|
||||
// Build an encrypted aligned unit
|
||||
let mut unit = vec![0xFFu8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
unit[0] |= 0xC0;
|
||||
|
||||
// Fill with recognizable pattern
|
||||
for (i, byte) in unit
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.take(aacs::ALIGNED_UNIT_LEN)
|
||||
.skip(1)
|
||||
{
|
||||
*byte = ((i * 3 + 7) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||
|
||||
// Encrypt the unit using AACS algorithm
|
||||
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
||||
|
||||
// Now we have encrypted data - create DecryptKeys with actual keys
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0u32, unit_key)],
|
||||
read_data_key: None,
|
||||
};
|
||||
|
||||
// decrypt_sectors should handle this without error
|
||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &keys, 0);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"decrypt_sectors with AACS keys should not error"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
|
||||
#[test]
|
||||
fn decrypt_sectors_with_none_keys_is_noop() {
|
||||
let mut sector = vec![0x42u8; 2048];
|
||||
|
||||
let keys = DecryptKeys::None;
|
||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
§or[..],
|
||||
&[0x42u8; 2048][..],
|
||||
"DecryptKeys::None should not modify buffer"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: decrypt_sectors with CSS keys descrambles sectors.
|
||||
#[test]
|
||||
fn decrypt_sectors_with_css_keys_works() {
|
||||
let mut sector = vec![0xFFu8; 2048];
|
||||
|
||||
// Set CSS scramble flag (bits 4-5 of byte 0x14)
|
||||
sector[0x14] |= 0x30;
|
||||
|
||||
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later
|
||||
let keys = DecryptKeys::Css { title_key };
|
||||
|
||||
// Descramble (CSS uses same operation for encrypt/decrypt)
|
||||
libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0).unwrap();
|
||||
|
||||
// Flag should be cleared
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared");
|
||||
}
|
||||
|
||||
/// Test: AACS unit encryption detection works.
|
||||
#[test]
|
||||
fn aacs_encryption_flag_detection() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
// No encryption flag
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 6
|
||||
unit[0] |= 0x40;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 7
|
||||
unit[0] = 0x80;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Both bits set
|
||||
unit[0] = 0xC0;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
#[test]
|
||||
fn decrypt_keys_is_encrypted_variants() {
|
||||
let none = DecryptKeys::None;
|
||||
assert!(!none.is_encrypted());
|
||||
|
||||
let aacs = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
};
|
||||
assert!(aacs.is_encrypted());
|
||||
|
||||
let css = DecryptKeys::Css {
|
||||
title_key: [0u8; 5],
|
||||
};
|
||||
assert!(css.is_encrypted());
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Pass N (Disc::patch) size-aware-skip targeted tests.
|
||||
//!
|
||||
//! The user's failure mode (2026-05-07): "what if we have a 100 sector zone
|
||||
//! and its really 2 25 sector zones and we keep jumping over the good in
|
||||
//! the middle." Today's pre-fix patch escalates skip-distance based on
|
||||
//! `consecutive_skips_without_recovery` with hardcoded 32 → 4096 sector
|
||||
//! caps. A 100-sector bad range whose actual layout is 25 bad + 50 good +
|
||||
//! 25 bad would have the patch skip 32-4096 sectors after a couple of
|
||||
//! failures, leaping over the entire range AND the good middle.
|
||||
//!
|
||||
//! The fix: cap each skip at `range_remaining/4`. These tests exercise
|
||||
//! that boundary.
|
||||
|
||||
use libfreemkv::disc::CopyOptions;
|
||||
use libfreemkv::disc::DiscRegion;
|
||||
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorReader};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
/// Reader where you specify exactly which LBAs return Err. Everything else
|
||||
/// returns Ok with the LBA encoded in each byte for verification.
|
||||
struct PatternedSectorReader {
|
||||
capacity: u32,
|
||||
bad_lbas: HashSet<u32>,
|
||||
/// Trace every read so tests can assert what was actually attempted.
|
||||
trace: Arc<Mutex<Vec<(u32, u16)>>>,
|
||||
}
|
||||
|
||||
impl PatternedSectorReader {
|
||||
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, Arc<Mutex<Vec<(u32, u16)>>>) {
|
||||
let trace = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
capacity,
|
||||
bad_lbas,
|
||||
trace: trace.clone(),
|
||||
},
|
||||
trace,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for PatternedSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.trace.lock().unwrap().push((lba, count));
|
||||
// Whole-batch fails if ANY sector in the batch is bad. (Models a
|
||||
// real drive: a multi-sector READ aborts on the first ECC failure.)
|
||||
for offset in 0..count as u32 {
|
||||
if self.bad_lbas.contains(&(lba + offset)) {
|
||||
return 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
||||
chunk.fill((lba & 0xff) as u8);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-populate a mapfile with one large NonTrimmed range so patch's work-
|
||||
/// list has something to do. Caller pre-allocates the ISO at `total_bytes`
|
||||
/// so seeks don't fail.
|
||||
fn prep_iso_and_mapfile(
|
||||
iso_path: &std::path::Path,
|
||||
total_bytes: u64,
|
||||
finished_ranges: &[(u64, u64)],
|
||||
nontrimmed_ranges: &[(u64, u64)],
|
||||
) {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
let mut f = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(iso_path)
|
||||
.unwrap();
|
||||
f.set_len(total_bytes).unwrap();
|
||||
f.seek(SeekFrom::Start(0)).unwrap();
|
||||
f.write_all(&[]).unwrap();
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(iso_path);
|
||||
let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap();
|
||||
for &(pos, size) in finished_ranges {
|
||||
mf.record(pos, size, SectorStatus::Finished).unwrap();
|
||||
}
|
||||
for &(pos, size) in nontrimmed_ranges {
|
||||
mf.record(pos, size, SectorStatus::NonTrimmed).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// THE critical test. A 100-sector "bad" range hides 50 good sectors in
|
||||
/// the middle (LBAs 125-174). Pre-fix patch would skip-escalate at 32+
|
||||
/// sectors and leap over the whole range. Post-fix: skip is capped at
|
||||
/// range_remaining/4 (=25 sectors initially), which forces convergence.
|
||||
#[test]
|
||||
fn patch_recovers_good_middle_of_a_bad_range() {
|
||||
let capacity_sectors: u32 = 1024;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
// Bad range layout: LBAs 100-124 bad, 125-174 GOOD, 175-199 bad.
|
||||
let mut bad_lbas = HashSet::new();
|
||||
for lba in 100..125 {
|
||||
bad_lbas.insert(lba);
|
||||
}
|
||||
for lba in 175..200 {
|
||||
bad_lbas.insert(lba);
|
||||
}
|
||||
|
||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
// Pre-populate: 0..100 already Finished from an imagined Pass 1,
|
||||
// 100..200 NonTrimmed (the range we want patch to retry),
|
||||
// 200..1024 already Finished.
|
||||
let finished = [
|
||||
(0, 100 * 2048),
|
||||
(200 * 2048, (capacity_sectors as u64 - 200) * 2048),
|
||||
];
|
||||
let nontrimmed = [(100 * 2048, 100 * 2048)];
|
||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||
|
||||
// Run patch.
|
||||
// disc.copy() with multipass=true auto-dispatches to patch when the
|
||||
// mapfile already covers the disc and has retryable ranges.
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
..Default::default()
|
||||
};
|
||||
let pr = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok");
|
||||
|
||||
// Re-load mapfile and inspect.
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let map = Mapfile::load(&map_path).unwrap();
|
||||
|
||||
// The good middle (125..175) MUST end up Finished. If size-aware skip
|
||||
// is not enabled, patch would skip 32+ sectors after a few failures
|
||||
// and leap clean over LBA 125 → middle stays NonTrimmed/Unreadable.
|
||||
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
|
||||
let total_finished_in_middle: u64 = finished_ranges
|
||||
.iter()
|
||||
.map(|&(pos, sz)| {
|
||||
let start = pos.max(125 * 2048);
|
||||
let end = (pos + sz).min(175 * 2048);
|
||||
end.saturating_sub(start)
|
||||
})
|
||||
.sum();
|
||||
|
||||
// Allow 2 sectors (4 KB) of boundary slop — patch's bisection may
|
||||
// not converge exactly on the good/bad boundary in a single pass,
|
||||
// and that's acceptable. The pre-fix behaviour would have left the
|
||||
// entire good middle as NonTrimmed (~0 bytes recovered).
|
||||
let good_middle_bytes: u64 = 50 * 2048;
|
||||
let min_acceptable: u64 = good_middle_bytes - 2 * 2048;
|
||||
|
||||
// Cleanup before assertions
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(&map_path);
|
||||
|
||||
assert!(
|
||||
total_finished_in_middle >= min_acceptable,
|
||||
"size-aware skip should have discovered most of the 50 good sectors in the middle. \
|
||||
Recovered {} of {} good middle bytes (min acceptable {}). bytes_good={} bytes_total={}",
|
||||
total_finished_in_middle,
|
||||
good_middle_bytes,
|
||||
min_acceptable,
|
||||
pr.bytes_good,
|
||||
pr.bytes_total,
|
||||
);
|
||||
}
|
||||
|
||||
/// A second test: a bad range that's actually 4 small bad sub-zones
|
||||
/// separated by good sectors. Demonstrates the bisection behaviour
|
||||
/// converges when zones are non-uniform.
|
||||
#[test]
|
||||
fn patch_recovers_multiple_good_middles() {
|
||||
let capacity_sectors: u32 = 2048;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
// Bad pattern: 1000-1024 bad, 1025-1099 good, 1100-1124 bad,
|
||||
// 1125-1199 good, 1200-1224 bad, 1225-1299 good.
|
||||
let mut bad_lbas = HashSet::new();
|
||||
for lba in 1000..1025 {
|
||||
bad_lbas.insert(lba);
|
||||
}
|
||||
for lba in 1100..1125 {
|
||||
bad_lbas.insert(lba);
|
||||
}
|
||||
for lba in 1200..1225 {
|
||||
bad_lbas.insert(lba);
|
||||
}
|
||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
let finished = [
|
||||
(0, 1000 * 2048),
|
||||
(1300 * 2048, (capacity_sectors as u64 - 1300) * 2048),
|
||||
];
|
||||
let nontrimmed = [(1000 * 2048, 300 * 2048)];
|
||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
..Default::default()
|
||||
};
|
||||
let pr = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok");
|
||||
|
||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||
let map = Mapfile::load(&map_path).unwrap();
|
||||
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
|
||||
let recovered: u64 = finished_ranges
|
||||
.iter()
|
||||
.map(|&(pos, sz)| {
|
||||
let start = pos.max(1000 * 2048);
|
||||
let end = (pos + sz).min(1300 * 2048);
|
||||
end.saturating_sub(start)
|
||||
})
|
||||
.sum();
|
||||
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(&map_path);
|
||||
|
||||
// Three good middles of 75 sectors each = 225 good sectors in the
|
||||
// bad range. Total bad = 75. So we want at least most of 225 sectors
|
||||
// (= 460800 bytes) to be Finished after patch.
|
||||
let target = 200 * 2048; // be generous — anything over 200 sectors is convincing
|
||||
assert!(
|
||||
recovered >= target,
|
||||
"size-aware skip should find most of the 3 good middles. \
|
||||
Recovered {} bytes; expected ≥ {}. bytes_good={} bytes_total={}",
|
||||
recovered,
|
||||
target,
|
||||
pr.bytes_good,
|
||||
pr.bytes_total,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! 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 marginal (BU40N returns this for bad sectors)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Tests for SgIoTransport timeout recovery (Fix 2).
|
||||
//!
|
||||
//! When `execute()` detects a transport-level failure (kernel timeout /
|
||||
//! USB bridge wedge — `host_status != 0`), it spawns two background
|
||||
//! threads to (1) close the old fd and (2) open a fresh one, then
|
||||
//! stores the new fd in `fd_recovery`. The next `execute()` call picks
|
||||
//! up the recovered fd without blocking on close().
|
||||
//!
|
||||
//! These tests require a real /dev/sg* device and are therefore #[ignore].
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_sgio_transport_timeout_does_not_kill_transport() {
|
||||
let device = "/dev/sg2";
|
||||
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
|
||||
let _path = Path::new(&device);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use libfreemkv::scsi::ScsiTransport;
|
||||
use libfreemkv::scsi::linux::SgIoTransport;
|
||||
use libfreemkv::scsi::{DataDirection, SCSI_STATUS_TRANSPORT_FAILURE};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut transport = SgIoTransport::open(_path).expect("open device");
|
||||
let fd_before = transport.fd;
|
||||
|
||||
// READ_10 with 1 ms timeout to force kernel timeout.
|
||||
let cdb = [0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let mut data = vec![0u8; 2048];
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = transport.execute(&cdb, DataDirection::FromDevice, &mut data, 1);
|
||||
let _elapsed = start.elapsed();
|
||||
|
||||
assert!(result.is_err(), "expected Err on timeout, got {:?}", result);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, libfreemkv::Error::ScsiError { status, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
"expected ScsiError(TRANSPORT_FAILURE), got {:?}",
|
||||
err
|
||||
);
|
||||
assert_eq!(transport.fd, -1, "fd should be -1 after timeout");
|
||||
|
||||
// Wait for recovery thread to store new fd.
|
||||
let recovered = Arc::new(AtomicI32::new(-1));
|
||||
let recovery_ref = transport.fd_recovery.clone();
|
||||
for _ in 0..100 {
|
||||
let v = recovery_ref.load(Ordering::Acquire);
|
||||
if v >= 0 {
|
||||
recovered.store(v, Ordering::Release);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let new_fd = recovered.load(Ordering::Acquire);
|
||||
assert!(new_fd >= 0, "recovery thread should have produced a new fd");
|
||||
|
||||
// Next execute() must pick up recovered fd quickly.
|
||||
let mut data2 = vec![0u8; 2048];
|
||||
let start2 = std::time::Instant::now();
|
||||
let result2 = transport.execute(&cdb, DataDirection::FromDevice, &mut data2, 5000);
|
||||
let elapsed2 = start2.elapsed();
|
||||
|
||||
assert!(
|
||||
result2.is_ok(),
|
||||
"execute() with recovered fd should succeed, got {:?}",
|
||||
result2
|
||||
);
|
||||
assert!(
|
||||
elapsed2 < Duration::from_secs(2),
|
||||
"should return quickly, took {:?}",
|
||||
elapsed2
|
||||
);
|
||||
assert_ne!(transport.fd, -1, "fd should be valid after recovery");
|
||||
assert_ne!(transport.fd, fd_before, "fd should be fresh after recovery");
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("SKIP: test requires Linux / SgIoTransport");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_drive_read_per_cdb_timeout_bounds_call() {
|
||||
let device = "/dev/sg2";
|
||||
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
|
||||
let _path = Path::new(&device);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::time::Duration;
|
||||
|
||||
let mut drive = libfreemkv::Drive::open(_path).expect("open drive");
|
||||
let timeout_ms: u32 = 5_000;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let _ = drive.read(0, 1, &mut [0u8; 2048], false);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let overhead = Duration::from_millis(500);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(timeout_ms as u64) + overhead,
|
||||
"Drive::read should return within timeout_ms + overhead, took {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("SKIP: test requires Linux / SgIoTransport");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user