v1.0.0-rc.1

CSS keyless decrypt (Stevenson), AACS 1.0/2.0/2.1, MPEG-2 DVD, multi-OS SCSI, multipass recovery, mux highway, audit hardening
This commit is contained in:
Matthew Jackson
2026-06-21 21:06:07 -07:00
parent e8bb6225ac
commit 5941c059c6
53 changed files with 7439 additions and 2340 deletions
+168 -12
View File
@@ -92,7 +92,14 @@ impl DemuxThread {
let mut ts = ts;
let mut ps = ps;
let handle = std::thread::Builder::new()
// SAFETY (no teardown deadlock on spawn failure): the worker closure is
// `move`, so it OWNS `prefetch_rx` and `recycle_tx`. If `spawn` fails it
// consumes and drops the closure, which drops those channel ends — so the
// upstream producer observes disconnection and exits on its own BEFORE we
// join it. `producer_shell` (whose Drop joins the producer) is NOT captured
// by the closure, so dropping it on the Err path below joins a producer that
// has already exited → non-blocking.
let spawn_result = std::thread::Builder::new()
.name("freemkv-demux".into())
.spawn(move || {
let prof = std::env::var_os("FREEMKV_PROFILE").is_some();
@@ -101,7 +108,14 @@ impl DemuxThread {
let mut prof_read_ns: u128 = 0;
let mut prof_feed_ns: u128 = 0;
let mut prof_bytes: u64 = 0;
// Liveness heartbeat: the feed loop blocks on prefetch_rx.recv()
// and on tx.send(); a stuck upstream/downstream shows up as the
// beat going silent. Total is unknown for a stream, so `pos` is
// cumulative bytes fed.
let mut hb = crate::progress::Heartbeat::new("demux_feed");
let mut fed_bytes: u64 = 0;
loop {
hb.tick(fed_bytes, 0);
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
// Caller-initiated stop is a clean termination —
// send the Eof sentinel so the consumer doesn't
@@ -128,6 +142,7 @@ impl DemuxThread {
None
};
let n = buf.len();
fed_bytes += n as u64;
if let Some(ref mut d) = ts {
let pkts = d.feed(&buf);
let t2 = if prof {
@@ -140,7 +155,14 @@ impl DemuxThread {
// recycle channel is closed the producer has
// exited; we drop the buffer and continue.
let _ = recycle_tx.send(buf);
if !pkts.is_empty() && tx.send(DemuxBatch::Ts(pkts)).is_err() {
// Always send the batch — even when empty (null /
// untracked PIDs only). send() is how we detect an early
// consumer disconnect; on mostly-null extents spanning
// gigabytes of disc the batch can stay empty for a long
// time, and skipping empty sends would hide the
// disconnect until a (possibly never-arriving) non-empty
// batch. An empty batch yields no frames downstream.
if tx.send(DemuxBatch::Ts(pkts)).is_err() {
return;
}
if prof {
@@ -153,7 +175,8 @@ impl DemuxThread {
{
let el = now.duration_since(prof_started).as_millis().max(1);
let mbps = prof_bytes as u128 * 1000 / 1_000_000 / el;
eprintln!(
tracing::debug!(
target: "mux",
"[demux] elapsed={}ms in={}MB/s read={}% feed={}%",
el,
mbps,
@@ -170,7 +193,9 @@ impl DemuxThread {
} else if let Some(ref mut d) = ps {
let pkts = d.feed(&buf);
let _ = recycle_tx.send(buf);
if !pkts.is_empty() && tx.send(DemuxBatch::Ps(pkts)).is_err() {
// Always send (even empty) — same early-disconnect
// detection rationale as the TS branch above.
if tx.send(DemuxBatch::Ps(pkts)).is_err() {
return;
}
} else {
@@ -194,8 +219,18 @@ impl DemuxThread {
// this and drops `tx`, which the consumer reads as an
// error rather than a clean EOF.
let _ = tx.send(DemuxBatch::Eof);
})
.map_err(|e| crate::error::Error::IoError { source: e })?;
});
let handle = match spawn_result {
Ok(h) => h,
Err(e) => {
// `prefetch_rx`/`recycle_tx` were moved into the (now-dropped)
// failed spawn closure, so the producer already sees disconnection.
// Dropping producer_shell here joins that already-exiting producer.
drop(producer_shell);
return Err(crate::error::Error::IoError { source: e });
}
};
Ok((
Self {
@@ -458,10 +493,14 @@ mod tests {
}
#[test]
fn empty_batches_are_not_forwarded() {
// The worker only forwards NON-empty packet vecs (`!pkts.is_empty()`).
// A buffer that yields no complete PES (e.g. a single continuation
// packet with no PUSI ever) must not produce a Ts batch — only Eof.
fn empty_batches_are_forwarded_for_disconnect_detection() {
// The worker forwards EVERY batch, including empty ones, so an early
// consumer disconnect is detected promptly via `send()` (crossbeam's
// Sender has no non-destructive disconnect check). An empty batch is
// harmless downstream: `pump_one_batch` consumes 0 packets and returns
// Ok(true) — only the explicit `Eof` sentinel ends the stream. A buffer
// that yields no complete PES therefore produces an empty Ts batch
// followed by Eof.
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4);
let pid = 0x1011;
@@ -482,7 +521,124 @@ mod tests {
drop(pf_tx);
let batches = collect_batches(&rx, Duration::from_secs(5));
assert_eq!(batches.len(), 1, "only Eof; no empty Ts batch forwarded");
assert!(matches!(batches[0], DemuxBatch::Eof));
assert_eq!(batches.len(), 2, "empty Ts batch forwarded, then Eof");
assert!(matches!(batches[0], DemuxBatch::Ts(ref v) if v.is_empty()));
assert!(matches!(batches[1], DemuxBatch::Eof));
}
/// Regression: worker must detect consumer disconnect even when every
/// demux batch is empty (no matching PIDs / null packets).
///
/// Before the fix, `tx.send()` was never called for empty batches so the
/// worker never observed the consumer drop — it would spin through ALL
/// remaining extents before exiting, causing DemuxThread::drop's join()
/// to block for minutes on a mostly-untracked disc region.
///
/// The watchdog: if the worker doesn't exit within 1 s of the consumer
/// drop the test fails (rather than hanging forever as the bug would).
#[test]
fn worker_exits_promptly_on_consumer_drop_during_empty_batches() {
// Use an untracked PID so every batch the demuxer produces is empty.
let tracked_pid = 0x1011u16;
let untracked_pid = 0x0100u16;
const SYNC: u8 = 0x47;
// Build a non-PUSI continuation packet on the untracked PID so
// TsDemuxer.feed() returns an empty Vec every call.
let mut empty_pkt = vec![0u8; 192];
empty_pkt[4] = SYNC;
empty_pkt[5] = ((untracked_pid >> 8) as u8) & 0x1F; // no PUSI
empty_pkt[6] = (untracked_pid & 0xFF) as u8;
empty_pkt[7] = 0x10; // payload only
// Large prefetch channel — enough that the worker will be spinning
// through empty batches long after the consumer drops.
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(64);
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(64);
let ts = super::super::ts::TsDemuxer::new(&[tracked_pid]);
let (dt, rx) =
DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, Some(ts), None).unwrap();
// Fill the prefetch channel with empty-batch buffers.
for _ in 0..64 {
pf_tx.send(Ok(empty_pkt.clone())).unwrap();
}
// Drop the consumer — the worker should notice during the next
// empty-batch iteration (is_disconnected() check).
drop(rx);
// Give the worker a generous but bounded window to observe the
// disconnect and exit. A regression (spin-until-exhaustion) would
// take >> 1 s; correct behaviour exits almost immediately.
let join_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let join_done2 = join_done.clone();
let watchdog = std::thread::spawn(move || {
drop(dt); // joins the worker
join_done2.store(true, std::sync::atomic::Ordering::Relaxed);
});
// Also close the producer so the worker doesn't block on prefetch_rx
// if somehow is_disconnected is not triggered (belt-and-suspenders).
drop(pf_tx);
watchdog.join().unwrap();
assert!(
join_done.load(std::sync::atomic::Ordering::Relaxed),
"worker must exit promptly after consumer drop during empty batches"
);
}
/// Regression: on thread-spawn failure the channels must be dropped BEFORE
/// the producer shell so the upstream producer observes disconnection and
/// exits, allowing join() to complete without hanging.
///
/// A true EAGAIN/pids-limit spawn failure cannot be reliably forced in a
/// unit test without root or ulimit co-operation, so we test the
/// drop-order contract directly: a mock shell that panics if join() is
/// called while either channel end is still open.
///
/// The test constructs a `(prefetch_tx, prefetch_rx)` pair where the tx
/// side is held by a sentinel that stays alive as long as either channel
/// end is open, then asserts that the sentinel is gone by the time
/// producer_shell's join logic would run. Because we can't force a real
/// spawn failure, we instead verify the helper logic in isolation: drop
/// `prefetch_rx` and `recycle_tx` first, then observe the producer-side
/// sender is disconnected, which is the property the fix relies on.
#[test]
fn channels_disconnected_before_producer_join_on_spawn_failure() {
// Build a prefetch channel pair. The producer "thread" is simulated by
// holding prefetch_tx; we verify it observes disconnection after we
// drop prefetch_rx (and only after — not before).
// crossbeam channels expose disconnection only through send/recv
// results (there is no is_disconnected()), so we probe it that way.
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(1);
let (rc_tx, rc_rx) = bounded::<Vec<u8>>(1);
// Before any drop: the producer-side ends are live (a send into the
// depth-1 prefetch channel succeeds; the recycle receiver can still
// be fed).
assert!(
pf_tx.send(Ok(vec![1, 2, 3])).is_ok(),
"prefetch_tx must accept a send before any drop"
);
// Simulate the spawn-failure teardown: the move-closure owns prefetch_rx
// and recycle_tx, so dropping them mirrors `spawn` dropping the failed
// closure before producer_shell is joined.
drop(pf_rx);
drop(rc_tx);
// Now the producer-side handles observe disconnection via Err results —
// a blocked producer send/recv returns Err and the producer exits, so
// the subsequent join() completes without hanging.
assert!(
pf_tx.send(Ok(vec![4, 5, 6])).is_err(),
"prefetch_tx send must fail after prefetch_rx drop (producer would exit)"
);
assert!(
rc_rx.recv().is_err(),
"recycle_rx recv must fail after recycle_tx drop"
);
}
}