//! [`FileSectorSource`] — read 2048-byte sectors from an ISO file on //! disk via direct `seek + read_exact` (`pread`-equivalent) calls, //! letting the kernel's own readahead policy manage prefetch. //! //! ## Why no app-level buffer //! //! Pre-0.21.3 this source held a 32 MiB (later 4 MiB) read-ahead //! buffer to amortise per-sector NFS round-trips. Empirically that //! buffer hurt: 32 MiB refills bursted the NFS TCP connection hard //! enough to starve the concurrent writer, and even a 4 MiB window //! gave the kernel less freedom to pipeline reads with writes. Direct //! pread per call lets Linux's readahead widen as it detects the //! sequential pattern, and naturally interleaves with writeback. //! //! ## DONTNEED on the consumed window //! //! Without page-cache eviction an 85 GB streaming ISO read pins the //! entire file in memory, starves the concurrent writer, and collapses //! mux throughput (observed: 2.7 MB/s mux on 0.21.5 vs. 70 MB/s //! isolated NFS reads). Every [`READ_DROP_CHUNK_BYTES_DEFAULT`] of //! consumed bytes we call `posix_fadvise(DONTNEED)` over that window, //! mirroring the write-side [`crate::io::writeback::WritebackPipeline`] //! policy. //! //! The drop window is accounted by a monotonic forward byte counter, //! which matches the sequential streaming pattern the mux highway //! drives. Under random or backward access the dropped range no longer //! lines up with the bytes actually read — but `DONTNEED` is purely an //! advisory cache hint with no correctness impact, so this degrades to //! a slightly imprecise hint rather than a bug. //! //! ## Platform open hint //! //! On `open()` each platform issues its "sequential access expected" //! hint so OS-level readahead widens. The hint and the DONTNEED call //! live in per-OS sibling modules ([`linux::hint_sequential`] et al.) //! — no inline `#[cfg]` in this file. //! //! ## Read-ahead prefetch //! //! After every consumed read we issue an OS-level prefetch hint for //! the next equivalent-sized window (`platform::prefetch`). The //! kernel queues that I/O asynchronously and returns immediately, so //! the next batch's read overlaps with the caller's processing of //! the current batch (decrypt + demux + mux). Without this the disk //! sits idle ~70% of each iteration because kernel SEQUENTIAL //! readahead alone (capped at `read_ahead_kb`, default 128 KB) is //! far smaller than our 16 MiB app-level batch. #[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; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; use crate::error::{Error, Result}; use crate::sector::SectorSource; use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64}; /// Bytes-read threshold per `posix_fadvise(DONTNEED)` drop on the /// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page /// cache stays bounded the same way the write side does. /// /// 32 MiB is the empirically tuned value on the rip1 test bed (single /// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the /// kernel-readahead overlap and slow the producer; larger windows /// (64 / 128 MiB) let the page cache pin enough of the ISO to /// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`. const READ_DROP_CHUNK_BYTES_DEFAULT: u64 = 32 * 1024 * 1024; fn read_drop_chunk_bytes() -> u64 { std::env::var("FREEMKV_READ_DROP_CHUNK_MIB") .ok() .and_then(|v| v.parse::().ok()) .filter(|&n| n > 0) .map(|n| n * 1024 * 1024) .unwrap_or(READ_DROP_CHUNK_BYTES_DEFAULT) } /// SectorSource backed by a file (ISO image). Every `read_sectors` /// call is a direct `seek + read_exact` against the underlying file /// — kernel readahead handles prefetch, and every /// [`READ_DROP_CHUNK_BYTES_DEFAULT`] bytes of consumed data the /// platform's `DONTNEED` hook drops the consumed window from the /// page cache to bound memory pressure. pub struct FileSectorSource { file: File, /// Total file size in sectors. Constant after construction; /// surfaced via [`SectorSource::capacity_sectors`]. capacity: u32, /// Bytes read since the last DONTNEED drop. Drives the per- /// [`read_drop_chunk_bytes`] page-cache eviction in read_sectors. bytes_read_since_drop: u64, /// File offset at which the current drop window starts. The next /// DONTNEED drops from `drop_window_start` for /// `bytes_read_since_drop` bytes. This advances monotonically with /// the byte count, so it tracks the actual reads only under the /// forward-sequential access the mux highway uses; under random /// access it degrades to a harmless, imprecise advisory hint. drop_window_start: u64, /// Cached drop chunk size (resolved from env once at open). drop_chunk_bytes: u64, } impl FileSectorSource { /// Open an existing ISO file for reading. Capacity is derived /// from `metadata().len() / 2048`. Returns /// [`Error::IsoTooLarge`] if the file would exceed the 32-bit /// LBA address space (~8 TB). /// /// Issues the platform's "sequential access expected" hint on the /// fd (Linux `posix_fadvise(SEQUENTIAL)`, macOS `fcntl(F_RDADVISE)`, /// Windows no-op) so the kernel's readahead widens. pub fn open(path: &Path) -> Result { let file = File::open(path).map_err(|e| Error::IoError { source: e })?; let len = file .metadata() .map_err(|e| Error::IoError { source: e })? .len(); let sectors = len / SECTOR_BYTES_U64; if sectors > u32::MAX as u64 { return Err(Error::IsoTooLarge { path: path.to_string_lossy().into_owned(), }); } let capacity = sectors as u32; // Best-effort sequential hint. Ignored on platforms without // an equivalent primitive (or where the API exists but the // FS doesn't honour it). platform::hint_sequential(&file, len); Ok(Self { file, capacity, bytes_read_since_drop: 0, drop_window_start: 0, drop_chunk_bytes: read_drop_chunk_bytes(), }) } } impl SectorSource for FileSectorSource { fn capacity_sectors(&self) -> u32 { self.capacity } fn read_sectors( &mut self, lba: u32, count: u16, out: &mut [u8], _recovery: bool, ) -> Result { let count = count as u32; let bytes = count as usize * SECTOR_BYTES; debug_assert!( out.len() >= bytes, "FileSectorSource::read_sectors: out len {} < requested {}", out.len(), bytes ); if count == 0 { return Ok(0); } let offset = lba as u64 * SECTOR_BYTES_U64; self.file .seek(SeekFrom::Start(offset)) .map_err(|e| Error::IoError { source: e })?; self.file .read_exact(&mut out[..bytes]) .map_err(|e| Error::IoError { source: e })?; // Queue the next batch's read with the kernel before the // caller starts processing what we just returned. readahead() // is non-blocking — it queues I/O and returns, so the kernel // pulls those pages into cache while the consumer (decrypt + // demux + mux) runs. Next read_sectors call hits a warm cache. platform::prefetch(&self.file, offset + bytes as u64, bytes as u64); // Periodic page-cache eviction on the read side. Without // this, an 85 GB streaming ISO read pins the entire file in // the kernel page cache, which starves concurrent writes and // collapses mux throughput. Mirrors the write-side // WritebackPipeline's DONTNEED policy. self.bytes_read_since_drop += bytes as u64; if self.bytes_read_since_drop >= self.drop_chunk_bytes { let drop_start = self.drop_window_start; let drop_len = self.bytes_read_since_drop; platform::drop_window(&self.file, drop_start, drop_len); self.drop_window_start = drop_start + drop_len; self.bytes_read_since_drop = 0; } Ok(bytes) } } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::tempdir; /// Build a deterministic ISO of `sectors` sectors where sector `n` /// is filled with the byte pattern `((n & 0xff) as u8)`. Lets us /// verify any sector by content alone. fn make_iso(path: &std::path::Path, sectors: u32) { let mut f = std::fs::File::create(path).unwrap(); let mut chunk = vec![0u8; SECTOR_BYTES]; for n in 0..sectors { let b = (n & 0xff) as u8; chunk.iter_mut().for_each(|c| *c = b); f.write_all(&chunk).unwrap(); } f.flush().unwrap(); } /// Sectors used by spanning-boundary tests. Pick something that /// exercises multi-megabyte reads without making test ISOs huge. /// 8192 sectors = 16 MiB — large enough to cross any readahead /// chunk size we set the kernel hint to. const TEST_SPAN_SECTORS: u32 = 8192; #[test] fn sequential_reads_match_file() { let total = TEST_SPAN_SECTORS * 2 + 17; let dir = tempdir().unwrap(); let path = dir.path().join("seq.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), total); let mut got = vec![0u8; SECTOR_BYTES]; for lba in 0..total { src.read_sectors(lba, 1, &mut got, false).unwrap(); let expected = (lba & 0xff) as u8; assert!( got.iter().all(|b| *b == expected), "sector {lba} content mismatch: expected 0x{expected:02x}" ); } } #[test] fn multi_sector_read_across_chunk_boundary() { let total = TEST_SPAN_SECTORS * 2; let dir = tempdir().unwrap(); let path = dir.path().join("span.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); let span_lba = TEST_SPAN_SECTORS - 2; let mut buf4 = vec![0u8; SECTOR_BYTES * 4]; src.read_sectors(span_lba, 4, &mut buf4, false).unwrap(); for i in 0..4 { let lba = span_lba + i as u32; let expected = (lba & 0xff) as u8; for b in &buf4[i * SECTOR_BYTES..(i + 1) * SECTOR_BYTES] { assert_eq!(*b, expected, "byte mismatch at sub-sector {i}"); } } } #[test] fn backward_seek_reads_correct_bytes() { // Read forward then jump back: the SectorSource contract is // byte-correctness regardless of access pattern. let total = TEST_SPAN_SECTORS * 2 + 5; let dir = tempdir().unwrap(); let path = dir.path().join("back.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); let mut got = vec![0u8; SECTOR_BYTES]; src.read_sectors(TEST_SPAN_SECTORS + 1, 1, &mut got, false) .unwrap(); src.read_sectors(0, 1, &mut got, false).unwrap(); assert!(got.iter().all(|b| *b == 0)); } #[test] fn read_at_eof_returns_correct_bytes() { // File smaller than the readahead chunk — reads near EOF must // still return correct bytes. let total: u32 = 100; let dir = tempdir().unwrap(); let path = dir.path().join("small.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), total); let mut got = vec![0u8; SECTOR_BYTES]; src.read_sectors(0, 1, &mut got, false).unwrap(); src.read_sectors(total - 1, 1, &mut got, false).unwrap(); let expected = ((total - 1) & 0xff) as u8; assert!(got.iter().all(|b| *b == expected)); } #[test] fn large_single_read() { // A multi-MB single read must work — the implementation has // no app-level chunking, so this just exercises the direct // pread path on a larger request. let total = TEST_SPAN_SECTORS + 100; let dir = tempdir().unwrap(); let path = dir.path().join("big.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); let req = (TEST_SPAN_SECTORS + 1) as u16; let req_bytes = req as usize * SECTOR_BYTES; let mut big = vec![0u8; req_bytes]; src.read_sectors(0, req, &mut big, false).unwrap(); assert!(big[..SECTOR_BYTES].iter().all(|b| *b == 0)); let last_lba = req as u32 - 1; let exp = (last_lba & 0xff) as u8; let last_off = (req as usize - 1) * SECTOR_BYTES; assert!( big[last_off..last_off + SECTOR_BYTES] .iter() .all(|b| *b == exp) ); } #[test] fn drop_chunk_size_env_override() { // Explicit 8 MiB via env var. // SAFETY: tests in this crate are single-threaded per the // default cargo test harness, but std::env::set_var is // declared `unsafe` since Rust 2024 (it can race with other // threads / TLS). For a test that runs in-process before any // FileSectorSource construction this is safe in practice. unsafe { std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "8"); } assert_eq!(read_drop_chunk_bytes(), 8 * 1024 * 1024); unsafe { std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB"); } assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT); // Garbage env value falls back to default. unsafe { std::env::set_var("FREEMKV_READ_DROP_CHUNK_MIB", "not-a-number"); } assert_eq!(read_drop_chunk_bytes(), READ_DROP_CHUNK_BYTES_DEFAULT); unsafe { std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB"); } } // --------------------------------------------------------------- // Additional coverage. // --------------------------------------------------------------- /// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or /// reading, even at an out-of-range LBA — the early-return guard /// runs before any I/O. Grounding: `if count == 0 { return Ok(0) }`. #[test] fn zero_count_returns_zero_no_io() { let dir = tempdir().unwrap(); let path = dir.path().join("zc.iso"); make_iso(&path, 4); let mut src = FileSectorSource::open(&path).unwrap(); // LBA far past EOF — must not matter because count==0 returns early. let mut buf = [0u8; 1]; let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap(); assert_eq!(n, 0); } /// Reading past EOF must ERROR (read_exact's UnexpectedEof), never /// return a partial/short count. This is the core "never silently /// truncate / never return fewer bytes than declared" property of /// the SectorSource contract. Grounding: `self.file.read_exact(...)` /// — read_exact fails if the file can't supply the full span. #[test] fn read_past_eof_errors_not_truncates() { let dir = tempdir().unwrap(); let path = dir.path().join("eof.iso"); make_iso(&path, 4); // 4 sectors only let mut src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), 4); // Request 2 sectors starting at LBA 3 → sector 4 doesn't exist. let mut buf = vec![0u8; 2 * SECTOR_BYTES]; let r = src.read_sectors(3, 2, &mut buf, false); let err = r.expect_err("reading past EOF must error, not short-read"); let io: std::io::Error = err.into(); assert_eq!( io.kind(), std::io::ErrorKind::UnexpectedEof, "partial read at EOF must surface read_exact's UnexpectedEof" ); } /// On a successful full read the returned count MUST equal /// `count * 2048` exactly — the declared byte count. Grounding: /// `Ok(bytes)` where `bytes = count * SECTOR_BYTES`. #[test] fn full_read_returns_exact_declared_bytes() { let dir = tempdir().unwrap(); let path = dir.path().join("exact.iso"); make_iso(&path, 16); let mut src = FileSectorSource::open(&path).unwrap(); let mut buf = vec![0u8; 5 * SECTOR_BYTES]; let n = src.read_sectors(2, 5, &mut buf, false).unwrap(); assert_eq!(n, 5 * SECTOR_BYTES, "must return exactly count*2048 bytes"); } /// Capacity is `file_len / 2048` (floor); trailing bytes that don't /// complete a sector are NOT counted. A file of 4 sectors + 100 /// extra bytes reports capacity 4. Grounding: `len / SECTOR_BYTES` /// integer division in `open`. #[test] fn capacity_floors_partial_trailing_sector() { let dir = tempdir().unwrap(); let path = dir.path().join("partial.iso"); make_iso(&path, 4); // Append 100 stray bytes (a torn final sector). { let mut f = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); f.write_all(&[0xee; 100]).unwrap(); f.flush().unwrap(); } let src = FileSectorSource::open(&path).unwrap(); assert_eq!( src.capacity_sectors(), 4, "partial trailing bytes must not inflate the sector capacity" ); } /// An empty file opens cleanly with capacity 0. Grounding: /// `0 / 2048 == 0`, and the IsoTooLarge guard only fires for /// oversize files. #[test] fn empty_file_capacity_zero() { let dir = tempdir().unwrap(); let path = dir.path().join("empty.iso"); std::fs::File::create(&path).unwrap(); let src = FileSectorSource::open(&path).unwrap(); assert_eq!(src.capacity_sectors(), 0); } /// Opening a nonexistent path returns an IoError (NotFound), not a /// panic. Grounding: `File::open(path).map_err(...)`. #[test] fn open_missing_file_errors() { let dir = tempdir().unwrap(); let path = dir.path().join("does-not-exist.iso"); let err = match FileSectorSource::open(&path) { Ok(_) => panic!("missing file must error"), Err(e) => e, }; let io: std::io::Error = err.into(); assert_eq!(io.kind(), std::io::ErrorKind::NotFound); } /// A DONTNEED drop crossing the chunk threshold must not corrupt or /// short subsequent reads — the eviction is a pure page-cache hint. /// We read past the DEFAULT 32 MiB drop chunk (16384 sectors) so the /// eviction block fires at least once, asserting every sector still /// reads correctly. (Avoids mutating FREEMKV_READ_DROP_CHUNK_MIB to /// sidestep a parallel-test env race with `drop_chunk_size_env_override`.) /// Grounding: the `bytes_read_since_drop >= drop_chunk_bytes` /// eviction block calls only `platform::drop_window` (advisory) and /// resets counters — no data effect. #[test] fn dontneed_eviction_does_not_affect_data() { // 32 MiB default chunk = 16384 sectors; read a bit past it. let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_BYTES_U64) as u32 + 64; let dir = tempdir().unwrap(); let path = dir.path().join("drop.iso"); make_iso(&path, total); let mut src = FileSectorSource::open(&path).unwrap(); // Read in 16-sector batches to keep the loop fast while still // crossing the drop boundary by byte count. let batch = 16u16; let mut got = vec![0u8; batch as usize * SECTOR_BYTES]; let mut lba = 0u32; while lba + batch as u32 <= total { src.read_sectors(lba, batch, &mut got, false).unwrap(); for i in 0..batch as u32 { let expected = ((lba + i) & 0xff) as u8; let off = i as usize * SECTOR_BYTES; assert!( got[off..off + SECTOR_BYTES].iter().all(|x| *x == expected), "DONTNEED eviction corrupted sector {}", lba + i ); } lba += batch as u32; } } }