Compare commits

...
22 Commits
Author SHA1 Message Date
MattJackson 5c4f575c46 v0.14.0: bump version 2026-04-30 08:45:32 -07:00
MattJackson e2cb7a78bb v0.13.46: damage-jump algorithm replaces probe, bridge degradation detection, ecc_sectors() 2026-04-29 22:08:52 -07:00
MattJackson a6f1bd19bc v0.13.45: multipass adaptive probe, NOT_READY retry, progress bytes_bad_total
- Adaptive probe algorithm in Disc::copy skip_on_error mode: after 4
  consecutive errors, probe 1 sector at 256x batch (8 MB) ahead. If
  good, zero-fill gap, mark NonTrimmed, jump. Clears bad zones in
  seconds instead of hours.
- NOT_READY sense key (0x02) now retries up to 3x with 3s pause before
  marking NonTrimmed. BU40N returns NOT READY for bad sectors, not
  MEDIUM ERROR.
- PassProgress struct gains bytes_bad_total field for consumer-side
  bad/retryable byte counts.
- Mapfile header version string fix: no longer duplicates 'libfreemkv v'
  prefix on each write.
- Structured sense_key/asc/ascq logging in copy error path.
2026-04-29 19:41:34 -07:00
MattJackson 9e3d0f1383 v0.13.44: macOS raw CDB transport via IOKit exclusive access
macOS SCSI transport rewritten from hybrid MMC+pread to single-path
raw CDB dispatch through SCSITaskDeviceInterface. All CDBs (INQUIRY,
READ, REPORT KEY, etc.) now go through ExecuteTaskSync — 1:1 with
the Linux SG_IO backend.

Key changes:
- New macos_shim.c: diskutil unmount → find IOBDServices →
  ObtainExclusiveAccess → raw CDB dispatch. Eliminates Rust-side
  IOKit COM vtable complexity.
- build.rs compiles macos_shim.c via cc into static lib
- macos.rs simplified to three FFI calls (open/close/execute)
- disc/mod.rs: graduated batch restore after errors, skip-ahead
  through bad zones, configurable error pause
