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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user