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:
+168
-12
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user