Files
libfreemkv/src/mux/disc.rs
T
Matthew Jackson b82075b41a Fix rc5 audit findings: keydb doc, pipeline ordering, hot-loop Arc, tests
- keydb.rs: separate default_path()/no_home_dir() doc blocks; correct the
  false XDG lock-step claim (Linux write path uses $HOME, ignores
  XDG_CONFIG_HOME; read-side search also checks XDG_CONFIG_HOME).
- io/pipeline.rs: use Release/Acquire on the abandoned flag so a leaked
  consumer reliably skips close() on weak memory models (ARM64/POWER),
  not just x86 TSO.
- mux/disc.rs: cache the decrypt-loss Arc at construction; lost_bytes()
  no longer clones an Arc per frame on the mux hot path.
- disc/dvd.rs: assert display_aspect mapping for both 16:9 (PAL test) and
  4:3 (NTSC test).
- mux/resolve.rs: extract css_error_aborts() helper and unit-test the
  scrambled-but-uncracked CSS guard (Fix 6) incl. the --raw exemption.
- aacs/keys.rs: add unit tests for mkb_type_raw/mkb_type/mkb_is_uhd and
  MkbType (Category C 2.0 UHD, prerecorded 1.0, no-0x10-record None).
- release.yml: publish job needs [verify, test] so a failing test suite
  blocks crates.io publication.
2026-06-23 19:11:09 -07:00

1223 lines
50 KiB
Rust

