Keep the seam plan to Blu-ray, and stop a missed crossing truncating a title

Two findings from the same escalation, both silent-wrong-output.

The plan was built for every multi-clip title. Only a Blu-ray PlayItem's
IN/OUT are positions in the clock the PES PTS runs on. HD-DVD fills the
same fields from the XPL's title-relative times and a DVD's come from
cell tables, so a plan built from them is an identity map with a drop
filter: it suppresses the layer-break rebase inference performs, and
drops whatever falls outside marks the PTS was never measured against. An
earlier reading of this called HD-DVD safe because its marks are
contiguous and every computed offset was zero — true, and irrelevant,
because they were zero in the wrong clock. Gated on the content format,
with a test using an HD-DVD-shaped table that the clock check alone
accepts.

The crossing test was also one-shot. A table whose clips restart their
own bases could miss it, and a missed crossing STRANDS the track: every
later frame falls outside the stranded clip's marks and is dropped for
the rest of the title. Counting drops, which is all the previous round
added, does not bound them. A table that is not one advancing clock is
now refused outright and falls back to inference, which is the documented
safe path for those titles.

Also from the same round: read_sectors added an unchecked lba + i, where
callers deliberately saturate their LBAs — a wrap folds the read back to
a low sector and hands the muxer another file's bytes. classify added 1
to two numbers parsed verbatim out of a filename. read_head used a single
read() where a short read on a network mount silently records no
placement constraint at all. And the page-cache eviction added last round
released only the read that crossed its threshold rather than everything
accumulated, so seven eighths of what was read stayed pinned.
This commit is contained in:
Matthew Jackson
2026-08-05 17:31:43 -07:00
parent 764535bb7d
commit c8fafec393
6 changed files with 184 additions and 41 deletions
+24 -27
View File
@@ -86,12 +86,6 @@ 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.
@@ -101,15 +95,6 @@ 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 {
@@ -183,7 +168,6 @@ impl DirImage {
total_sectors: plan.total_sectors,
volume_id: plan.volume_id,
data_bytes,
bytes_since_drop: 0,
})
}
@@ -251,16 +235,21 @@ impl DirImage {
h.seek(SeekFrom::Start(at)).map_err(Error::from)?;
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;
// Release the window just read, every time.
//
// The ISO source accumulates and drops in chunks because it reads
// one file linearly, so a running start offset always names the
// bytes it has consumed. Reads here jump between files, so there is
// no single cursor to accumulate against — an accumulated byte
// count paired with one read's offset names 1/Nth of what was
// actually consumed and leaves the rest pinned, which is how the
// first version of this got it wrong.
//
// Dropping per read costs one advisory syscall per batch (4-16 MiB),
// which is nothing against the read itself, and it is correct
// regardless of how reads interleave across files.
if let Some((_, fh)) = self.open.iter().find(|(i, _)| *i == file) {
drop_window(fh, at, want as u64);
}
}
match res {
@@ -300,7 +289,15 @@ impl SectorSource for DirImage {
// one 16 MiB sequential read.
let mut i = 0u32;
while i < count as u32 {
let at = lba + i;
// Checked: callers saturate their LBAs (`disc/dvd.rs` builds a cell
// start as `vob_start_sector.saturating_add(cell.first_sector)`, and
// the prefetcher adds an offset the same way), so a crafted IFO can
// present a request at the very top of the address space. Wrapping
// here would fold `at` back to a LOW sector and hand the muxer a
// different file's bytes with nothing reported.
let Some(at) = lba.checked_add(i) else {
break;
};
let off = i as usize * SECTOR;
if let Some(s) = self.meta.get(&at) {
buf[off..off + SECTOR].copy_from_slice(&s[..]);