2026-04-29 15:59:35 -07:00
MattJackson 1e6eb0698d v0.13.43: Pass 1 transport-failure recovery loop 2026-04-29 08:53:56 -07:00
MattJackson bd744171e4 v0.13.42: transport failure skips instead of aborting copy 2026-04-29 07:46:42 -07:00
MattJackson 7de9d4d42c v0.13.41: debug logging for sector-0 regression 2026-04-29 07:09:46 -07:00
MattJackson 9ac0c2fcdf v0.13.40 2026-04-28 21:50:32 -07:00
MattJackson 3aa27d5d1b fix CI: Rust 2024 match ergonomics - remove ref binding modifier 2026-04-28 21:47:19 -07:00
MattJackson 5083f70cff fix CI: import ScsiTransport trait in integration test 2026-04-28 21:45:34 -07:00
MattJackson 477cf7ee0e fix CI: pub scsi::linux module and SgIoTransport fields for integration tests 2026-04-28 21:43:14 -07:00
MattJackson 0b079e27ce fix CI: make scsi::linux pub(crate) for integration test access 2026-04-28 21:39:50 -07:00
MattJackson 5f69fd2a44 update CHANGELOG and README for v0.13.39 2026-04-28 21:36:37 -07:00
MattJackson befe6ef69b remove (internal) references from public code 2026-04-28 21:32:15 -07:00
MattJackson 29491d6eaf v0.13.39 2026-04-28 21:19:51 -07:00
MattJackson 2e602f7e37 fix scsi_recovery test: dereference status in pattern match 2026-04-28 21:17:11 -07:00
MattJackson a126f31b70 v0.13.38 2026-04-28 21:12:05 -07:00
MattJackson 6ff10df83a fix scsi_recovery test compilation (old Drive::read signature) 2026-04-28 21:11:37 -07:00
MattJackson 52b8522a75 v0.13.37: Pass 1 is pure ECC-block sweep — read 32 sectors, fail → skip, no single-sector reads 2026-04-28 21:08:58 -07:00
MattJackson 13eb4331b6 v0.13.36: remove unused reset() 2026-04-28 17:57:25 -07:00
MattJackson 1a3f7ce676 remove unused reset() 2026-04-28 17:56:42 -07:00
MattJackson e1a1f730be v0.13.35: pause 3s after first MEDIUM ERROR before retry to prevent USB bridge crash 2026-04-28 17:54:12 -07:00
18 changed files with 891 additions and 701 deletions
+61
View File
@@ -1,5 +1,66 @@
# Changelog
## 0.13.43 (2026-04-29)
### Pass 1 transport-failure recovery loop
- Transport failure (USB bridge crash) no longer kills the entire rip.
- Autorip re-discovers the drive after USB re-enumeration and resumes
from the mapfile. Up to 10 attempts.
- `Error::is_scsi_transport_failure()` now matches DiscRead with
status 0xFF (bridge crash) in addition to ScsiError.
- DriveSession tracks device_path for re-discovery.
## 0.13.42 (2026-04-29)
### Fix: transport failure skips instead of aborting
- Transport failure (USB bridge crash) now skips the failed ECC block
(marks NonTrimmed) and continues. 3 consecutive transport failures
still abort the copy. Previously, a single transport failure killed
the entire rip.
## 0.13.41 (2026-04-29)
### Debug logging for sector-0 regression diagnosis
- Add debug logging to Drive::read and Disc::copy first reads.
- No functional changes.
## 0.13.40 (2026-04-28)
### Pass 1 pure ECC-block sweep, transport-failure abort, mapfile-based recovery
This release reworks the sector-copy pipeline to handle unreliable USB-SATA
bridges (notably the Initio INIC-1618L) that crash on MEDIUM ERROR retries.
**Disc::copy() — Pass 1 (ECC-block sweep):**
- Reads `batch` sectors (default 32 = 1 BD ECC block = 64 KB).
- Success → mark Finished. MEDIUM ERROR → zero-fill, mark NonTrimmed, advance.
- Transport failure (host_status=7) → abort immediately, return error.
- No single-sector reads, no retry, no state machine in Pass 1.
**Disc::patch() — Pass 2+ (single-sector recovery):**
- Reads NonTrimmed sectors one at a time with pause between failures.
- Succeeds → mark Finished. Fails → mark Unreadable (or leave for next pass).
- Multi-pass: caller runs patch repeatedly until 0 recovered.
**CopyOptions simplified:**
- Removed `skip_forward`, `cautious_pause_ms`, `BPT1_EXIT_THRESHOLD`.
- Fields: `decrypt`, `resume`, `batch_sectors`, `skip_on_error`, `progress`, `halt`.
**Other fixes since 0.13.26:**
- `open()` just opens the device — no side effects. `drive_has_disc()` is a
standalone TUR, not a probe sequence.
- `enumerate_sg_names()` skips unreadable `/sys` type files.
- SCSI sense data preserved in DiscRead errors (status + key + ASC/ASCQ).
- MapStats splits `bytes_pending` into `nontried` / `retryable`.
- Wallclock rip budget — halt after max(disc_runtime_secs, 3600).
- Patch instrumentation: counters for reads_ok/err, writes_ok/err, finished/unreadable.
- `as_encoded_bytes()` replaces `as_bytes()` for portable OsStr handling.
- Removed inline retry/reset from Drive::read — orchestration layer handles recovery.
- Removed all `(internal)` references from public code.
## 0.13.26 (2026-04-27)
### Extend DiscRead with SCSI status/sense for 30% wedge diagnostics
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.13.34"
version = "0.14.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+312
View File
@@ -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.
+29
View File
@@ -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");
}
}
+1 -1
View File
@@ -102,7 +102,7 @@ Recovery is layered above `Drive::read`, not inside it. Layer 1
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
`(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`.
the stop-wedge postmortem (2026-04-25).
---
+1 -1
View File
@@ -67,7 +67,7 @@ Recovery is layered above `Drive::read`:
streak.
Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was
removed in 0.13.6. See `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`
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.
+2 -2
View File
@@ -133,8 +133,8 @@ implement retry logic.
no eject cycle. The `recovery` flag controls only the per-CDB timeout
(1.5 s vs. 30 s); on any failure it returns `Err(DiscRead)` immediately.
Inline recovery (5× gentle retry → close + SCSI reset + reopen → 5× more)
was removed in 0.13.6. See `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`
for rationale: the inline reset on the LG BU40N (Initio USB-SATA bridge)
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
+1 -1
View File
@@ -318,7 +318,7 @@ impl Mapfile {
let mut w = std::io::BufWriter::new(file);
writeln!(
w,
"# Rescue Logfile. Created by libfreemkv v{}",
"# Rescue Logfile. Created by {}",
self.version
)?;
writeln!(w, "# Current pos / status / pass / pass_time")?;
+167 -112
View File
@@ -1217,14 +1217,14 @@ impl Disc {
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
///
/// # Options
/// - **default** (all false): behavior matches pre-v0.11.21 — uses full
/// drive recovery (may take minutes per bad sector), aborts on error.
/// Mapfile is produced as a side-effect.
/// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the
/// mapfile, and continue. Uses fast reads (no drive-level recovery loop).
/// - **skip_forward** (implies skip_on_error): on block failure, also skip
/// forward by an exponentially-growing amount, marking the jumped region
/// as `non-trimmed` for later trimming/scraping by `Disc::patch`.
/// - **default** (skip_on_error=false): uses full drive recovery (may take
/// minutes per bad sector), aborts on error. Mapfile is produced as a
/// side-effect.
/// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them NonTrimmed
/// in the mapfile, and continue. Reads in `batch`-sector chunks (32 sectors
/// = 1 BD ECC block by default). Failed blocks are marked NonTrimmed for
/// recovery by `Disc::patch`. No single-sector reads in this pass —
/// Pass 1 is pure ECC-block sweep.
/// - **resume**: if the mapfile exists, resume from its state — only
/// `non-tried` ranges are read. Without `resume`, a fresh mapfile is
/// written and the ISO recreated from scratch.
@@ -1249,7 +1249,7 @@ impl Disc {
let _ = std::fs::remove_file(&mapfile_path);
}
let mut map =
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, concat!("libfreemkv v", env!("CARGO_PKG_VERSION")))
.map_err(|e| Error::IoError { source: e })?;
// ISO file: if resuming and mapfile has Finished ranges, open existing;
@@ -1274,7 +1274,7 @@ impl Disc {
let mut file = file;
let batch: u16 = match opts.batch_sectors {
Some(b) => b,
None if opts.skip_forward => 32, // 64 KB = BD ECC block size
None if opts.skip_on_error => 32,
None => DEFAULT_BATCH_SECTORS,
};
@@ -1286,23 +1286,26 @@ impl Disc {
let mut read_ok_count: u64 = 0;
let mut read_err_count: u64 = 0;
let mut last_log_iter: u64 = 0;
// Simple read strategy: start in block mode, drop to 1 sector on any
// failure, then after BPT1_EXIT_THRESHOLD consecutive good single-sector
// reads we try block mode again. This avoids hammering every sector
// in a big bad zone with a slow timeout at block size.
let mut use_single = false;
let mut not_ready_retries: u32 = 0;
const NOT_READY_MAX_RETRIES: u32 = 3;
let mut bridge_degradation_count: u32 = 0;
const BRIDGE_DEGRADATION_MAX: u32 = 5;
const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10;
const DAMAGE_WINDOW: usize = 50;
const DAMAGE_THRESHOLD_PCT: usize = 25;
const JUMP_SECTORS_FACTOR: u64 = 256;
let mut damage_window: Vec<bool> = Vec::with_capacity(DAMAGE_WINDOW);
let mut jump_multiplier: u64 = 1;
let mut consecutive_good: u64 = 0;
tracing::trace!(
target: "freemkv::disc",
phase = "copy_start",
total_bytes,
batch,
bpt1_exit_threshold = BPT1_EXIT_THRESHOLD,
skip_on_error = opts.skip_on_error,
"Disc::copy entered"
);
// Iterate over not-yet-finished regions from the mapfile. We re-read the
// mapfile after each block because record() mutates the region list.
'outer: loop {
let regions_to_do = map.ranges_with(&[
mapfile::SectorStatus::NonTried,
@@ -1318,9 +1321,6 @@ impl Disc {
if regions_to_do.is_empty() {
break;
}
// Only process the first NonTried range per outer pass; skip_forward
// may turn others into NonTrimmed which we DO NOT re-enter here —
// Disc::patch handles those.
let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
else {
break;
@@ -1337,7 +1337,6 @@ impl Disc {
);
while pos < region_end {
// Check halt
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halt_requested = true;
@@ -1345,12 +1344,7 @@ impl Disc {
}
}
// Decide read size: if use_single, read 1 sector; else use batch
let block_bytes = if use_single {
(region_end - pos).min(2048) // 1 sector
} else {
(region_end - pos).min(batch as u64 * 2048)
};
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
let block_lba = (pos / 2048) as u32;
let block_count = (block_bytes / 2048) as u16;
let recovery = !opts.skip_on_error;
@@ -1362,9 +1356,20 @@ impl Disc {
recovery,
);
let mut did_skip_ahead = false;
if read_result.is_ok() {
// Good read — write to file
read_ok_count += 1;
damage_window.push(true);
if damage_window.len() > DAMAGE_WINDOW {
damage_window.remove(0);
}
consecutive_good += 1;
if consecutive_good >= DAMAGE_WINDOW as u64 {
jump_multiplier = 1;
}
bridge_degradation_count = 0;
if opts.decrypt {
crate::decrypt::decrypt_sectors(
&mut buf[..block_bytes as usize],
@@ -1379,17 +1384,7 @@ impl Disc {
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
// If we're in single mode and hit threshold, try block mode again
if use_single {
consecutive_good = consecutive_good.saturating_add(1);
if consecutive_good >= BPT1_EXIT_THRESHOLD {
use_single = false;
consecutive_good = 0;
}
}
} else if !opts.skip_on_error {
// Strict mode: abort
let (status, sense) = read_result
.as_ref()
.err()
@@ -1400,65 +1395,140 @@ impl Disc {
status: Some(status),
sense,
});
} else if use_single {
// Single sector mode — read failed, mark NonTrimmed
} else {
let err = read_result.err().unwrap();
read_err_count += 1;
if err.is_marginal_read()
|| err.scsi_sense().is_some_and(|s| s.is_medium_error())
{
// Disc-related error at 1 sector — bad sector, mark for pass 2
let is_medium = err.scsi_sense().is_some_and(|s| s.is_medium_error());
if is_medium {
tracing::warn!(
target: "freemkv::disc",
phase = "skip_bad_sector",
lba = block_lba,
error = %err,
"MEDIUM ERROR at 1 sector; marking NonTrimmed"
);
}
// Zero fill, mark NonTrimmed
let zero = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
} else {
// Transport error at 1 sector — bail
consecutive_good = 0;
if err.is_scsi_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "transport_failure",
lba = block_lba,
error = %err,
"transport failure (bridge crash); aborting copy — caller should USB reset + resume"
);
return Err(err);
}
// Stay in single mode for next reads
use_single = true;
consecutive_good = 0;
} else {
// Batch mode failed — drop to 1 sector and retry
let err = read_result.err().unwrap();
read_err_count += 1;
if err.is_marginal_read()
|| err.scsi_sense().is_some_and(|s| s.is_medium_error())
{
// Disc-related error — try 1 sector instead
use_single = true;
consecutive_good = 0;
// Don't advance pos — retry this location at 1 sector
if err.is_bridge_degradation() {
if bridge_degradation_count < BRIDGE_DEGRADATION_MAX {
bridge_degradation_count += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation",
lba = block_lba,
degradation_count = bridge_degradation_count,
error = %err,
"bridge degradation; cooling down 10s"
);
std::thread::sleep(std::time::Duration::from_secs(BRIDGE_DEGRADATION_COOLDOWN_SECS));
continue;
}
tracing::warn!(
target: "freemkv::disc",
phase = "bridge_degradation_exhausted",
lba = block_lba,
"bridge degradation retries exhausted; treating as bad sector"
);
}
let sense = err.scsi_sense();
let sense_key = sense.map(|s| s.sense_key).unwrap_or(0);
let asc = sense.map(|s| s.asc).unwrap_or(0);
let ascq = sense.map(|s| s.ascq).unwrap_or(0);
if sense_key == crate::scsi::SENSE_KEY_NOT_READY && not_ready_retries < NOT_READY_MAX_RETRIES {
not_ready_retries += 1;
tracing::warn!(
target: "freemkv::disc",
phase = "not_ready_pause",
lba = block_lba,
sense_key,
asc,
ascq,
retry = not_ready_retries,
"NOT READY; pausing 3s then retrying"
);
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
} else {
// Non-recoverable transport error — bail
return Err(err);
}
not_ready_retries = 0;
tracing::warn!(
target: "freemkv::disc",
phase = "skip_ecc_block",
lba = block_lba,
sectors = block_count,
sense_key,
asc,
ascq,
error = %err,
"ECC block failed; marking NonTrimmed"
);
let zero = vec![0u8; block_bytes as usize];
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero)
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(block_bytes);
damage_window.push(false);
if damage_window.len() > DAMAGE_WINDOW {
damage_window.remove(0);
}
let bad_count = damage_window.iter().filter(|&&b| !b).count();
if damage_window.len() >= DAMAGE_WINDOW
&& bad_count * 100 / damage_window.len() >= DAMAGE_THRESHOLD_PCT
{
let jump_sectors = JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier;
let jump_lba = ((pos / 2048) + jump_sectors) as u32;
let region_end_lba = (region_end / 2048) as u32;
if jump_lba < region_end_lba {
let jump_pos = jump_lba as u64 * 2048;
let gap_start = pos + block_bytes;
let gap_bytes = jump_pos.saturating_sub(gap_start);
let jump_mb = gap_bytes / 1_048_576;
tracing::warn!(
target: "freemkv::disc",
phase = "damage_jump",
from_lba = block_lba,
to_lba = jump_lba,
jump_mb,
bad_pct = bad_count * 100 / damage_window.len(),
multiplier = jump_multiplier,
"25%+ failures in last 50 blocks; jumping ahead"
);
if gap_bytes > 0 {
let zero = vec![0u8; 65536];
let mut filled: u64 = 0;
while filled < gap_bytes {
let chunk = (gap_bytes - filled).min(zero.len() as u64);
file.seek(SeekFrom::Start(gap_start + filled))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&zero[..chunk as usize])
.map_err(|e| Error::IoError { source: e })?;
filled += chunk;
}
map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed)
.map_err(|e| Error::IoError { source: e })?;
bytes_done = bytes_done.saturating_add(gap_bytes);
}
pos = jump_pos;
jump_multiplier *= 2;
did_skip_ahead = true;
}
}
}
// Advance position
pos += block_bytes;
// Progress callback throttling
if !did_skip_ahead {
pos += block_bytes;
}
iter_count += 1;
// Throttled iter telemetry — every 100 inner iterations.
if iter_count - last_log_iter >= 100 {
last_log_iter = iter_count;
let stats = map.stats();
@@ -1484,6 +1554,7 @@ impl Disc {
work_done: pos,
work_total: total_bytes,
bytes_good_total: stats.bytes_good,
bytes_bad_total: stats.bytes_unreadable + stats.bytes_retryable,
bytes_total_disc: total_bytes,
});
}
@@ -1520,25 +1591,9 @@ impl Disc {
#[derive(Default)]
pub struct CopyOptions<'a> {
pub decrypt: bool,
/// Resume from existing mapfile + ISO if present. Without this, any
/// existing mapfile is wiped and the ISO recreated.
pub resume: bool,
/// Override the default block size in sectors. Callers should resolve
/// this with `detect_max_batch_sectors(device_path)` for live drives.
/// When `None`, falls back to 32 sectors (64 KB BD ECC block) in
/// `skip_forward` mode or `DEFAULT_BATCH_SECTORS=60` otherwise.
pub batch_sectors: Option<u16>,
/// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue.
/// Uses fast reads (no drive-level recovery loop).
pub skip_on_error: bool,
/// ddrescue-style exponential skip-forward on block failure. Implies
/// `skip_on_error`. The skipped region is marked `non-trimmed` for later
/// trimming/scraping by `Disc::patch`.
pub skip_forward: bool,
/// Per-iteration progress reporter. v0.13.16 architecture: the library
/// emits a single `PassProgress` shape via the `Progress` trait;
/// consumers compute their own derived percentages / ETAs from it. No
/// more positional `(bytes_good, pos, total)` callbacks.
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
}
@@ -1807,6 +1862,7 @@ impl Disc {
work_done,
work_total,
bytes_good_total: s.bytes_good,
bytes_bad_total: s.bytes_unreadable + s.bytes_retryable,
bytes_total_disc: total_bytes,
});
}
@@ -1846,14 +1902,13 @@ const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS: u16 = 60;
const MIN_BATCH_SECTORS: u16 = 3;
/// Number of consecutive good single-sector reads required to exit
/// `Single` mode (bpt=1) and return to `Block` mode (bpt=batch). 10 000
/// sectors ≈ 20 MB of clean data — long enough that we don't bounce in
/// and out of bpt=1 inside a sparse-bad cluster, short enough that we
/// don't waste much time reading clean territory at bpt=1 after the
/// damaged region ends. Tunable; calibrated from the 2026-04-26 BU40N
/// live test.
const BPT1_EXIT_THRESHOLD: u64 = 10_000;
pub fn ecc_sectors(format: DiscFormat) -> u16 {
match format {
DiscFormat::Uhd | DiscFormat::BluRay => 32,
DiscFormat::Dvd => 16,
DiscFormat::Unknown => 32,
}
}
/// Coarse damage tier for a finished or in-progress rip. Maps the
/// observable signals (bad sector count + lost wallclock playback time)
+17 -1
View File
@@ -433,7 +433,7 @@ impl Drive {
///
/// Inline retry phases (5× gentle + reset+reopen + 5× more) were
/// removed in 0.13.6. Per
/// `(internal)/postmortems/2026-04-25-stop-wedge-and-zero-kbs.md`,
/// the 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
@@ -444,6 +444,14 @@ impl Drive {
} else {
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,
@@ -467,6 +475,14 @@ impl Drive {
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"
);
Err(Error::DiscRead {
sector: lba as u64,
status: Some(status),
+23
View File
@@ -507,9 +507,32 @@ impl Error {
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:
+3
View File
@@ -47,6 +47,9 @@ pub struct PassProgress {
/// Cumulative bytes confirmed clean (`Finished` mapfile state) across
/// every pass run on this rip. Doesn't change across pass boundaries.
pub bytes_good_total: u64,
/// Cumulative bytes marked bad (`NonTrimmed` + `NonScraped` +
/// `Unreadable`) across every pass run on this rip.
pub bytes_bad_total: u64,
/// Total disc capacity in bytes. Constant.
pub bytes_total_disc: u64,
}
+5 -46
View File
@@ -11,8 +11,8 @@
//! This matches what every reference project does: MakeMKV (8 s sync
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
//! devices (30 s `/sys/.../timeout`). See
//! `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`
//! for the full primary-source audit.
//! 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
@@ -64,9 +64,9 @@ const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
pub struct SgIoTransport {
fd: i32,
pub fd: i32,
device_path: std::path::PathBuf,
fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
pub fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
}
impl SgIoTransport {
@@ -106,48 +106,7 @@ impl SgIoTransport {
/// STOP+START UNIT. Both escalations were tried in 0.13.0–0.13.5
/// against the LG BU40N (Initio USB-SATA bridge); both failed to
/// recover wedged drives and made the wedge worse — see
/// `(internal)/postmortems/2026-04-25-bu40n-wedge-recovery.md`.
/// If the drive is genuinely unresponsive, the next workload command
/// fails naturally and the caller surfaces a "physical reconnect
/// required" prompt. Software has no path back from a wedged Initio
/// bridge — only physical replug clears it.
pub fn reset(device: &Path) -> Result<()> {
let c_path = Self::to_c_path(device);
// open + close — make the kernel cancel any SG_IO commands queued
// against a previous fd that didn't close cleanly.
let probe_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if probe_fd >= 0 {
unsafe { libc::close(probe_fd) };
}
// Let the kernel finish that cancellation before we reopen.
std::thread::sleep(std::time::Duration::from_secs(2));
// Fresh fd just to send the unlock command, then close.
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);
}
// ALLOW MEDIUM REMOVAL — clear any tray lock left by a killed
// process whose Drop never ran. Best-effort; ignore result.
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(fd) };
Ok(())
}
/// 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 {
+98 -488
View File
@@ -1,183 +1,41 @@
//! 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` so the kernel block-storage driver releases
//! 2. Find `IOBDServices` directly via `IOServiceMatching` (not IOMedia walk)
//! 3. Create `MMCDeviceInterface` → `SCSITaskDeviceInterface`
//! 4. `ObtainExclusiveAccess`
//! 5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync`
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,
}
// ── 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;
}
// ── 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 +44,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,88 +52,87 @@ impl MacScsiTransport {
dev_str
};
let device_iface = Self::acquire_device_iface(bsd_name)?;
let mut bsd_c = bsd_name.as_bytes().to_vec();
bsd_c.push(0);
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(),
})
}
/// Resolve the BSD name → IOKit SCSITaskDeviceInterface with exclusive
/// access. Returns the COM ref the caller must release.
fn acquire_device_iface(bsd_name: &str) -> Result<ComRef> {
let service = find_scsi_service(bsd_name)?;
// Create IOKit plugin for the MMC device
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: bsd_name.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: bsd_name.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);
return Err(Error::DeviceLocked {
path: bsd_name.to_string(),
kr: kr as u32,
});
}
Ok(device_iface)
}
// `reset()` removed in 0.13.6 — see scsi/mod.rs for rationale.
// `try_recover()` removed in 0.13.20 — userspace handle-recovery on
// task failure was the same anti-pattern stripped from Linux SG_IO
// (see (internal)/docs/audits/2026-04-26-scsi-architecture-research.md).
// Errors bubble up; caller decides whether to reopen the Drive.
}
/// 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`.
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,
});
}
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 ────────────────────────────────────────────────────
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let mut out = Vec::new();
for i in 0..K_DEV_DISK_MAX {
@@ -292,7 +148,6 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
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
{
@@ -308,24 +163,11 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
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;
/// 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];
@@ -341,235 +183,3 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
Err(e) => Err(e),
}
}
impl Drop for MacScsiTransport {
fn drop(&mut self) {
if self.device_iface.is_null() {
return;
}
if self.exclusive {
unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
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: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
// 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 {
// Task-level failure (timeout / IOKit error). Bubble it up;
// the kernel mid-layer has already done what it can.
return Err(Error::ScsiError {
opcode: cdb[0],
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
if task_status != K_SCSI_TASK_STATUS_GOOD as u32 {
// IOKit doesn't surface a "bytes written into sense buffer"
// count the way SG_IO does — pass the buffer's full length
// and let parse_sense inspect byte 0's response code to pick
// descriptor-vs-fixed format.
//
// 0.13.23: carry the full SPC-4 sense triple in
// `Error::ScsiError::sense` so callers can route on
// `ScsiSense::is_medium_error()` etc.
let parsed = super::parse_sense(&sense, sense.len() as u8);
return Err(Error::ScsiError {
opcode: cdb[0],
status: task_status as u8,
sense: Some(parsed),
});
}
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
}
+142
View File
@@ -0,0 +1,142 @@
#include <IOKit/IOKitLib.h>
#include <IOKit/IOCFPlugIn.h>
#include <IOKit/scsi/SCSITaskLib.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
typedef struct {
IOCFPlugInInterface **plugin;
MMCDeviceInterface **mmc;
SCSITaskDeviceInterface **scsi;
int exclusive;
} ShimHandle;
static ShimHandle g_handle = {NULL, NULL, NULL, 0};
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;
}
char cmd[128];
snprintf(cmd, sizeof(cmd), "diskutil unmountDisk force %s 2>/dev/null", bsd_name);
system(cmd);
usleep(500000);
mach_port_t mp;
IOMainPort(0, &mp);
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
io_service_t 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;
}
kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi);
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;
}
+4 -5
View File
@@ -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")]
@@ -56,9 +56,8 @@ pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
///
/// 10 s catches every legitimate slow read with comfortable margin and
/// short-circuits truly bad sectors at ~10 s rather than letting the
/// kernel mid-layer escalate for 30 s+. See run log in
/// `(internal)/docs/TEST_PLAN.md` and the audit at
/// `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`.
/// 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
+21 -41
View File
@@ -351,7 +351,7 @@ fn test_file_sector_reader_round_trip() {
//
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
// regardless of how many reads fail. The only legitimate early exit is the
// halt flag. With `skip_on_error + skip_forward` and a reader that returns
// 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)
@@ -424,7 +424,7 @@ impl SectorReader for FailingSectorReader {
#[test]
fn test_disc_copy_completes_full_disc_with_failing_reader() {
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
// skip_forward, Pass 1 must mark every sector NonTrimmed and return
// 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;
@@ -439,7 +439,7 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() {
let opts = CopyOptions {
decrypt: false,
skip_on_error: true,
skip_forward: true,
..Default::default()
};
@@ -454,9 +454,7 @@ fn test_disc_copy_completes_full_disc_with_failing_reader() {
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Hard bound — even at 0 ms per read, 1024 sectors with skip-forward
// should complete in well under a second on any host. If this test runs
// for minutes, something has regressed (e.g. stall guard reintroduced
// with infinite-loop semantics, or Pass 1 is hanging on each read).
// 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"
@@ -521,7 +519,7 @@ fn test_disc_copy_halts_promptly_on_failing_reader() {
let opts = CopyOptions {
decrypt: false,
skip_on_error: true,
skip_forward: true,
halt: Some(halt),
..Default::default()
};
@@ -553,16 +551,9 @@ fn test_disc_copy_halts_promptly_on_failing_reader() {
// ── 8. Hysteresis recovers data the drive can read individually ──────────
//
// Empirically observed on the LG BU40N: in damaged regions the drive fails
// multi-sector READ commands but reads each sector cleanly when asked one
// at a time. Disc::copy's hysteresis state machine (0.13.22, replaces the
// 0.13.21 bisect-on-fail) drops to bpt=1 on the first multi-sector failure
// and stays there until BPT1_EXIT_THRESHOLD consecutive good single-sector
// reads, then returns to bpt=batch.
//
// Fixture: a reader that returns Err for any read with count > 1, and Ok
// for count == 1. The full disc must recover via the bpt=1 path with
// 100 % bytes_good outcome.
// 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,
@@ -577,15 +568,11 @@ impl SectorReader for BlockSizeFailingReader {
_recovery: bool,
) -> Result<usize> {
if count == 1 {
// Single-sector reads succeed — fill the sector with a marker.
for chunk in buf.chunks_mut(SECTOR_SIZE) {
chunk.fill((lba & 0xff) as u8);
}
Ok(buf.len())
} else {
// Multi-sector reads fail with the BU40N's signature: CHECK
// CONDITION + MEDIUM ERROR. The hysteresis must dispatch on
// this as marginal-read and drop to bpt=1.
Err(libfreemkv::error::Error::ScsiError {
opcode: libfreemkv::scsi::SCSI_READ_10,
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
@@ -604,13 +591,7 @@ impl SectorReader for BlockSizeFailingReader {
}
#[test]
fn test_disc_copy_hysteresis_recovers_via_single_sector_reads() {
// 256 sectors = 0.5 MB. Reader fails any multi-sector read but
// succeeds on bpt=1. The hysteresis path must drop to Single mode on
// the first multi-sector failure and recover every sector at bpt=1.
// Stays in Single mode until BPT1_EXIT_THRESHOLD reached (10 000
// sectors); since this disc is only 256 sectors we never re-enter
// Block mode, which is fine — every sector still recovers.
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;
@@ -626,7 +607,7 @@ fn test_disc_copy_hysteresis_recovers_via_single_sector_reads() {
let opts = CopyOptions {
decrypt: false,
skip_on_error: true,
skip_forward: true,
..Default::default()
};
@@ -637,20 +618,19 @@ fn test_disc_copy_hysteresis_recovers_via_single_sector_reads() {
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
// Bisect must recover every sector — the drive could read each one
// individually, and our algorithm must descend to that.
// Pass 1 reads every batch at bpt=32 (no batch reduction, no skip-ahead).
// BlockSizeFailingReader fails on all batch=32 reads, so every sector
// is marked NonTrimmed. bytes_good=0 is correct — Pass 2 recovers them.
assert_eq!(
result.bytes_good, total_bytes,
"bisect-on-fail must recover every sector via single-sector reads. \
Got bytes_good={} of total {}",
result.bytes_good, total_bytes
);
assert_eq!(
result.bytes_pending, 0,
"no sectors should be left NonTrimmed after a successful bisect"
result.bytes_good, 0,
"Pass 1 at bpt=32 should not recover any sectors when all batches fail"
);
assert!(
result.complete,
"complete=true expected when every sector recovered"
result.bytes_pending > 0,
"all sectors should be NonTrimmed pending Pass 2"
);
assert!(
!result.complete,
"complete=false when sectors remain NonTrimmed"
);
}
+3 -2
View File
@@ -21,6 +21,7 @@ fn test_sgio_transport_timeout_does_not_kill_transport() {
#[cfg(target_os = "linux")]
{
use libfreemkv::scsi::ScsiTransport;
use libfreemkv::scsi::linux::SgIoTransport;
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering};
@@ -34,7 +35,7 @@ fn test_sgio_transport_timeout_does_not_kill_transport() {
let start = std::time::Instant::now();
let result = transport.execute(&cdb, DataDirection::FromDevice, &mut data, 1);
let elapsed = start.elapsed();
let _elapsed = start.elapsed();
assert!(result.is_err(), "expected Err on timeout, got {:?}", result);
let err = result.unwrap_err();
@@ -97,7 +98,7 @@ fn test_drive_read_per_cdb_timeout_bounds_call() {
let timeout_ms: u32 = 5_000;
let start = std::time::Instant::now();
let _ = drive.read(0, &mut [0u8; 2048], Some(timeout_ms));
let _ = drive.read(0, 1, &mut [0u8; 2048], false);
let elapsed = start.elapsed();
let overhead = Duration::from_millis(500);