v0.13.24 — MapStats: split bytes_pending into nontried / retryable

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).
This commit is contained in:
2026-04-26 19:28:35 -07:00
parent 0cb497b431
commit c16fb8ac9a
9 changed files with 74 additions and 42 deletions
+23
View File
@@ -1,5 +1,28 @@
# Changelog # 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) ## 0.13.23 (2026-04-27)
### Stop discarding the drive's SCSI sense data ### Stop discarding the drive's SCSI sense data
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "0.13.23" version = "0.13.24"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+23 -2
View File
@@ -69,12 +69,28 @@ pub struct MapEntry {
} }
/// Summary statistics over all entries. /// 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)] #[derive(Debug, Clone, Copy, Default)]
pub struct MapStats { pub struct MapStats {
pub bytes_total: u64, pub bytes_total: u64,
pub bytes_good: u64, pub bytes_good: u64,
pub bytes_unreadable: u64, pub bytes_unreadable: u64,
pub bytes_pending: 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 /// Write-through mapfile. Every `record()` persists to disk immediately
@@ -275,8 +291,13 @@ impl Mapfile {
match e.status { match e.status {
SectorStatus::Finished => s.bytes_good += e.size, SectorStatus::Finished => s.bytes_good += e.size,
SectorStatus::Unreadable => s.bytes_unreadable += e.size, SectorStatus::Unreadable => s.bytes_unreadable += e.size,
SectorStatus::NonTried | SectorStatus::NonTrimmed | SectorStatus::NonScraped => { SectorStatus::NonTried => {
s.bytes_pending += e.size s.bytes_pending += e.size;
s.bytes_nontried += e.size;
}
SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
s.bytes_pending += e.size;
s.bytes_retryable += e.size;
} }
} }
} }
+2 -10
View File
@@ -1393,11 +1393,7 @@ impl Disc {
// Fast path — full block read cleanly. // Fast path — full block read cleanly.
read_ok_count += 1; read_ok_count += 1;
if opts.decrypt { if opts.decrypt {
crate::decrypt::decrypt_sectors( crate::decrypt::decrypt_sectors(&mut buf[..block_bytes_usz], &keys, 0)?;
&mut buf[..block_bytes_usz],
&keys,
0,
)?;
} }
file.seek(SeekFrom::Start(pos)) file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?; .map_err(|e| Error::IoError { source: e })?;
@@ -1496,11 +1492,7 @@ impl Disc {
read_ok_count += 1; read_ok_count += 1;
consecutive_good = consecutive_good.saturating_add(1); consecutive_good = consecutive_good.saturating_add(1);
if opts.decrypt { if opts.decrypt {
crate::decrypt::decrypt_sectors( crate::decrypt::decrypt_sectors(&mut buf[..one_bytes], &keys, 0)?;
&mut buf[..one_bytes],
&keys,
0,
)?;
} }
file.seek(SeekFrom::Start(s_pos)) file.seek(SeekFrom::Start(s_pos))
.map_err(|e| Error::IoError { source: e })?; .map_err(|e| Error::IoError { source: e })?;
+4 -1
View File
@@ -241,7 +241,10 @@ impl Drive {
5_000, 5_000,
) { ) {
Ok(_) => DriveStatus::DiscPresent, 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::NotReady
} }
_ => DriveStatus::Unknown, _ => DriveStatus::Unknown,
+2 -10
View File
@@ -389,13 +389,7 @@ impl std::fmt::Display for Error {
s.asc, s.asc,
s.ascq, s.ascq,
), ),
None => write!( None => write!(f, "E{}: 0x{:02x}/0x{:02x}", self.code(), opcode, status,),
f,
"E{}: 0x{:02x}/0x{:02x}",
self.code(),
opcode,
status,
),
}, },
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source), Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector), Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
@@ -469,9 +463,7 @@ impl Error {
/// no sense data exists). /// no sense data exists).
pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> { pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> {
match self { match self {
Error::ScsiError { Error::ScsiError { sense: Some(s), .. } => Some(s),
sense: Some(s), ..
} => Some(s),
_ => None, _ => None,
} }
} }
+4 -4
View File
@@ -134,10 +134,10 @@ pub use decrypt::{DecryptKeys, decrypt_sectors};
// background. The codec / channel / resolution enums are the canonical // background. The codec / channel / resolution enums are the canonical
// structured representation; never compare against display strings. // structured representation; never compare against display strings.
pub use disc::{ pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
DamageSeverity, Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
KeySource, LabelPurpose, LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
SubtitleStream, VideoStream, classify_damage, classify_damage,
}; };
// ─── Streams ──────────────────────────────────────────────────────────────── // ─── Streams ────────────────────────────────────────────────────────────────
+5 -1
View File
@@ -620,7 +620,11 @@ mod parse_sense_tests {
// independently of the response code. parse_sense_key must mask // independently of the response code. parse_sense_key must mask
// it off before classifying the format. // it off before classifying the format.
let s = buf(0xF2, 0x05, 0x77); 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); let s = buf(0xF0, 0x77, 0x02);
assert_eq!(parse_sense_key(&s, 18), 2); assert_eq!(parse_sense_key(&s, 18), 2);
} }
+10 -13
View File
@@ -37,10 +37,10 @@
use libfreemkv::error::Error; use libfreemkv::error::Error;
use libfreemkv::scsi::{ use libfreemkv::scsi::{
DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiResult, DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE,
ScsiSense, ScsiTransport, SENSE_KEY_ABORTED_COMMAND, SENSE_KEY_DATA_PROTECT, SENSE_KEY_ABORTED_COMMAND, SENSE_KEY_DATA_PROTECT, SENSE_KEY_HARDWARE_ERROR,
SENSE_KEY_HARDWARE_ERROR, SENSE_KEY_ILLEGAL_REQUEST, SENSE_KEY_MEDIUM_ERROR, SENSE_KEY_ILLEGAL_REQUEST, SENSE_KEY_MEDIUM_ERROR, SENSE_KEY_NOT_READY,
SENSE_KEY_NOT_READY, SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, ScsiResult, ScsiSense, ScsiTransport,
}; };
/// A scripted ScsiTransport. Each `execute()` consumes the next entry /// 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 /// Healthy completion. `data` is what the transport wrote into the
/// caller's data buffer (truncated to the buffer length); `resid` /// caller's data buffer (truncated to the buffer length); `resid`
/// is reported back as `data.len() - bytes_transferred`. /// is reported back as `data.len() - bytes_transferred`.
Ok { Ok { data: Vec<u8>, resid: i32 },
data: Vec<u8>,
resid: i32,
},
/// Transport-level failure: `hdr.host_status = DID_TIME_OUT` on /// Transport-level failure: `hdr.host_status = DID_TIME_OUT` on
/// Linux, `kIOReturnError` on macOS, `DeviceIoControl` returning 0 /// Linux, `kIOReturnError` on macOS, `DeviceIoControl` returning 0
/// on Windows. Backends synthesise `SCSI_STATUS_TRANSPORT_FAILURE` /// on Windows. Backends synthesise `SCSI_STATUS_TRANSPORT_FAILURE`
@@ -71,10 +68,7 @@ enum MockOutcome {
TransportFailure, TransportFailure,
/// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION` /// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION`
/// + a populated sense buffer). /// + a populated sense buffer).
ScsiFailure { ScsiFailure { status: u8, sense: ScsiSense },
status: u8,
sense: ScsiSense,
},
} }
impl MockTransport { impl MockTransport {
@@ -360,7 +354,10 @@ fn test_scsi_error_display_format_is_codes_only() {
}), }),
}; };
let s = err.to_string(); 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!( assert!(
s.contains("0x12") && s.contains("0x02") && s.contains("0x05") && s.contains("0x24"), s.contains("0x12") && s.contains("0x02") && s.contains("0x05") && s.contains("0x24"),
"ScsiError must show opcode/status/key/asc in hex: {s}" "ScsiError must show opcode/status/key/asc in hex: {s}"