v0.13.21 — bisect-on-fail in Disc::copy + 10s caller READ timeout
Fixes the BU40N wedge cycle that has been chasing us through v0.13.18-20. Two changes, both backed by empirical live-hardware probes recorded in freemkv-private/docs/TEST_PLAN.md: 1. scsi/mod.rs: READ_TIMEOUT_MS 1500 → 10000 ms. Cold-start seek on the BU40N takes ~1.5 s. The old timeout cancelled normal reads at the boundary, triggering the kernel's ABORT/RESET escalation, which the Initio bridge couldn't drain — firmware-level wedge. 10 s catches every legitimate slow read (max successful ECC recovery: 2.6 s; cold-start: 1.5 s) with margin and short-circuits truly bad sectors at ~10 s. 2. disc/mod.rs: Disc::copy bisect-on-fail (replaces skip-forward). Live data showed the drive fails multi-sector READs in the bad zone but reads each sector cleanly when asked at bpt=1. Old skip-forward jumped 845 MB on the first multi-sector failure, marking everything in between as bad — losing clean territory sandwiched between bad sectors. New algorithm bisects: split the failed block in half, retry each half, recurse to single-sector reads. Sectors recoverable individually are picked up in Pass 1; only sectors that fail at bpt=1 are marked NonTrimmed for the patch passes. Stack-based DFS, log2(batch) = 6 levels for the default 60-sector batch. Multi-pass machinery is untouched. Pass 2..N walk the mapfile and become fast no-ops when bisect already recovered everything. Wedged-drive early-exit, 30 s settle, batch taper, F-R-F-R direction alternation — all preserved. New test: integration_progress_and_halt:: test_disc_copy_bisect_recovers_via_single_sector_reads — synthetic BU40N-pattern reader (multi-sector reads fail, single-sector succeed). Pre-patch: lost everything to skip-forward. Post-patch: 100 % bytes_good. Plus the 10 sense-key parser tests from the 0.13.20 test-coverage pass. Empirical recovery on Dune 2 UHD on the BU40N (per TEST_PLAN.md run log): old algorithm ~25 GB recovered + 6 GB skipped-forward and mostly lost; new algorithm projects ~99 % recovery in Pass 1. Audits + raw probe data: - freemkv-private/docs/TEST_PLAN.md (run log) - freemkv-private/docs/audits/2026-04-26-scsi-architecture-research.md
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
//! Integration tests for the SCSI error-decoding contract.
|
||||
//!
|
||||
//! v0.13.20 rewrote `scsi/linux.rs` to a synchronous blocking SG_IO and
|
||||
//! consolidated sense-key parsing into the `parse_sense_key` helper that
|
||||
//! every platform backend now shares. The actual `ioctl(SG_IO, ...)` call
|
||||
//! is impossible to mock without a kernel — see
|
||||
//! `freemkv-private/docs/audits/2026-04-26-scsi-architecture-research.md`
|
||||
//! for why the audit recommends against libc shims here.
|
||||
//!
|
||||
//! These tests pin the *contract* every backend must satisfy:
|
||||
//!
|
||||
//! 1. Healthy result → `Ok(ScsiResult { bytes_transferred = data.len() - resid })`.
|
||||
//! 2. Transport-level failure (`host_status` or `driver_status` non-zero)
|
||||
//! → `Error::ScsiError { status: 0xFF, sense_key: 0 }`. Used by
|
||||
//! `drive_has_disc` to detect the wedge signature.
|
||||
//! 3. SCSI-level failure (status non-zero, sense buffer populated) →
|
||||
//! `Error::ScsiError { status, sense_key }` with the parsed key.
|
||||
//! 4. Sense-key parsing handles descriptor (0x72/0x73) and fixed
|
||||
//! (0x70/0x71) response codes; missing sense data → key 0.
|
||||
//!
|
||||
//! The mock `ScsiTransport` here emulates exactly that layered shape.
|
||||
//! Inline `parse_sense_tests` in `src/scsi/mod.rs` cover the pure parse
|
||||
//! logic; 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, ScsiResult, 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.20 rewrite: pre-parsed sense_key and synthesized 0xFF
|
||||
/// status 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: e.g. `hdr.host_status = DID_TIME_OUT`
|
||||
/// on Linux, or `kIOReturnError` on macOS, or `DeviceIoControl`
|
||||
/// returning 0 on Windows. Backends synthesize 0xFF.
|
||||
TransportFailure,
|
||||
/// SCSI-level failure: device responded with a non-zero status and
|
||||
/// some sense data. `status` and `sense_key` are what the caller
|
||||
/// must see on the `Error::ScsiError` variant.
|
||||
ScsiFailure {
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
},
|
||||
}
|
||||
|
||||
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: 0xFF,
|
||||
sense_key: 0,
|
||||
}),
|
||||
MockOutcome::ScsiFailure { status, sense_key } => Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status,
|
||||
sense_key,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 (host_status or driver_status non-zero) ─────
|
||||
//
|
||||
// Linux: kernel sets `hdr.host_status = DID_TIME_OUT (0x03)` or
|
||||
// `hdr.driver_status` non-zero on a USB bridge wedge. SgIoTransport
|
||||
// synthesizes ScsiError { status: 0xFF, sense_key: 0 }.
|
||||
// macOS: IOKit `ExecuteTaskSync` returns non-zero IOReturn; same shape.
|
||||
// Windows: `DeviceIoControl` returns 0; same shape.
|
||||
//
|
||||
// Callers (drive_has_disc, etc.) match on status == 0xFF as the wedge
|
||||
// signature. This test pins that contract.
|
||||
|
||||
#[test]
|
||||
fn test_transport_failure_surfaces_as_status_0xff_sense_key_0() {
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::TransportFailure]);
|
||||
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
match err {
|
||||
Error::ScsiError {
|
||||
status, sense_key, ..
|
||||
} => {
|
||||
assert_eq!(status, 0xFF, "transport failure must surface as 0xFF");
|
||||
assert_eq!(sense_key, 0, "transport failure has no sense key");
|
||||
}
|
||||
other => panic!("expected ScsiError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. SCSI-level failure with descriptor-format sense (0x72) ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_scsi_failure_descriptor_format_illegal_request() {
|
||||
// A real device returning CHECK CONDITION (status 0x02) with
|
||||
// descriptor-format sense indicating ILLEGAL REQUEST (key 5).
|
||||
// The Linux backend's parse_sense_key reads byte 1; the caller sees
|
||||
// sense_key = 5.
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::ScsiFailure {
|
||||
status: 0x02,
|
||||
sense_key: 5,
|
||||
}]);
|
||||
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
match err {
|
||||
Error::ScsiError {
|
||||
opcode,
|
||||
status,
|
||||
sense_key,
|
||||
} => {
|
||||
assert_eq!(opcode, libfreemkv::scsi::SCSI_INQUIRY);
|
||||
assert_eq!(status, 0x02);
|
||||
assert_eq!(sense_key, 5);
|
||||
}
|
||||
other => panic!("expected ScsiError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. SCSI-level failure with NOT READY sense ───────────────────────────
|
||||
//
|
||||
// `drive_has_disc` matches on sense_key 2 to mean "no disc inserted"
|
||||
// rather than a hard error. Pin that contract via the consumer.
|
||||
|
||||
#[test]
|
||||
fn test_scsi_failure_not_ready_key_2_propagates_intact() {
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::ScsiFailure {
|
||||
status: 0x02,
|
||||
sense_key: 2,
|
||||
}]);
|
||||
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
match err {
|
||||
Error::ScsiError {
|
||||
status, sense_key, ..
|
||||
} => {
|
||||
assert_eq!(status, 0x02);
|
||||
assert_eq!(sense_key, 2);
|
||||
}
|
||||
other => panic!("expected ScsiError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. SCSI-level failure with empty sense ───────────────────────────────
|
||||
//
|
||||
// Backends pass `sb_len_wr` to `parse_sense_key`; when zero, the helper
|
||||
// returns 0. From the caller's perspective this is `sense_key = 0`
|
||||
// (NO SENSE) on a non-zero status — surface that contract.
|
||||
|
||||
#[test]
|
||||
fn test_scsi_failure_empty_sense_returns_key_0() {
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::ScsiFailure {
|
||||
status: 0x02,
|
||||
sense_key: 0,
|
||||
}]);
|
||||
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
match err {
|
||||
Error::ScsiError {
|
||||
status, sense_key, ..
|
||||
} => {
|
||||
assert_eq!(status, 0x02);
|
||||
assert_eq!(sense_key, 0);
|
||||
}
|
||||
other => panic!("expected ScsiError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. 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,
|
||||
}]);
|
||||
|
||||
// Drive INQUIRY through the public helper to exercise the consumer
|
||||
// path; INQUIRY itself doesn't act on bytes_transferred but the
|
||||
// transport contract is what we care about.
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 7. Error::Display does not leak English in the SCSI variant ───────────
|
||||
//
|
||||
// Library rule (CLAUDE.md): no English in error display, only "E{code}"
|
||||
// + structured data. Existing error-mod tests cover the new variants;
|
||||
// this is a 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: 0x02,
|
||||
sense_key: 5,
|
||||
};
|
||||
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"),
|
||||
"ScsiError must show opcode/status/sense_key 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user