//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
//!
//! One stream type for all disc sources. The source is a SectorSource —
//! Drive (hardware) or FileSectorSource (file). DiscStream doesn't care.
//!
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use crate::disc::{DiscTitle, Extent};
use crate::drive::extract_scsi_context;
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::halt::Halt;
use crate::sector::{DecryptingSectorSource, SectorSource};
use std::io;
#[cfg(test)]
use std::sync::Arc;
#[cfg(test)]
use std::sync::atomic::AtomicBool;
/// Ramp back up to the preferred batch size after this many sectors
/// of clean reading at the current (reduced) size. 100 MiB = 51,200 sectors.
///
/// Chosen so that an isolated transient failure doesn't lock the rip at
/// size 1: once past the bad zone, we probe up after ~100 ms of good reads.
/// And so that noisy zones with occasional successes can't trigger a
/// premature probe — we need a sustained clean run.
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
/// Halve a batch size, keeping 3-sector alignment when >= 6
/// (3-sector alignment = one AACS unit). At sizes < 6 we descend
/// through 3 → 1 without intermediate unaligned sizes.
fn halve_batch_size(size: u16) -> u16 {
let h = (size / 2).max(1);
if h >= 6 { h - (h % 3) } else { h }
}
/// Double a batch size toward a preferred max, keeping 3-sector alignment
/// when the result is >= 6.
fn double_batch_size(size: u16, preferred: u16) -> u16 {
let d = size.saturating_mul(2).min(preferred);
if d >= 6 { d - (d % 3) } else { d }
}
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
/// clean streak. Amortizes the cost of entering a bad zone — descent happens
/// once, not once per bad sector.
#[derive(Debug)]
struct AdaptiveBatch {
preferred: u16,
current: u16,
streak_sectors: u32,
}
impl AdaptiveBatch {
fn new(preferred: u16) -> Self {
Self {
preferred,
current: preferred,
streak_sectors: 0,
}
}
fn current(&self) -> u16 {
self.current
}
/// Record a successful read of `sectors`. Returns an event if the
/// sizer probed up to a larger batch size.
fn on_success(&mut self, sectors: u16) -> Option<EventKind> {
self.streak_sectors = self.streak_sectors.saturating_add(sectors as u32);
if self.current < self.preferred && self.streak_sectors >= PROBE_THRESHOLD_SECTORS {
let new_size = double_batch_size(self.current, self.preferred);
if new_size != self.current {
self.current = new_size;
self.streak_sectors = 0;
return Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Probed,
});
}
}
None
}
/// Record a read failure. Returns an event if the sizer shrank.
/// Does nothing at size 1 (caller handles skip/error).
fn on_failure(&mut self) -> Option<EventKind> {
self.streak_sectors = 0;
if self.current <= 1 {
return None;
}
let new_size = halve_batch_size(self.current);
self.current = new_size;
Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Shrunk,
})
}
}
/// Disc stream. Reads sectors from any source → PES frames.
///
/// Sources: physical drive, ISO file, or any SectorSource.
/// Decrypt, demux, and codec parsing happen internally.
pub struct DiscStream {
/// Underlying sector source wrapped in the 0.18
/// [`DecryptingSectorSource`] decorator. Every `read_sectors`
/// call yields plaintext, so `fill_extents` no longer needs an
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
/// (raw / unencrypted disc) makes the decorator a pass-through.
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
/// Shared decrypt-loss counter, cloned once at construction from
/// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the
/// per-frame hot path performs no per-call `Arc::clone` (matching the
/// `PipelinedPesStream` pattern).
decrypt_loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
title: DiscTitle,
/// Mirror of the keys handed in at construction. The decorator
/// owns the cryptographic state; this field is kept for
/// metadata-side callers (`info()` and friends) that want to
/// know whether the disc was encrypted, without reaching through
/// the wrapper.
decrypt_keys: crate::decrypt::DecryptKeys,
/// Sector granularity the decrypt step requires each read buffer to start
/// on and span a multiple of. AACS decrypts whole 6144-byte (3-sector)
/// units keyed off the buffer's first 16 bytes, so every `read_sectors`
/// buffer must begin on a real on-disc unit boundary — hence reads and
/// error-skips must stay aligned to this. `3` for AACS, `1` for CSS /
/// unencrypted (per-sector, self-synchronizing). Mirrors the file-backed
/// highway's `PrefetchedSectorSource` guard; this is the inline live path.
unit_align: u16,
// Extents to read
extents: Vec<Extent>,
// Position
current_extent: usize,
current_offset: u32,
// Buffer
read_buf: Vec<u8>,
buf_valid: usize,
// Adaptive batch sizer — preferred comes from the caller
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
adaptive: AdaptiveBatch,
pub errors: u64,
/// Cumulative bytes actually skipped (zero-filled) on read error.
/// Distinct from `errors`, which counts skip *events*: one event can
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
/// `errors * 2048` understates real loss by the alignment factor.
/// Consumers estimating lost video time must scale by this, not by
/// the event count.
pub lost_bytes: u64,
pub skip_errors: bool,
/// When set and the token is cancelled, fill_extents returns Err(Halted)
/// at the next retry boundary. Unlike skip_errors, this propagates the
/// error up so the rip terminates cleanly. Construct with
/// [`DiscStream::with_halt`], passing the same `Halt` clone handed to
/// sweep / patch / mux so every phase observes one Stop signal.
halt: Option<Halt>,
event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool,
// Cumulative bytes successfully read from the source. Drives
// EventKind::BytesRead emission and autorip's per-device progress.
bytes_read_total: u64,
// Pre-computed total of all extents in bytes (or 0 if extents are
// empty). Carried in EventKind::BytesRead.total so consumers can show
// a percent without a separate API call.
bytes_total_extents: u64,
// PES output — single-threaded inline demux + codec parse. The
// pipeline-mode mux (3-stage threaded) lives in
// [`super::pipelined_stream::PipelinedPesStream`]; this type is
// the legacy in-thread path for live-disc reads where adaptive
// batch retry on bad sectors lives in `fill_extents`.
ts_demuxer: Option<super::ts::TsDemuxer>,
ps_demuxer: Option<super::ps::PsDemuxer>,
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
pid_to_track: Vec<(u16, usize)>,
/// Cached `FREEMKV_SKIP_PARSE` profiling flag. The env var cannot
/// change at runtime, and `std::env::var_os` takes a process-wide
/// lock; reading it once at construction keeps it out of the
/// per-batch read() hot loop.
skip_parse: bool,
/// Cached `FREEMKV_PROFILE` presence, read once at construction. When
/// false, the read() loop skips the four `Instant::now()` captures and the
/// `prof_tick` calls entirely, so profiling-off runs pay no per-iteration
/// timestamp cost or `prof_active()` env-var lookup (which takes a
/// process-wide lock).
profiling: bool,
}
impl DiscStream {
/// Create a disc stream from any sector reader.
///
/// Works with physical drives and ISO files — both implement SectorSource.
/// The caller opens the source, scans for titles/keys, and passes them in.
/// The stream handles demuxing, decryption, and codec parsing internally.
pub fn new(
reader: Box<dyn SectorSource>,
title: DiscTitle,
decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
) -> Self {
let extents = title.extents.clone();
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
// Debug log reader type at construction — critical for diagnosing mux
// reading from drive instead of ISO. `type_name_of_val(&*reader)`
// resolves the CONCRETE type behind the box (Drive / FileSectorSource),
// unlike `type_name::<dyn SectorSource>()` which always prints the
// trait-object name regardless of the underlying source.
tracing::debug!(
target: "mux",
"DiscStream constructed with reader type: {}",
std::any::type_name_of_val(&*reader)
);
let mut pids = Vec::new();
let mut parsers = Vec::new();
let mut pid_to_track = Vec::new();
for (idx, s) in title.streams.iter().enumerate() {
let (pid, codec) = match s {
crate::disc::Stream::Video(v) => (v.pid, v.codec),
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(content_format, crate::disc::ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
}
let mut ts_demuxer = None;
let mut ps_demuxer = None;
match content_format {
crate::disc::ContentFormat::MpegPs => {
ps_demuxer = Some(super::ps::PsDemuxer::new());
}
crate::disc::ContentFormat::BdTs => {
let ts_pids: Vec<u16> = pids.clone();
if !ts_pids.is_empty() {
ts_demuxer = Some(super::ts::TsDemuxer::new(&ts_pids));
}
}
}
// AACS decrypts whole 6144-byte (3-sector) units keyed off each read
// buffer's first 16 bytes, so reads/skips must stay 3-sector aligned.
// CSS and unencrypted content are per-2048-byte and self-synchronizing
// (align 1). Same rule the file-backed highway applies in resolve.rs.
let unit_align: u16 = match &decrypt_keys {
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// Wrap the input reader in DecryptingSectorSource so the internal
// fill_extents path sees plaintext bytes. For DecryptKeys::None
// (unencrypted / raw / test fixtures) the decorator is a pass-through.
let reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// Clone the shared loss counter once here so `lost_bytes()` never
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
Self {
reader,
decrypt_loss,
title,
decrypt_keys,
unit_align,
extents,
current_extent: 0,
current_offset: 0,
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
adaptive: AdaptiveBatch::new(batch_sectors),
errors: 0,
lost_bytes: 0,
skip_errors: false,
halt: None,
event_fn: None,
eof: false,
bytes_read_total: 0,
bytes_total_extents,
ts_demuxer,
ps_demuxer,
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
}
}
/// Set event handler for sector-level events (binary search, skip, recover).
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
/// Constructor-time builder: attach a [`Halt`] token so that when
/// any clone is cancelled, the next read-retry boundary inside
/// `fill_extents` returns `Err(Halted)`. Required for Stop to work
/// during dense bad-sector regions (where the outer PES read() loop
/// can spend minutes inside fill_extents before emitting a frame).
///
/// Pass the same `Halt` clone you hand to sweep / patch / mux so every
/// phase observes a single Stop signal.
pub fn with_halt(mut self, halt: Halt) -> Self {
self.halt = Some(halt);
self
}
fn is_halted(&self) -> bool {
self.halt
.as_ref()
.map(|h| h.is_cancelled())
.unwrap_or(false)
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
/// Skip decryption — return raw encrypted bytes. Updates both
/// the metadata-side key field and the wrapped reader's keys so
/// subsequent `read_sectors` calls become a pass-through.
pub fn set_raw(&mut self) {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
self.reader.set_keys(crate::decrypt::DecryptKeys::None);
}
fn fill_extents(&mut self) -> io::Result<bool> {
if self.current_extent >= self.extents.len() {
return Ok(false);
}
let ext_start = self.extents[self.current_extent].start_lba;
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(self.current_offset);
if remaining == 0 {
self.current_extent += 1;
self.current_offset = 0;
return self.fill_extents();
}
// start_lba comes from UDF/MPLS extents; a malformed extent near
// u32::MAX would overflow (debug panic / release wrap to a wrong LBA).
// Saturate for consistency with the rest of the file's arithmetic.
let lba = ext_start.saturating_add(self.current_offset);
// Adaptive sizer: start at current (preferred until a failure), shrink
// on failure, advance on success. One 5s read attempt per try — no
// retry loops, no sleeps. On size-1 failure, skip or error.
//
// Halt is checked at the top of every iteration — in a dense bad zone
// this loop can spend minutes shrinking and skipping sectors; without
// the check, Stop wouldn't take effect until the outer PES read() loop
// finally emits a frame, which may never happen.
let start_time = std::time::Instant::now();
loop {
if self.is_halted() {
return Err(crate::error::Error::Halted.into());
}
// Debug: log slow reads during mux — helps diagnose stalls
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
}
// Keep every read buffer starting on a real on-disc unit boundary.
// AACS (unit_align=3) decrypts whole 6144-byte units keyed off the
// buffer's first bytes, so a sub-unit read mid-extent desyncs the
// rest of the title; always read at least one full unit. Only the
// final partial unit at the extent tail (remaining < align) is read
// short — nothing follows it to desync. CSS/raw (align=1) is
// per-sector and self-synchronizing, so this is a no-op there.
let align = self.unit_align.max(1) as u32;
let want = remaining.min(self.adaptive.current() as u32);
let sectors: u16 = if align <= 1 {
want as u16
} else if remaining < align {
remaining as u16
} else if want < align {
align as u16
} else {
(want - want % align) as u16
};
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
let res = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false);
if let Ok(&got) = res.as_ref() {
// SectorSource::read_sectors returns the number of bytes
// written into buf. All in-tree sources return full-or-error,
// but a short count would leave the stale/zeroed tail of
// read_buf in place; trust the returned count, not `bytes`.
debug_assert!(got <= bytes, "read_sectors over-reported byte count");
if let Some(ev) = self.adaptive.on_success(sectors) {
self.emit(ev);
}
let bytes = got.min(bytes);
self.buf_valid = bytes;
self.current_offset += sectors as u32;
self.bytes_read_total = self.bytes_read_total.saturating_add(bytes as u64);
self.emit(EventKind::BytesRead {
bytes: self.bytes_read_total,
total: self.bytes_total_extents,
});
break;
}
// Transport failure (status=0xFF: USB-bridge crash / disconnect) is
// NOT a skippable bad sector. The bridge is wedged and every
// subsequent read fails identically, so shrinking + skipping past it
// — even under `skip_errors` — just marches the whole disc at one
// ~15s bridge-recovery per probe, producing no usable output (the
// "runs forever, no MKV" report). Abort immediately, highest
// priority, mirroring the multipass sweep's transport-failure rule
// in `read_error::handle_read_error`. The CLI/UX surfaces this so the
// user power-cycles the drive (or switches to multipass recovery).
if let Some(e) = res.as_ref().err() {
if e.is_scsi_transport_failure() {
let (status, sense) = extract_scsi_context(e);
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
}
if (sectors as u32) <= align {
// Bottomed out at one unit (AACS) / one sector (CSS) / the
// extent tail. Skip the WHOLE failed unit or bail. Zero-filling
// and advancing by the full unit keeps current_offset
// unit-aligned, so the next read still begins on a real AACS
// unit boundary (a 1-sector skip here would desync the rest of
// the title — the bug this guards).
if self.skip_errors {
let zb = sectors as usize * 2048;
self.read_buf.resize(zb, 0);
self.read_buf[..zb].fill(0);
self.buf_valid = zb;
self.errors += 1;
// `errors` counts skip events; `lost_bytes` counts the
// bytes actually zero-filled. For AACS (unit_align=3) a
// single event skips a whole 6144-byte unit, so loss
// estimates must use this, not `errors * 2048`.
self.lost_bytes = self.lost_bytes.saturating_add(zb as u64);
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
self.current_offset += sectors as u32;
break;
} else {
// Build the error from the failure we ALREADY hold.
// Re-reading the same known-bad LBA here doubled drive
// abuse (hard rule #2: repeated failed reads on the
// same LBA push the BU40N into fast-fail) and, if the
// retry transiently succeeded, dropped the good data
// and returned a bogus status=0/sense=None error for a
// readable sector.
let err = res.err();
let (status, sense) =
err.as_ref().map(extract_scsi_context).unwrap_or((0, None));
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
}
// Shrink and retry at the same LBA with a smaller batch.
if let Some(ev) = self.adaptive.on_failure() {
self.emit(ev);
}
}
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
Ok(true)
}
}
/// Per-stage profiling state — populated only when `FREEMKV_PROFILE`
/// is set. Logs a percentage breakdown via `tracing` (target "mux")
/// every [`PROFILE_INTERVAL`]. Zero overhead in normal runs (the
/// `DiscStream::profiling` check is the only added cost).
struct StageProf {
started: std::time::Instant,
last_dump: std::time::Instant,
fill_ns: u128,
feed_ns: u128,
consume_ns: u128,
bytes_in: u64,
}
const PROFILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
thread_local! {
static STAGE_PROF: std::cell::RefCell<Option<StageProf>> = const { std::cell::RefCell::new(None) };
}
fn prof_active() -> bool {
std::env::var_os("FREEMKV_PROFILE").is_some()
}
fn prof_tick(stage: &str, ns: u128, bytes: u64) {
STAGE_PROF.with(|cell| {
let mut slot = cell.borrow_mut();
if slot.is_none() {
if !prof_active() {
return;
}
let now = std::time::Instant::now();
*slot = Some(StageProf {
started: now,
last_dump: now,
fill_ns: 0,
feed_ns: 0,
consume_ns: 0,
bytes_in: 0,
});
}
let p = slot.as_mut().unwrap();
match stage {
"fill" => p.fill_ns += ns,
"feed" => p.feed_ns += ns,
"consume" => p.consume_ns += ns,
_ => {}
}
p.bytes_in += bytes;
let now = std::time::Instant::now();
if now.duration_since(p.last_dump) < PROFILE_INTERVAL {
return;
}
let elapsed_ms = now.duration_since(p.started).as_millis().max(1);
let fill_pct = p.fill_ns / 10_000 / elapsed_ms;
let feed_pct = p.feed_ns / 10_000 / elapsed_ms;
let consume_pct = p.consume_ns / 10_000 / elapsed_ms;
let mbps = p.bytes_in as u128 * 1000 / 1_000_000 / elapsed_ms;
tracing::debug!(
target: "mux",
"[profile] elapsed={}ms in={}MB/s fill={}% feed={}% consume={}%",
elapsed_ms, mbps, fill_pct, feed_pct, consume_pct,
);
p.last_dump = now;
});
}
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
loop {
// Profiling timestamps only when FREEMKV_PROFILE is set; otherwise
// these stay None and no Instant::now() is taken in the hot loop.
let t0 = self.profiling.then(std::time::Instant::now);
if !self.fill_extents()? {
self.eof = true;
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.ts_demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
}
}
}
}
}
// PS demuxer flush (DVD)
if let Some(ref mut demuxer) = self.ps_demuxer {
for ps in &demuxer.flush() {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
{
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
}
}
}
}
// Drain any access unit a codec parser buffered past the last
// PES (DTS-HD's final core+extension unit, assembled across
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
for frame in parser.flush() {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
return Ok(self.pending_frames.pop_front());
}
let bytes = self.buf_valid;
let t1 = self.profiling.then(std::time::Instant::now);
if let (Some(t0), Some(t1)) = (t0, t1) {
prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64);
}
// Plaintext: the wrapped reader (DecryptingSectorSource)
// applied AACS / CSS in-place during fill_extents'
// read_sectors call. The pre-0.18 inline decrypt step
// lived here.
if let Some(ref mut demuxer) = self.ts_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
let t2 = self.profiling.then(std::time::Instant::now);
if let (Some(t1), Some(t2)) = (t1, t2) {
prof_tick("feed", t2.duration_since(t1).as_nanos(), 0);
}
let skip_parse = self.skip_parse;
for pes in packets {
if let Some((_, track)) = self
.pid_to_track
.iter()
.find(|(pid, _)| *pid == pes.pid)
.copied()
{
if skip_parse {
// Profiling escape hatch — bypass the codec
// parser and pass the raw PES bytes straight
// through as a single PesFrame. Lets us
// attribute consumer-thread time to
// "demux + framing" vs "codec parse".
self.pending_frames.push_back(crate::pes::PesFrame {
track,
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
keyframe: false,
data: pes.data,
duration_ns: None,
});
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
}
}
}
}
let t3 = self.profiling.then(std::time::Instant::now);
if let (Some(t2), Some(t3)) = (t2, t3) {
prof_tick("consume", t3.duration_since(t2).as_nanos(), 0);
}
} else if let Some(ref mut demuxer) = self.ps_demuxer {
let packets = demuxer.feed(&self.read_buf[..bytes]);
for ps in &packets {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
}
self.buf_valid = 0;
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn headers_ready(&self) -> bool {
// FREEMKV_SKIP_PARSE bypasses codec parsers entirely for
// bottleneck profiling, so codec_private is never populated.
// Pretend headers are ready immediately in that mode so the
// CLI loop doesn't hang waiting for them.
if self.skip_parse {
return true;
}
for (idx, s) in self.title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
}
true
}
fn errors(&self) -> u64 {
self.errors
}
fn lost_bytes(&self) -> u64 {
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
// loss — bytes of scrambled AACS units the decorator could not decrypt
// and passed through still encrypted (the TS assembler silently drops
// them). Both are real missing content the abort gate must see; without
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
// missing segments passes even under abort_on_lost_secs=0.
self.lost_bytes
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
}
}
#[cfg(test)]
mod tests {
//! `DiscStream` is the only read-only `Stream` impl in tree (every
//! other concrete impl in `mux/*` is bidirectional or write-only).
//! These tests lock down a static `Send` assertion plus a
//! `Box<dyn Stream>` round trip exercising every method through the
//! trait object, so future Send-breaking edits to `DiscStream`'s
//! interior types fail at compile time.
use super::*;
use crate::disc::{ContentFormat, DiscTitle};
use crate::pes::Stream;
/// Static-assert `DiscStream: Send`. The `Stream` trait has `Send` as a
/// supertrait — if a future field on `DiscStream` is non-`Send` (e.g.
/// a `Box<dyn Read>` instead of `Box<dyn SectorSource>`), this fails
/// at compile time, before the runtime trait-object test below.
fn _assert_disc_stream_is_send() {
fn requires_send<T: Send>() {}
requires_send::<DiscStream>();
}
/// Trivial `SectorSource` that yields zeroed sectors. Empty title means
/// the demuxer produces no PES frames, so `read()` walks the extents to
/// EOF and returns `Ok(None)`. That's enough to exercise the trait-object
/// dispatch — the goal here is the bridge, not the demuxer.
struct ZeroReader {
capacity: u32,
}
impl crate::sector::SectorSource for ZeroReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
fn synthetic_title(sector_count: u32) -> DiscTitle {
DiscTitle {
extents: vec![crate::disc::Extent {
start_lba: 0,
sector_count,
}],
..DiscTitle::empty()
}
}
/// Smallest credible witness that `DiscStream` flows through `dyn Stream`:
/// build a `Box<dyn Stream>`, drive `read()` to EOF, exercise `info()` /
/// `headers_ready()` / `codec_private()` through the trait object.
#[test]
fn stream_via_dyn_object() {
let reader = ZeroReader { capacity: 8 };
let title = synthetic_title(8);
let stream = DiscStream::new(
Box::new(reader),
title,
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
let mut src: Box<dyn Stream> = Box::new(stream);
// Empty-title fixture has no streams configured, so headers are
// trivially ready and codec_private() yields nothing on track 0.
assert!(src.headers_ready());
assert!(src.codec_private(0).is_none());
let _ = src.info();
// Drive read() to EOF through the trait object — empty-title fixture
// produces no frames, but the call still routes through the blanket
// dispatch into Stream::read.
let mut frames = 0usize;
while src.read().expect("read").is_some() {
frames += 1;
if frames > 1024 {
panic!("unexpected unbounded frame stream from empty title");
}
}
assert_eq!(frames, 0);
}
/// `is_halted()` must observe a cancellation signal installed via
/// `with_halt(Halt)` — flipping the token must cause the next
/// `fill_extents` retry boundary to bail.
#[test]
fn halt_via_with_halt_observed_by_is_halted() {
let halt = Halt::new();
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
synthetic_title(8),
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
)
.with_halt(halt.clone());
assert!(!stream.is_halted());
halt.cancel();
assert!(
stream.is_halted(),
"with_halt token cancellation must be observed by is_halted()"
);
}
/// Recording `SectorSource`: logs every `(lba, count)` request and
/// returns `Err` whenever the requested range covers `bad_sector`.
/// Successful reads return zeroed sectors (which are NOT
/// `is_aacs_scrambled`, so `DecryptingSectorSource` passes them through
/// even with synthetic AACS keys — no real decrypt is attempted).
struct RecordingReader {
capacity: u32,
bad_sector: u32,
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
}
impl crate::sector::SectorSource for RecordingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count));
let end = lba + count as u32;
if self.bad_sector >= lba && self.bad_sector < end {
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(0x02),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
/// `SectorSource` that fails every read covering `bad_sector` with a
/// SCSI **transport failure** (status=0xFF) — the USB-bridge-crash sentinel
/// that `Drive::read` surfaces as `DiscRead { status: Some(0xFF), .. }`.
/// Logs each `(lba, count)` so a test can prove the failure was not
/// retried/skipped.
struct TransportFailReader {
capacity: u32,
bad_sector: u32,
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
}
impl crate::sector::SectorSource for TransportFailReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count));
let end = lba + count as u32;
if self.bad_sector >= lba && self.bad_sector < end {
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
/// Regression: a USB-bridge transport crash (status=0xFF) during a direct
/// single-pass `disc://→mkv://` rip must ABORT immediately, even under
/// `skip_errors=true`. The pre-fix behavior treated it as a skippable bad
/// sector: zero-fill, advance, repeat — marching the whole disc at one
/// ~15s bridge-recovery per probe, producing no MKV ("runs forever"). The
/// fix mirrors the multipass sweep: transport failure short-circuits to an
/// error before any shrink/skip, so exactly ONE read is issued and no skip
/// is counted.
#[test]
fn transport_failure_aborts_single_pass_even_with_skip_errors() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = TransportFailReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
stream.skip_errors = true;
let res = stream.fill_extents();
assert!(
res.is_err(),
"transport failure must abort fill_extents, not skip past it"
);
assert_eq!(
stream.errors, 0,
"a transport-failure abort must NOT count as a skipped sector"
);
let reads = log.lock().unwrap();
assert_eq!(
reads.len(),
1,
"transport failure must abort after the first failed read with no \
shrink/retry/skip-ahead; got reads {reads:?}"
);
}
/// AACS unit-alignment skip (the #1 coverage gap). With `unit_align=3`
/// (DecryptKeys::Aacs) and `skip_errors=true`, a single bad mid-extent
/// sector must NOT desync the rest of the title: every `read_sectors`
/// request must start on a 3-sector unit boundary relative to the extent
/// start, and the skip over the failed unit must advance the cursor by a
/// whole 3-sector unit (never a single sector).
#[test]
fn aacs_reads_stay_unit_aligned_and_skip_whole_units() {
const COUNT: u32 = 30;
const ALIGN: u32 = 3;
// Bad sector at offset 13 — inside unit 4 (offsets 12,13,14). The
// whole unit must be skipped, keeping the cursor unit-aligned.
let bad = 13u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let title = synthetic_title(COUNT);
let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
};
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
stream.skip_errors = true;
assert_eq!(
stream.unit_align, ALIGN as u16,
"AACS keys must set unit_align=3"
);
// Drive fill_extents to EOF (no PES demux needed — we observe the
// raw read pattern directly).
let ext_start = 0u32;
let mut guard = 0;
loop {
match stream.fill_extents() {
Ok(true) => {}
Ok(false) => break,
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
}
guard += 1;
assert!(guard < 1000, "fill_extents did not reach EOF");
}
let reads = log.lock().unwrap();
assert!(!reads.is_empty(), "expected at least one read");
for &(lba, count) in reads.iter() {
assert_eq!(
(lba - ext_start) % ALIGN,
0,
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
lba - ext_start
);
// Non-tail reads must be a whole number of units; the only
// permitted short read is the final partial unit (here COUNT is a
// multiple of ALIGN, so every read should be unit-multiple unless
// it shrank below one unit — which is itself a single unit).
let _ = count;
}
// At least one error was skipped (the bad unit) and a SectorSkipped
// event was emitted; errors counter advanced by exactly the bad units.
assert!(stream.errors >= 1, "expected the bad unit to be skipped");
// Regression: `lost_bytes` must account for the WHOLE skipped unit
// (3 sectors = 6144 bytes), not a single sector. A loss estimate
// built from `errors * 2048` would undercount AACS loss ~3x — the
// single-pass abort-gate bug this guards against. Exactly one unit
// is bad in this fixture, so lost_bytes == errors * ALIGN * 2048.
assert_eq!(
stream.lost_bytes,
stream.errors * ALIGN as u64 * 2048,
"AACS skip must record a whole unit (6144 B) per skip event, not 2048"
);
assert!(
stream.lost_bytes > stream.errors * 2048,
"lost_bytes must exceed the errors*2048 undercount for AACS units"
);
// Crucial anti-desync assertion: the read that bottomed out and was
// skipped must have been a single 3-sector unit starting at offset 12
// (the unit boundary at or below the bad sector 13), NOT a 1-sector
// read at 13. Find a recorded read of (12, 3).
assert!(
reads
.iter()
.any(|&(lba, count)| lba == 12 && count == ALIGN as u16),
"expected a unit-aligned (lba=12,count=3) read over the bad unit; got {reads:?}"
);
// And NO single-sector read at the bad sector itself (would be a desync).
assert!(
!reads.iter().any(|&(lba, count)| lba == bad && count == 1),
"a 1-sector read at the bad sector {bad} would desync the AACS unit stream"
);
}
/// `unit_align == 1` (DecryptKeys::None) variant: single-sector skips
/// still work (CSS/raw is self-synchronizing, so a 1-sector skip is
/// correct there — contrast with the AACS whole-unit skip above).
#[test]
fn unencrypted_single_sector_skip_works() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
);
stream.skip_errors = true;
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
let mut guard = 0;
loop {
match stream.fill_extents() {
Ok(true) => {}
Ok(false) => break,
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
}
guard += 1;
assert!(guard < 1000, "fill_extents did not reach EOF");
}
let reads = log.lock().unwrap();
// The bad sector must have been retried down to a single sector and
// skipped at count==1 — the self-synchronizing per-sector path.
assert!(
reads.iter().any(|&(lba, count)| lba == bad && count == 1),
"align=1 must bottom out at a 1-sector read over the bad sector; got {reads:?}"
);
assert!(stream.errors >= 1);
// align=1: a skip event covers exactly one sector, so lost_bytes
// and errors*2048 agree (the AACS undercount does not apply here).
assert_eq!(
stream.lost_bytes,
stream.errors * 2048,
"single-sector (align=1) skip must record exactly 2048 B per event"
);
}
#[test]
fn halt_via_with_halt_from_arc_observed_by_is_halted() {
let arc = Arc::new(AtomicBool::new(false));
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
synthetic_title(8),
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
)
.with_halt(Halt::from_arc(arc.clone()));
assert!(!stream.is_halted());
arc.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
stream.is_halted(),
"with_halt(Halt::from_arc) must observe Arc-side flips"
);
}
}