Files
libfreemkv/docs/mux-driver.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
Security
- Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable
  from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged.

CI / tooling
- clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny
  dependency-audit gate with first-party git sources allowed (#15); audience-based
  comment-guard (#17).

Docs
- README (keydb ownership, BD-J parser count), drive-access transport/reset
  notes, mux timeline module-doc links.
2026-09-02 12:40:55 -07:00

236 lines
12 KiB
Markdown

# `src/mux/driver.rs` — design notes
## Overview
High-level mux driver — the one place that runs the
`construct → headers gate → open sink → pump → finish` pipeline the
consumers (CLI `pipe`/`pipe_disc`, autorip `run_mux`) each hand-rolled.
`mux_stream` DRIVES the existing highway; it does not replace it. For a
file/ISO source it constructs the SAME
[`build_iso_pipeline`](../src/mux/resolve.rs) 3-stage
prefetch → demux → parse chain the consumers build today (the 660 MB/s
highway), reads frames off it exactly where the consumers call
`stream.read()`, and writes them through a
`WRITE_PIPELINE_DEPTH`-deep write `Pipeline` so the latency-bound sink
write overlaps the next read. No wrapper is inserted around the reader or
the frames — the only threads are the three inside `build_iso_pipeline` plus
the single write consumer, exactly as in the consumers today.
## Gate ordering (bug fix)
The `chapters://` / `json://` metadata sinks write their whole file from the
scanned title at `output()` time and consume no PES frames, so they need no
codec headers. The CLI put that short-circuit AFTER the `headers_resolved`
gate (`pipe.rs`), so a metadata export on a title whose video headers never
resolved failed with `MkvInvalid`. Here the short-circuit runs BEFORE the
header pump/gate — by construction a metadata sink can never trip the header
gate.
## `NO_SEND_DEADLINE`
A slow-but-alive downstream — a paused pager, a backpressured pipe, a slow
network sink — must NOT be reported as an interrupted mux, which is exactly
what a finite per-frame deadline would do after that much backpressure.
`None` therefore means "block on backpressure, don't time out", while still
honouring the halt token for Ctrl-C / `/api/stop`.
This is safe because `Pipeline::send_with_halt` slices its wait at
`crate::halt::POLL_INTERVAL` (250 ms) internally and re-checks the halt
token on EVERY slice — halt responsiveness is independent of the deadline
value. So a huge (but finite, `Instant`-overflow-safe) duration blocks
indefinitely on a live-but-slow sink yet still unwinds within ~250 ms of a
halt. ~10 years is far past any real mux; a literal `Duration::MAX` risks
panicking the `Instant::now() + deadline` addition inside `send_with_halt`.
## `MuxOptions::send_deadline`
`None` means no backpressure timeout: the send blocks as long as the
downstream is alive (only a `halt` interrupts it). The CLI's interactive
stdout / network sinks pass `None` so a slow-but-alive consumer (paused
pager, backpressured pipe, slow peer) is never spuriously reported as an
interrupted/incomplete mux — matching the pre-refactor inline
`output.write()` which had no deadline.
## `MuxEvents` event-firing threads
The driver fires `Self::on_output_opened` and the write-side
`Self::on_write_progress` from the driving thread; the reader-side events
(`Self::on_sector_skipped` / `Self::on_batch_size_changed` /
`Self::on_read_error`, plus `Self::on_read_progress`) are fired by the
cloned `EventFn` from the highway's producer thread / the live
`DiscStream`'s read loop.
Progress is split into two callbacks because consumers drive their progress
UI from different sides of the pipeline: the CLI renders from the WRITE side
(bytes finalised to the sink), autorip from the READ side (bytes pulled off
the disc). Keeping them distinct avoids the old ambiguous single
`on_progress(bytes, total)` where the caller couldn't tell which number it
was handed.
## `MuxOutcome::undelivered_streams`
Non-empty means the file does NOT match the pre-mux plan
(`mp4_fit_report`), even with `completed = true`: those streams are
missing. Always empty when the run stopped before the sink was finalised
(nothing was finished, so nothing is known).
## `mux_run_completed`
A clean operator stop (`interrupted`), a wedged/halted finalize
(`finalize_failed` — the write `Pipeline` returned
`Halted`/`PipelineJoinTimeout` from `finish`), or a halt cancellation each
force `completed = false`, so the consumer runs its stop-preserves-staging
path instead of reporting a truncated file as done.
Extracted as a pure fn because the `finalize_failed` branch is otherwise
reachable only through real write-thread wedge timing (the
internally-built `WriteSink` offers no seam to force a `finish` timeout
deterministically), so the mapping is unit-tested here directly.
## `session_mux_keys`
CSS title keys are per-VTS, and `Disc::decrypt_keys` returns the LARGEST
title's VTS key — muxing a bonus title in a DIFFERENT VTS with it
descrambles to corrupt MPEG-PS. `resolve_dvd_title_key`'s crack-guard only
fires when `keys` is `None`, so passing the whole-disc key would skip the
per-title crack entirely. This mirrors the sibling `resolve.rs::input()`
DVD special-case (and the pre-refactor CLI `pipe.rs`). AACS / genuinely-clear
discs resolve from `decrypt_keys()` with no read.
## `resolve_inline_base_map`
Resolves the base AACS key map for an INLINE live-drive mux (the `Session`/
`Live` arms) BEFORE the reader is moved into `DiscStream::new` — the
counterpart to the map resolution `build_iso_pipeline` performs internally
for the file highway.
Under the map-only decrypt model an AACS `DecryptingSectorSource` decrypts
NOTHING until a key map is installed: with no map the AACS arm of the
decrypt path fails loud with `Error::DecryptFailed` on the first content
unit (the deliberate "a reader built without its map is a bug" guard). Both
inline arms used to skip this — `Session` installed no map at all, and
`Live` installed only a caller-supplied forensic FMTS map (`None` for a
plain AACS disc) — so EVERY plain AACS Blu-ray/UHD muxed via the live
single-pass path failed `DecryptFailed` on the first content read. This
resolves + returns the map so the caller can install it via
`DiscStream::with_key_map`.
- AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map,
multi-CPS per-extent key selection, or FMTS per-segment map) and return
`Some(map)`. Resolution failure propagates (fail loud), matching the ISO
path's decrypt gate.
- CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside
`DiscStream::new`, and a genuinely-clear disc needs no map.
- `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key.
The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe
and any multi-CPS unit samples); a single-CPS disc — the overwhelming
majority, including every single-key UHD — resolves its map with NO content
read beyond the one-time UDF filesystem probe. The caller then moves the
same reader into `DiscStream::new`; reads are by absolute LBA, so the
sampling leaves no read-position state behind.
## `reader_event_fn`
Cloning the `Arc` into the returned closure is precisely what lets a
borrowed-lifetime consumer's events reach the highway's producer thread — a
`&dyn MuxEvents` borrow cannot satisfy the `'static` bound.
Mapping (real `EventKind` variants):
- `BytesRead { bytes, total }` → `MuxEvents::on_read_progress` (read-side;
the file highway's only reader event — `total` is the extents' byte total)
- `SectorSkipped { sector }` → `MuxEvents::on_sector_skipped` (live only)
- `BatchSizeChanged { new_size, reason }` → `MuxEvents::on_batch_size_changed`
(live only)
- `ReadError { sector, .. }` → `MuxEvents::on_read_error`
Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32`
(the disc's LBA space), so they are narrowed with `as u32`.
## `mux_input_live_uses_inline_discstream_and_applies_key_map` test
`MuxInput::Live` builds the INLINE `DiscStream` (not the highway) AND
applies the forensic `key_map` before reading. Proof is the recorded LBA
set: `with_key_map` rewrites the extent walk via `AacsKeyMap::read_plan` so
the alternate-phase (odd) forensic units are NEVER fetched off the source.
The zeroed reader can't resolve codec headers, so the mux drains to a
`NoStreams`/`MkvInvalid` refusal — irrelevant here; the test asserts only
the read plan the reader actually executed.
Mutation: deleting the `stream = stream.with_key_map(map)` line in the
`Live` arm leaves the extents un-rewritten, so the reader DOES fetch the
dropped odd-phase LBAs and the "never read" assertion below fails. (A
second mutation — routing `Live` through `build_iso_pipeline` instead of
`DiscStream::new` — would not compile against a `Box<dyn SectorSource>` and
drop the inline `fill_extents` retry entirely.)
## `mux_input_live_aacs_without_caller_map_resolves_and_decrypts` test
END-TO-END decrypt on the live single-pass `MuxInput::Live` path with a
plain (non-FMTS) AACS disc and NO caller-supplied key map — the exact shape
of `freemkv rip disc://…` and autorip's non-FMTS single-pass. The driver's
`Live` arm must RESOLVE + INSTALL the base AACS key map itself; the unit
then decrypts to a valid audio PES and the mux drains and finalises.
This is the regression guard for the confirmed bug: without the map the
AACS `DecryptingSectorSource` fails `DecryptFailed` on the first content
unit and no AACS disc could ever be live-muxed.
Mutation: deleting the `resolve_inline_base_map` call (or the
`stream = stream.with_key_map(map)` install) in the `Live` arm leaves the
reader mapless → the first content batch cannot decrypt (root cause
`DecryptFailed`, surfaced through `fill_extents`' non-skip read-error path
as a `DiscRead`) → `mux_stream` returns `Err` and `out.completed` is never
reached (verified: the mux aborts instead of finalising).
## `mux_input_session_aacs_without_caller_map_resolves_and_decrypts` test
END-TO-END decrypt on the live single-pass `MuxInput::Session` path — the
exact shape of `freemkv rip disc://…mkv`. The `Session` arm runs the SAME
sequence as `Live` (take_reader → resolve_inline_base_map → DiscStream →
with_key_map), but until now had NO end-to-end coverage because a real
`DiscSession` needs a live `Drive`. Using the `#[cfg(test)]`
`from_parts_for_test` constructor, a genuinely-AACS-encrypted unit muxed
through the `Session` arm must resolve+install the base key map itself and
DECRYPT to a valid audio PES.
Mutation: dropping `stream = stream.with_key_map(map)` (or the
resolve_inline_base_map call) in the `Session` arm leaves the reader
mapless → the content batch cannot decrypt → the mux aborts (`Err`) and
`out.completed` is never reached.
## `finalize_failed_forces_incomplete_outcome` test (FIX 3)
A mux whose read side drained cleanly (`interrupted = false`, halt not
cancelled) but whose write pipeline WEDGED on finish (`finish_with_halt` →
`Err(Halted | PipelineJoinTimeout)` → `finalize_failed = true`) must fall
through to `completed = false` — never surface a truncated file as a
finished rip.
Mutation: dropping `finalize_failed` from `mux_run_completed`'s condition
makes `mux_run_completed(false, true, false)` return `true` → this fails.
## `mux_input_session_out_of_range_title_is_clean_error_not_panic` test (FIX 4)
`MuxInput::Session` with a `title_index` past the disc's title count must
surface a clean `Error::MuxTrackRange` (code E9011), NOT panic on the
out-of-range `titles.get(idx)`. Everything else is valid (disc scanned,
reader staged) so the range guard is the sole failure.
Mutation: replacing the `.ok_or(MuxTrackRange…)?` guard with `.unwrap()`
panics on the out-of-range index → this test fails.
## `HEADER_BUFFER_CAP_BYTES`
Normally `headers_ready()` resolves after a handful of frames; a
damaged/undecryptable title whose video `codec_private` never resolves
would otherwise grow the pre-headers buffer without bound (the whole
30-90 GB title, one PES frame at a time) until the process is OOM-killed.
512 MiB is far more than any real codec-private resolution needs but small
enough to fail fast rather than swap the box to death. Once exceeded the
mux is refused with `Error::MuxHeaderBufferExceeded` — its OWN code, not
the headers-never-resolved gate's `Error::MkvInvalid`, which
`error::is_skippable_title_stub` reports as a skippable stub. Mirrors
autorip's pre-refactor `HEADER_BUFFER_CAP_BYTES`.