From ae76aaf0fae08df8e2c1c3e3d3d93b1cf6680d3f Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:28:35 -0700 Subject: [PATCH] =?UTF-8?q?v0.13.24=20=E2=80=94=20MapStats:=20split=20byte?= =?UTF-8?q?s=5Fpending=20into=20nontried=20/=20retryable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bytes_pending was an opaque aggregate of NonTried + NonTrimmed + NonScraped. UIs that wanted a "will retry in Pass 2-N" bucket were stuck showing the entire unread disc as Maybe at pct=0. Adds two granular fields to MapStats: bytes_nontried — Pass 1 hasn't read these yet bytes_retryable — NonTrimmed + NonScraped, Pass 2-N will retry bytes_pending stays for back-compat (= bytes_nontried + bytes_retryable). Also picks up the cargo fmt --check lint that's been red on main CI since v0.13.18 (rustfmt fold differences on a few long format-string layouts; functional no-op). --- CHANGELOG.md | 23 +++++++++++++++++++++++ Cargo.toml | 2 +- src/disc/mapfile.rs | 25 +++++++++++++++++++++++-- src/disc/mod.rs | 12 ++---------- src/drive/mod.rs | 5 ++++- src/error.rs | 12 ++---------- src/lib.rs | 8 ++++---- src/scsi/mod.rs | 6 +++++- tests/scsi_error_decoding.rs | 23 ++++++++++------------- 9 files changed, 74 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b799f32..999180f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.13.24 (2026-04-27) + +### MapStats: split `bytes_pending` into `bytes_nontried` + `bytes_retryable` + +`MapStats.bytes_pending` aggregates `NonTried` (sectors Pass 1 hasn't +reached) + `NonTrimmed` + `NonScraped` (sectors flagged for Pass 2-N +retry). UIs that wanted a "MAYBE / will retry" bucket were stuck +showing the entire unread disc as "Maybe" at pct=0. + +v0.13.24 keeps `bytes_pending` for back-compat and adds two granular +fields: + + - `bytes_nontried` — Pass 1 hasn't read these yet + - `bytes_retryable` — `NonTrimmed + NonScraped`, Pass 2-N will retry + +`bytes_pending == bytes_nontried + bytes_retryable` (invariant). + +### cargo fmt cleanup + +Picks up the `cargo fmt --check` lint failure that's been red on +`main` since v0.13.18 (long format-string layouts the local rustfmt +folded differently from CI's runner). + ## 0.13.23 (2026-04-27) ### Stop discarding the drive's SCSI sense data diff --git a/Cargo.toml b/Cargo.toml index 142e6d3..e8d6bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.13.23" +version = "0.13.24" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs index d709c17..54a5a05 100644 --- a/src/disc/mapfile.rs +++ b/src/disc/mapfile.rs @@ -69,12 +69,28 @@ 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, } /// Write-through mapfile. Every `record()` persists to disk immediately @@ -275,8 +291,13 @@ impl Mapfile { 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; } } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index cd53005..a55527b 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1393,11 +1393,7 @@ impl Disc { // Fast path — full block read cleanly. read_ok_count += 1; if opts.decrypt { - crate::decrypt::decrypt_sectors( - &mut buf[..block_bytes_usz], - &keys, - 0, - )?; + crate::decrypt::decrypt_sectors(&mut buf[..block_bytes_usz], &keys, 0)?; } file.seek(SeekFrom::Start(pos)) .map_err(|e| Error::IoError { source: e })?; @@ -1496,11 +1492,7 @@ impl Disc { read_ok_count += 1; consecutive_good = consecutive_good.saturating_add(1); if opts.decrypt { - crate::decrypt::decrypt_sectors( - &mut buf[..one_bytes], - &keys, - 0, - )?; + crate::decrypt::decrypt_sectors(&mut buf[..one_bytes], &keys, 0)?; } file.seek(SeekFrom::Start(s_pos)) .map_err(|e| Error::IoError { source: e })?; diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 343cb44..7028909 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -241,7 +241,10 @@ impl Drive { 5_000, ) { Ok(_) => DriveStatus::DiscPresent, - Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready() || s.is_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, diff --git a/src/error.rs b/src/error.rs index da7080d..25031d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -389,13 +389,7 @@ impl std::fmt::Display for Error { s.asc, s.ascq, ), - None => write!( - f, - "E{}: 0x{:02x}/0x{:02x}", - self.code(), - opcode, - status, - ), + 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), @@ -469,9 +463,7 @@ impl Error { /// no sense data exists). pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> { match self { - Error::ScsiError { - sense: Some(s), .. - } => Some(s), + Error::ScsiError { sense: Some(s), .. } => Some(s), _ => None, } } diff --git a/src/lib.rs b/src/lib.rs index 191d3c6..26e2b71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,10 +134,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, - DamageSeverity, Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, - KeySource, LabelPurpose, LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, - SubtitleStream, VideoStream, classify_damage, + 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 ──────────────────────────────────────────────────────────────── diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index 96eb033..b37c3e9 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -620,7 +620,11 @@ mod parse_sense_tests { // 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"); + 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); } diff --git a/tests/scsi_error_decoding.rs b/tests/scsi_error_decoding.rs index d86dcdf..a38d151 100644 --- a/tests/scsi_error_decoding.rs +++ b/tests/scsi_error_decoding.rs @@ -37,10 +37,10 @@ use libfreemkv::error::Error; use libfreemkv::scsi::{ - DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiResult, - ScsiSense, ScsiTransport, 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, + 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 @@ -60,10 +60,7 @@ 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, - resid: i32, - }, + Ok { data: Vec, 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` @@ -71,10 +68,7 @@ enum MockOutcome { TransportFailure, /// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION` /// + a populated sense buffer). - ScsiFailure { - status: u8, - sense: ScsiSense, - }, + ScsiFailure { status: u8, sense: ScsiSense }, } impl MockTransport { @@ -360,7 +354,10 @@ fn test_scsi_error_display_format_is_codes_only() { }), }; let s = err.to_string(); - assert!(s.starts_with("E4000:"), "ScsiError must lead with E4000: {s}"); + 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}"