From cbb3517afef1d6e8d364abad1b3c2edac3a07eb6 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:10:19 -0700 Subject: [PATCH] Bound the synthesized image, and stop it pinning the page cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DVD title set records where its VOBS begins as an offset inside its own IFO, and the planner honours that offset because honouring it is what makes a real backup readable. Nothing bounded it: a regenerated .BUP or a hand-assembled folder naming an offset far past the content grew the image to wherever it pointed — a u32 sector count reaches ~8.8 TB, and writing that to an iso:// destination fills a disk with zeros before anything notices. Capped at 128 GiB, which clears BD-100 with room. Metadata is materialized up front and held for the life of the image, at a 2 KiB File Entry per node, so the 100,000-entry cap alone permitted ~205 MB of it for content of no size at all — and the mux holds two images at once while probing. The module claimed a budget of a few MiB; that budget is now enforced rather than asserted. Host reads had no page-cache eviction. The ISO source documents what that costs, measured: an 85 GB read pins the whole file, starves the writer, and collapses the mux to 2.7 MB/s against 70 MB/s isolated. A folder source reads host files the same way, so it now uses the same eviction — the hints move from private-to-that-module to crate-internal rather than being reimplemented. --- src/dirimage/layout.rs | 33 +++++++++++++++++++++++ src/dirimage/mod.rs | 40 +++++++++++++++++++++++++++- src/io/file_sector_source/linux.rs | 6 ++--- src/io/file_sector_source/macos.rs | 6 ++--- src/io/file_sector_source/mod.rs | 19 +++++++------ src/io/file_sector_source/other.rs | 6 ++--- src/io/file_sector_source/windows.rs | 6 ++--- 7 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/dirimage/layout.rs b/src/dirimage/layout.rs index fcde600..879a0e9 100644 --- a/src/dirimage/layout.rs +++ b/src/dirimage/layout.rs @@ -59,6 +59,30 @@ const MAX_CS0_NAME_BYTES: usize = 254; /// value is `u16::MAX - 1`. const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize; +/// Largest image this planner will synthesize, in sectors (128 GiB). +/// +/// A DVD title set records where its VOBS begins as an offset in its own IFO, +/// and that offset is read verbatim out of a file in the folder. A regenerated +/// `.BUP`, a tool that rewrote an IFO, or a hand-assembled folder can therefore +/// name an offset far beyond the content — and the planner honours it, because +/// honouring it is what makes a real backup readable. Without a ceiling the +/// image grows to wherever that offset points: a `u32` sector count reaches +/// ~8.8 TB, and writing one to an `iso://` destination would fill a disk with +/// zeros before anything noticed. +/// +/// 128 GiB clears the largest real medium (BD-100) with room to spare, so a +/// genuine disc folder never meets it. +const MAX_IMAGE_SECTORS: u32 = (128u64 * 1024 * 1024 * 1024 / SECTOR as u64) as u32; + +/// Ceiling on the in-memory metadata region (64 MiB). +/// +/// Every node costs a 2 KiB File Entry sector held for the life of the image, +/// so the entry cap alone permits ~205 MB of metadata for content of no size at +/// all — and the mux holds two of these at once while probing. The module +/// documents a budget of "a few MiB even for a large Blu-ray"; this is what +/// enforces it rather than merely asserting it. +const MAX_META_BYTES: u64 = 64 * 1024 * 1024; + /// The fan-out cap must bite before the global entry cap, or it never fires. const _: () = assert!(MAX_SUBDIRS < MAX_ENTRIES); @@ -605,6 +629,15 @@ pub(super) fn plan(root: &Path) -> Result { let part_sectors = cursor; let total = part_start as u64 + part_sectors as u64 + 1; // + trailing anchor let total_sectors = u32::try_from(total).map_err(|_| Error::DirImageTooLarge)?; + if total_sectors > MAX_IMAGE_SECTORS { + return Err(Error::DirImageTooLarge); + } + // Metadata is materialized up front and held for the life of the image, so + // its size is bounded here rather than discovered when memory runs out. + let meta_bytes = (dir_count as u64 + file_count as u64).saturating_mul(SECTOR as u64); + if meta_bytes > MAX_META_BYTES { + return Err(Error::DirImageTooLarge); + } let volume_id = root .file_name() diff --git a/src/dirimage/mod.rs b/src/dirimage/mod.rs index aa16a28..b33add3 100644 --- a/src/dirimage/mod.rs +++ b/src/dirimage/mod.rs @@ -36,6 +36,14 @@ mod encode; mod layout; use crate::error::{Error, Result}; +#[cfg(target_os = "linux")] +use crate::io::file_sector_source::linux::drop_window; +#[cfg(target_os = "macos")] +use crate::io::file_sector_source::macos::drop_window; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +use crate::io::file_sector_source::other::drop_window; +#[cfg(target_os = "windows")] +use crate::io::file_sector_source::windows::drop_window; use crate::sector::SectorSource; use encode::{MetaSectors, SECTOR}; use std::fs::File; @@ -78,6 +86,12 @@ struct FileRef { /// Owns everything it reads through (`PathBuf`s and its own file handles), so /// it is `Send + 'static` and can be moved into `build_iso_pipeline`, which /// hands it to `PrefetchedSectorSource`'s producer thread. +/// Bytes read between page-cache eviction calls (32 MiB). +/// +/// Large enough that the hint costs nothing measurable against a rip, small +/// enough that resident pages stay bounded well below any machine's RAM. +const DROP_CHUNK_BYTES: u64 = 32 * 1024 * 1024; + pub struct DirImage { meta: MetaSectors, /// Sorted by `start_lba`, non-overlapping. @@ -87,6 +101,15 @@ pub struct DirImage { total_sectors: u32, volume_id: String, data_bytes: u64, + /// Bytes read from host files since the last page-cache eviction. + /// + /// A rip streams every byte of the folder exactly once. Without eviction + /// the kernel keeps all of it resident, which starves the concurrent writer + /// — `io::file_sector_source` records the measured cost of exactly this + /// omission on the ISO path (2.7 MB/s mux against 70 MB/s isolated reads). + /// A folder source reads host files the same way and needs the same + /// treatment. + bytes_since_drop: u64, } impl std::fmt::Debug for DirImage { @@ -160,6 +183,7 @@ impl DirImage { total_sectors: plan.total_sectors, volume_id: plan.volume_id, data_bytes, + bytes_since_drop: 0, }) } @@ -225,7 +249,21 @@ impl DirImage { let file = r.file; let h = self.handle(file)?; h.seek(SeekFrom::Start(at)).map_err(Error::from)?; - match h.read_exact(&mut out[..want]) { + let res = h.read_exact(&mut out[..want]); + if res.is_ok() { + // Evict what we have consumed, per file handle. The window is the + // read just completed rather than a running offset, because reads + // here jump between files and a single monotonic cursor would name + // the wrong pages. + self.bytes_since_drop = self.bytes_since_drop.saturating_add(want as u64); + if self.bytes_since_drop >= DROP_CHUNK_BYTES { + if let Some((_, fh)) = self.open.iter().find(|(i, _)| *i == file) { + drop_window(fh, at, want as u64); + } + self.bytes_since_drop = 0; + } + } + match res { Ok(()) => Ok(()), // The file shrank while the handle was open. Same verdict as the // size check in `handle`, reached the other way. diff --git a/src/io/file_sector_source/linux.rs b/src/io/file_sector_source/linux.rs index 8a7c0eb..2fac73f 100644 --- a/src/io/file_sector_source/linux.rs +++ b/src/io/file_sector_source/linux.rs @@ -23,7 +23,7 @@ use std::fs::File; use std::os::unix::io::AsRawFd; -pub(super) fn hint_sequential(file: &File, _len_bytes: u64) { +pub(crate) fn hint_sequential(file: &File, _len_bytes: u64) { // Best-effort: return value ignored. A fadvise failure has no // user-observable consequence. unsafe { @@ -34,7 +34,7 @@ pub(super) fn hint_sequential(file: &File, _len_bytes: u64) { /// Drop pages in the half-open byte range `[start, start+len)` from /// the page cache. Called periodically by `read_sectors` to bound the /// read-side page cache pressure. -pub(super) fn drop_window(file: &File, start: u64, len: u64) { +pub(crate) fn drop_window(file: &File, start: u64, len: u64) { unsafe { libc::posix_fadvise( file.as_raw_fd(), @@ -58,7 +58,7 @@ pub(super) fn drop_window(file: &File, start: u64, len: u64) { /// can only pre-stage a tiny slice of the next batch. An explicit /// `readahead()` of the same size as the current batch tells the /// kernel to queue the full next-batch read now. -pub(super) fn prefetch(file: &File, offset: u64, len: u64) { +pub(crate) fn prefetch(file: &File, offset: u64, len: u64) { unsafe { libc::readahead(file.as_raw_fd(), offset as i64, len as usize); } diff --git a/src/io/file_sector_source/macos.rs b/src/io/file_sector_source/macos.rs index 850cacf..d79aa6d 100644 --- a/src/io/file_sector_source/macos.rs +++ b/src/io/file_sector_source/macos.rs @@ -15,7 +15,7 @@ use std::os::unix::io::AsRawFd; /// pipeline depth. const RDADVISE_MAX_BYTES: i64 = 64 * 1024 * 1024; -pub(super) fn hint_sequential(file: &File, len_bytes: u64) { +pub(crate) fn hint_sequential(file: &File, len_bytes: u64) { let bytes = (len_bytes as i64).min(RDADVISE_MAX_BYTES); let mut ra = libc::radvisory { ra_offset: 0, @@ -33,14 +33,14 @@ pub(super) fn hint_sequential(file: &File, len_bytes: u64) { /// approximation: no-op. macOS's unified buffer cache is generally /// less prone to the pin-everything pathology that triggers the /// regression on Linux NFS clients. -pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {} +pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {} /// Async-prefetch the byte range `[offset, offset+len)`. macOS uses /// the same `fcntl(F_RDADVISE, &radvisory)` primitive as the open- /// time sequential hint, just targeted at a moving window instead of /// the whole file. The kernel queues I/O for the requested range and /// returns immediately. -pub(super) fn prefetch(file: &File, offset: u64, len: u64) { +pub(crate) fn prefetch(file: &File, offset: u64, len: u64) { let bytes = (len as i64).min(RDADVISE_MAX_BYTES); let mut ra = libc::radvisory { ra_offset: offset as libc::off_t, diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index ec2f1b0..b1aaba3 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -48,22 +48,25 @@ //! far smaller than our 16 MiB app-level batch. #[cfg(target_os = "linux")] -mod linux; +pub(crate) mod linux; #[cfg(target_os = "macos")] -mod macos; +pub(crate) mod macos; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -mod other; +pub(crate) mod other; #[cfg(target_os = "windows")] -mod windows; +pub(crate) mod windows; +// The page-cache hints are shared with any other file-backed sector source: +// `dirimage` reads host files the same way and needs the same eviction, or a +// large rip pins every byte it has read (see this module's DONTNEED note). #[cfg(target_os = "linux")] -use linux as platform; +pub(crate) use linux as platform; #[cfg(target_os = "macos")] -use macos as platform; +pub(crate) use macos as platform; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -use other as platform; +pub(crate) use other as platform; #[cfg(target_os = "windows")] -use windows as platform; +pub(crate) use windows as platform; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; diff --git a/src/io/file_sector_source/other.rs b/src/io/file_sector_source/other.rs index 97ca955..0aedfeb 100644 --- a/src/io/file_sector_source/other.rs +++ b/src/io/file_sector_source/other.rs @@ -4,8 +4,8 @@ use std::fs::File; -pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) {} +pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) {} -pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {} +pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {} -pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {} +pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {} diff --git a/src/io/file_sector_source/windows.rs b/src/io/file_sector_source/windows.rs index ab0c495..47137cc 100644 --- a/src/io/file_sector_source/windows.rs +++ b/src/io/file_sector_source/windows.rs @@ -9,7 +9,7 @@ use std::fs::File; /// No-op stub. `FILE_FLAG_SEQUENTIAL_SCAN` can only be set at /// `CreateFile` open time, which the plain `File::open` path does not /// do, so there is no post-open hint to issue here. -pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) { +pub(crate) fn hint_sequential(_file: &File, _len_bytes: u64) { tracing::debug!( target: "mux", "FileSectorSource hint_sequential: windows no-op stub" @@ -19,10 +19,10 @@ pub(super) fn hint_sequential(_file: &File, _len_bytes: u64) { /// Windows page-cache eviction is not exposed via a posix_fadvise /// equivalent. The kernel does its own working-set management. No-op /// for now. -pub(super) fn drop_window(_file: &File, _start: u64, _len: u64) {} +pub(crate) fn drop_window(_file: &File, _start: u64, _len: u64) {} /// Windows async-prefetch hint. With FILE_FLAG_SEQUENTIAL_SCAN at /// open the kernel already prefetches aggressively, so there's no /// per-range hint we'd add on top. No-op stub for parity with the /// posix platforms. -pub(super) fn prefetch(_file: &File, _offset: u64, _len: u64) {} +pub(crate) fn prefetch(_file: &File, _offset: u64, _len: u64) {}