- CSS: unlock scrambled-sector reads on enforcing drives via bus-auth only; classify sense 6F/03 as CSS-locked; early-bail on a fully locked scan; gate the AACS handshake off DVD discs. - DVD first-play menu no longer prepended to the feature: read the title VOBS base from vtstt_vobs (0xC4), not the menu VOBS vtsm_vobs (0xC0). - Interlaced field-duration (DefaultDecodedFieldDuration) written as a direct TrackEntry child rather than inside Video, so Windows reports the correct frame rate. - Audio channel count read from the AC-3 bitstream; FieldOrder set to TFF; per-track BPS tags. - Structured disc diagnostics at --log-level 3; reduced per-operation log spam.
647 lines
27 KiB
Rust
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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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);
|
|
}
|
|
}
|