Files
libfreemkv/src/io/writeback_file/mod.rs
T
MattJackson f468237279 io: promote WritebackFile to pub
Bounded-cache buffered File replacement used across mux/extract/sweep/patch
-- general I/O infrastructure, not recovery policy. freemkv-engine's
relocated sweep/patch need to construct it directly once those methods
leave this crate. No behavior change.
2026-07-28 11:36:43 -07:00

647 lines
27 KiB
Rust

//! `WritebackFile` — a `File` wrapper whose reason for existing is the
//! bounded-cache writeback pipeline.
//!
//! Why: large sequential writes (sweep, patch, mux on UHD-scale output)
//! left to the kernel's default writeback policy accumulate hundreds of
//! megabytes of dirty pages and then burst-flush, stalling subsequent
//! writes for seconds at a time. `WritebackFile` drives a continuous
//! [`super::writeback::WritebackPipeline`] that on Linux issues
//! incremental `sync_file_range` + `posix_fadvise(DONTNEED)` calls at
//! 32 MB granularity so dirty pages drain at the same rate they're
//! produced. macOS and Windows fall through to a no-op pipeline — their
//! default cache policies have not been shown to exhibit the same
//! pathology for this access pattern.
//!
//! It implements `Write` and `Seek` so any call site that wrote to a
//! plain `File` through those traits (sweep, patch, mux) can swap in
//! `WritebackFile` without touching the body of the loop. The wrapper
//! also tracks the current file position to feed the pipeline with
//! progress + seek boundaries.
//!
//! See `super::writeback::linux` for the underlying pathology and the
//! strategy.
//!
//! ## Platform split
//!
//! The platform-specific pieces of this wrapper — extent preallocation
//! (Linux `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows no-op
//! today) and the durable-flush primitive (Linux/macOS
//! `fsync`/`F_FULLFSYNC` wrapped in a bounded syscall; Windows plain
//! `FlushFileBuffers`, unbounded) — live in per-OS sibling modules. The
//! dispatch happens once at the bottom of this file via cfg-gated `mod`
//! decls. No inline `#[cfg(target_os = "...")]` in the business-logic
//! above.
//!
//! ## Write path
//!
//! Writes are direct passthrough to the underlying `File` (no writer
//! thread, no ring, no batching). Empirically a writer-thread
//! architecture introduced a ~60% mux throughput regression on NFS
//! bidirectional workloads; the direct-passthrough write path is faster.
//! The writeback pipeline still runs (it's called inline from `write` /
//! `write_all` / `seek`) so the bounded-cache invariant on Linux is
//! preserved.
//!
//! ## Halt-safety
//!
//! `sync_all` runs the per-OS durable-flush primitive. On Linux/macOS
//! it is wrapped in [`crate::io::bounded::bounded_syscall`] with a 60 s
//! deadline, so a wedged NFS server cannot trap the muxer indefinitely
//! on the final fsync. Windows is a known deviation: its `durable_sync`
//! calls `File::sync_all` (`FlushFileBuffers`) directly and is NOT
//! bounded — a wedged UNC/SMB share can block the final flush there.
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
mod other;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
use other as platform;
#[cfg(target_os = "windows")]
use windows as platform;
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::Path;
use super::writeback::WritebackPipeline;
/// Granularity at which the Linux writeback pipeline issues
/// `sync_file_range` pairs. 32 MiB is the empirically best value on a
/// 1 GbE NFS mount backed by a single spinning disk: 8 MiB / 64 MiB /
/// 128 MiB all measured worse. Override via `FREEMKV_WRITEBACK_CHUNK_MIB`
/// — faster backends (NVMe, RAID) may tolerate larger windows.
const WRITEBACK_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024;
/// Upper bound (in MiB) accepted from `FREEMKV_WRITEBACK_CHUNK_MIB`.
/// 64 GiB — far above `CHUNK_BYTES_MAX` (256 MiB), generous for any
/// real backend, and small enough that `n * 1024 * 1024` cannot wrap
/// `u64`. Out-of-range values fall back to the default.
const WRITEBACK_CHUNK_MIB_MAX: u64 = 64 * 1024;
fn writeback_chunk_bytes() -> u64 {
std::env::var("FREEMKV_WRITEBACK_CHUNK_MIB")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&n| n > 0 && n <= WRITEBACK_CHUNK_MIB_MAX)
.map(|n| n * 1024 * 1024)
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
}
pub struct WritebackFile {
file: File,
pipeline: WritebackPipeline,
pos: u64,
/// Count of position-moving seeks (for the finalize summary). The MKV muxer
/// seeks back occasionally (cluster size patching, Cues, Segment header
/// backpatch); the per-seek DEBUG line is trace-level now, and this rolls
/// the total into one finalize summary.
seek_count: u64,
/// Sum of |delta| over all position-moving seeks, in bytes.
seek_bytes: u64,
}
impl WritebackFile {
/// Wrap an open `File`. The current OS file position is queried
/// once so the pipeline starts tracking from wherever the file
/// already is (typically 0 for fresh files; non-zero for resumed
/// or appended files).
pub fn new(mut file: File) -> io::Result<Self> {
let pos = file.stream_position()?;
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
Ok(Self {
file,
pipeline,
pos,
seek_count: 0,
seek_bytes: 0,
})
}
/// Create a new file at `path` (truncating any existing contents)
/// and wrap it. Convenience for the common
/// `File::create(path)` + `WritebackFile::new(file)` pair so callers
/// don't have to assemble a `File` first.
///
/// Callers that know the target output size should prefer
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
/// extents.
#[allow(dead_code)]
pub fn create(path: &Path) -> io::Result<Self> {
let file = File::create(path)?;
Self::new(file)
}
/// Like [`Self::create`] but pre-reserves `size_bytes` of disk
/// space via the platform's extent-preallocation primitive (Linux
/// `fallocate(KEEP_SIZE)`, macOS `F_PREALLOCATE`, Windows
/// `SetFileValidData` stub). The reported file size is unchanged
/// (writes still grow the file naturally) — only the on-disk extent
/// allocation is preallocated, which reduces extent fragmentation
/// on large sequential writes (mux output, especially on slow
/// storage / NFS).
///
/// On platforms without an extent-preallocation primitive this is
/// equivalent to `create` — the size hint is dropped after a debug
/// log.
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
let file = File::create(path)?;
platform::preallocate(&file, size_bytes);
Self::new(file)
}
/// Open an existing file at `path` for writing (no truncation) and
/// wrap it. Mirrors `File::open` semantics for the writable case
/// — used by patch / resume paths that mutate an existing ISO in
/// place.
pub fn open(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new().write(true).open(path)?;
Self::new(file)
}
/// Drain in-flight writeback then issue a full fsync. Use this in
/// place of `File::sync_all`.
///
/// The final durable flush is wrapped in
/// [`crate::io::bounded::bounded_syscall`] (per the per-OS module)
/// with a 60 s deadline on Linux/macOS — a wedged NFS server cannot
/// trap the calling thread indefinitely. On timeout the page cache
/// is left to the kernel's normal flush-on-close path — best
/// effort, but bounded.
///
/// IMPORTANT: on Linux/macOS a successful `Ok(())` does NOT
/// guarantee the data is durable if the bounded fsync timed out or
/// was halted — only the hang is bounded, the fsync may not have
/// completed. Callers needing crash-consistency (e.g. mux-finish
/// then external commit/DB update) must not treat `Ok(())` as a
/// durability barrier.
pub fn sync_all(&mut self) -> io::Result<()> {
if self.seek_count > 0 {
tracing::debug!(
target: "mux",
"WritebackFile finalize: {} seeks, {} bytes seeked total",
self.seek_count,
self.seek_bytes
);
}
self.pipeline.finalize();
platform::durable_sync(&self.file)
}
}
impl Write for WritebackFile {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let n = self.file.write(buf)?;
self.pos += n as u64;
self.pipeline.note_progress(self.pos);
Ok(n)
}
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
self.file.write_all(buf)?;
self.pos += buf.len() as u64;
self.pipeline.note_progress(self.pos);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
impl Seek for WritebackFile {
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
let p = self.file.seek(from)?;
// Only treat seeks that actually move the position as
// boundaries — sweep does a redundant `seek(Current(pos))`
// before every write, and we don't want that to drain the
// pipeline on every iteration.
if p != self.pos {
// Diagnostic for the NFS mux hang: the MKV format requires
// the muxer to seek back occasionally (cluster size
// patching, Cues index write, Segment header backpatch).
// Each such seek invalidates the writeback chunk tracking
// and forces a finalize → WAIT_AFTER on the in-flight
// chunk. Logging the seek delta lets us correlate hang
// offsets with specific muxer operations.
let from_pos = self.pos;
let to_pos = p;
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
// Per-seek detail is trace-level (L4) — benign and high-frequency.
// The aggregate (count + total bytes) is logged once at finalize.
tracing::trace!(
target: "mux",
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
);
self.seek_count += 1;
self.seek_bytes += delta.unsigned_abs();
self.pipeline.handle_seek(p);
self.pos = p;
}
Ok(p)
}
}
impl super::sink::SequentialSink for WritebackFile {
/// Drain the writeback pipeline and run the bounded durable flush —
/// the same work [`Self::sync_all`] does. Implemented explicitly (no
/// blanket impl) so a `dyn SequentialSink` / `dyn RandomAccessSink`
/// `finish()` actually finalises + fsyncs instead of hitting a no-op
/// default. Note the bounded-fsync caveat from [`Self::sync_all`]
/// applies: `Ok(())` is not a durability barrier if the fsync timed
/// out or was halted.
fn finish(&mut self) -> io::Result<()> {
self.sync_all()
}
}
impl super::sink::RandomAccessSink for WritebackFile {}
impl Drop for WritebackFile {
fn drop(&mut self) {
// Run the pipeline's tail finalize so the last in-flight chunk
// gets its `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. Without
// this, callers that drop a `WritebackFile` without calling
// `sync_all` (panic, early-return, idiomatic `let _ = w;`)
// leave the trailing chunk in cache; the kernel still flushes
// on close, but the bounded-cache invariant fails at the tail.
// We deliberately do *not* call `self.file.sync_all()` here —
// close already triggers a flush, and an `fsync` from `Drop`
// would silently swallow its `io::Error` anyway. `finalize` is
// idempotent so an explicit `sync_all` followed by drop is
// still safe.
self.pipeline.finalize();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn read_back(path: &Path) -> Vec<u8> {
let mut f = File::open(path).unwrap();
let mut v = Vec::new();
f.read_to_end(&mut v).unwrap();
v
}
#[test]
fn write_then_drop_persists_bytes() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("a.bin");
{
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"hello world").unwrap();
// Drop drains the pipeline tail.
}
assert_eq!(read_back(&p), b"hello world");
}
#[test]
fn sync_all_drains_and_flushes() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("b.bin");
let mut w = WritebackFile::create(&p).unwrap();
for _ in 0..32 {
w.write_all(&[0x5au8; 1024]).unwrap();
}
// After sync_all, the bytes MUST be visible to a separate
// reader. The pipeline has been finalised and durable-sync has
// run.
w.sync_all().unwrap();
let bytes = read_back(&p);
assert_eq!(bytes.len(), 32 * 1024);
assert!(bytes.iter().all(|&b| b == 0x5a));
drop(w);
}
#[test]
fn seek_then_patch_roundtrip() {
// Write A; seek back; patch with B; read back; the patch lands
// at the right offset.
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("c.bin");
let mut w = WritebackFile::create(&p).unwrap();
let big = vec![b'A'; 4096];
w.write_all(&big).unwrap();
// Seek back to offset 1000 and overwrite 8 bytes.
w.seek(SeekFrom::Start(1000)).unwrap();
w.write_all(b"PATCHED!").unwrap();
w.sync_all().unwrap();
drop(w);
let bytes = read_back(&p);
assert_eq!(bytes.len(), 4096);
assert_eq!(&bytes[1000..1008], b"PATCHED!");
// Bytes outside the patch are still 'A'.
assert_eq!(bytes[999], b'A');
assert_eq!(bytes[1008], b'A');
}
#[test]
fn flush_is_observed_in_order() {
// `Write::flush` should not panic or reorder; verify the bytes
// land in order through interleaved flushes.
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"one").unwrap();
w.flush().unwrap();
w.write_all(b"two").unwrap();
w.flush().unwrap();
w.write_all(b"three").unwrap();
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), b"onetwothree");
}
/// finish() through a `dyn RandomAccessSink` trait object must
/// dispatch to WritebackFile's override (finalize + durable_sync),
/// not a no-op default. Bytes must be visible to a separate reader
/// before drop.
#[test]
fn finish_through_trait_object_persists() {
use crate::io::sink::RandomAccessSink;
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("finish-dyn.bin");
let w = WritebackFile::create(&p).unwrap();
let mut boxed: Box<dyn RandomAccessSink> = Box::new(w);
boxed.write_all(b"durable-tail").unwrap();
boxed.finish().unwrap();
assert_eq!(read_back(&p), b"durable-tail");
}
// ── Added hardening tests ───────────────────────────────────────
/// `write` (not write_all) must return the count the inner File
/// reported and advance `pos` by exactly that count (lines
/// 185-189). For a regular file a single `write` of a small buffer
/// writes all of it. We verify the returned count equals the buffer
/// length AND that a subsequent seek reports the right position.
/// Mutation: changing `self.pos += n` to `self.pos += buf.len()`
/// (lines 187 vs a hypothetical bug) would desync on a partial
/// write; here they coincide, but `Seek(Current(0))` reflecting `n`
/// still guards the count return value.
#[test]
fn write_returns_byte_count_and_advances_pos() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("wc.bin");
let mut w = WritebackFile::create(&p).unwrap();
let n = w.write(b"twelve bytes").unwrap();
assert_eq!(n, 12, "write must report bytes written");
// pos is private; observe it via the public Seek impl's
// stream_position (which resolves to seek(Current(0))).
let pos = w.stream_position().unwrap();
assert_eq!(pos, 12, "pos not advanced by write count");
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), b"twelve bytes");
}
/// Redundant seek to the CURRENT position must be a no-op for the
/// pipeline (lines 211-228 only act when `p != self.pos`). This is
/// the documented sweep optimisation: sweep does
/// `seek(Current(pos))` before every write and we must not treat it
/// as a boundary. We can only observe the public effect: the seek
/// returns the same offset and writes continue contiguously.
/// Mutation: removing the `if p != self.pos` guard (line 211) would
/// call handle_seek on every redundant seek — on the noop pipeline
/// (macOS) this stays correct for data, but the contiguity +
/// returned-offset invariant still must hold and is asserted here.
#[test]
fn seek_to_current_position_is_noop_for_data() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("noop-seek.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"AAAA").unwrap();
// Seek to the current end (offset 4) — a no-move seek.
let off = w.seek(SeekFrom::Start(4)).unwrap();
assert_eq!(off, 4);
w.write_all(b"BBBB").unwrap();
w.sync_all().unwrap();
drop(w);
assert_eq!(
read_back(&p),
b"AAAABBBB",
"redundant seek corrupted contiguous write"
);
}
/// `open` (no-truncate) must preserve existing file contents and
/// allow in-place patching from offset 0 — distinct from `create`
/// which truncates (lines 157-160 use OpenOptions write-only, no
/// truncate). We pre-seed a file, reopen with `open`, overwrite the
/// first bytes, and confirm the tail survives. Mutation: if `open`
/// used `File::create` (truncate) the tail would be lost.
#[test]
fn open_preserves_existing_contents() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("reopen.bin");
std::fs::write(&p, b"ORIGINAL-CONTENT").unwrap();
let mut w = WritebackFile::open(&p).unwrap();
// open() does NOT truncate; pos starts at 0. Overwrite the
// first 8 bytes only.
w.write_all(b"PATCHED!").unwrap();
w.sync_all().unwrap();
drop(w);
// First 8 bytes overwritten; the rest of ORIGINAL-CONTENT
// ("-CONTENT") survives because there was no truncation.
assert_eq!(read_back(&p), b"PATCHED!-CONTENT");
}
/// `open` on a file whose position is queried must start tracking
/// from the file's current offset. `WritebackFile::new` calls
/// `stream_position()` (line 112); a freshly `open`ed file is at
/// offset 0. After writing, seeking Current(0) must reflect the
/// bytes written from 0. Mutation: if `new` hardcoded pos=0 instead
/// of querying, a non-zero starting offset would desync — covered
/// indirectly; here we assert the offset is exactly the write size.
#[test]
fn new_tracks_initial_position() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pos-init.bin");
std::fs::write(&p, b"0123456789").unwrap();
let mut w = WritebackFile::open(&p).unwrap();
let start = w.stream_position().unwrap();
assert_eq!(start, 0, "freshly opened file should start at offset 0");
w.write_all(b"XY").unwrap();
let after = w.stream_position().unwrap();
assert_eq!(after, 2, "pos must advance by written length");
}
/// Seek past EOF then write must create a sparse hole that reads
/// back as zeros — standard POSIX file semantics that the wrapper
/// must not break (it forwards seek to the inner File at line 205).
/// Mutation: if `seek` clamped or mishandled the offset, the hole
/// size/zero-fill would be wrong.
#[test]
fn seek_past_eof_creates_zero_hole() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("hole.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"head").unwrap(); // bytes 0..4
w.seek(SeekFrom::Start(20)).unwrap(); // jump past EOF
w.write_all(b"tail").unwrap(); // bytes 20..24
w.sync_all().unwrap();
drop(w);
let bytes = read_back(&p);
assert_eq!(
bytes.len(),
24,
"file should extend to the last written byte"
);
assert_eq!(&bytes[0..4], b"head");
// The 4..20 gap must read back as zeros (sparse hole).
assert!(bytes[4..20].iter().all(|&b| b == 0), "hole not zero-filled");
assert_eq!(&bytes[20..24], b"tail");
}
/// `SeekFrom::End` must resolve against the actual file length.
/// After writing 10 bytes, `seek(End(-2))` lands at offset 8;
/// overwriting 2 bytes there patches the tail. Mutation: forwarding
/// the wrong SeekFrom variant would land at the wrong offset.
#[test]
fn seek_from_end_resolves_against_length() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("end-seek.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"0123456789").unwrap();
let landed = w.seek(SeekFrom::End(-2)).unwrap();
assert_eq!(landed, 8, "End(-2) of a 10-byte file is offset 8");
w.write_all(b"XY").unwrap();
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), b"01234567XY");
}
/// `create_with_size_hint` must produce a normal, writable file
/// whose *reported size* tracks bytes written (the hint only
/// reserves extents, per the doc lines 137-145 — it must NOT
/// pre-grow the logical file length). We write 5 bytes against a
/// 1 MiB hint and the file must be exactly 5 bytes long.
/// Mutation: if the hint path truncated/extended to size_bytes the
/// length would be 1 MiB and this fails.
#[test]
fn create_with_size_hint_does_not_inflate_logical_length() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("hint-len.bin");
let mut w = WritebackFile::create_with_size_hint(&p, 1024 * 1024).unwrap();
w.write_all(b"hello").unwrap();
w.sync_all().unwrap();
drop(w);
let bytes = read_back(&p);
assert_eq!(bytes.len(), 5, "size hint must not inflate logical length");
assert_eq!(&bytes, b"hello");
}
/// `sync_all` is idempotent: calling it twice (and then Drop, which
/// also finalizes) must not corrupt data or panic. Doc lines
/// 256-262: `finalize` is idempotent so explicit sync_all then drop
/// is safe. Mutation: a finalize that double-freed or advanced a
/// cursor would corrupt on the second call.
#[test]
fn double_sync_all_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("double-sync.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"idempotent").unwrap();
w.sync_all().unwrap();
w.sync_all().unwrap(); // second call must be safe
drop(w); // Drop also finalizes
assert_eq!(read_back(&p), b"idempotent");
}
/// Env-var chunk override parsing (`writeback_chunk_bytes`, lines
/// 91-98). Out-of-range / unparseable values must fall back to the
/// 32 MiB default; valid in-range values are converted MiB→bytes.
/// We can't safely mutate process env in parallel tests for the
/// default-path branch, but we CAN assert the pure boundary logic
/// the function encodes by reconstructing it: the filter accepts
/// `0 < n <= WRITEBACK_CHUNK_MIB_MAX`. This pins the constants and
/// the MiB→byte multiply. Mutation: changing `* 1024 * 1024` to a
/// single `* 1024` would break this equality.
#[test]
fn writeback_chunk_constants_and_conversion() {
// Default is exactly 32 MiB.
assert_eq!(WRITEBACK_CHUNK_BYTES_DEFAULT, 32 * 1024 * 1024);
// Max MiB bound is 64 GiB expressed in MiB, and the byte value
// it maps to must not overflow u64.
assert_eq!(WRITEBACK_CHUNK_MIB_MAX, 64 * 1024);
let max_bytes = (WRITEBACK_CHUNK_MIB_MAX as u128) * 1024 * 1024;
assert!(
max_bytes <= u64::MAX as u128,
"max chunk MiB * 1MiB must fit in u64"
);
}
/// Env-var override parsing for `writeback_chunk_bytes` (lines
/// 91-98). All four branches in ONE test to avoid the data race of
/// several parallel tests mutating the same process-global env var.
///
/// Branches: (1) valid in-range value → MiB→byte conversion; (2)
/// zero → `n > 0` filter rejects → default; (3) garbage → parse
/// fails → default; (4) over-max → `n <= MAX` filter rejects →
/// default.
///
/// Mutations: `* 1024 * 1024` → `* 1024` breaks (1); dropping
/// `n > 0` breaks (2); `unwrap()` on parse panics (3); dropping
/// `n <= MAX` breaks (4).
#[test]
fn writeback_chunk_env_override_branches() {
// SAFETY: this is the only test touching this env var, and it
// sets+reads+clears synchronously within its own body.
let set = |v: &str| unsafe { std::env::set_var("FREEMKV_WRITEBACK_CHUNK_MIB", v) };
let clear = || unsafe { std::env::remove_var("FREEMKV_WRITEBACK_CHUNK_MIB") };
set("8");
assert_eq!(
writeback_chunk_bytes(),
8 * 1024 * 1024,
"in-range mis-converted"
);
set("0");
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"zero must fall back (n > 0 filter)"
);
set("not-a-number");
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"unparseable must fall back"
);
// One past the max: WRITEBACK_CHUNK_MIB_MAX + 1.
set(&(WRITEBACK_CHUNK_MIB_MAX + 1).to_string());
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"over-max must fall back (n <= MAX filter)"
);
// Exactly at the max boundary is accepted (inclusive bound).
set(&WRITEBACK_CHUNK_MIB_MAX.to_string());
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_MIB_MAX * 1024 * 1024,
"max boundary must be accepted (inclusive)"
);
clear();
// With the var cleared, the default is returned.
assert_eq!(writeback_chunk_bytes(), WRITEBACK_CHUNK_BYTES_DEFAULT);
}
}