Fix fifteen defects across perf, resource, panics and key hygiene
All 21 findings held up under verification; 15 fixed here, 6 deferred to files another agent held this round, 0 rejected. **A defect in my own round-2 probe fix.** CHUNK_SECTORS was 1024, and 1024 % 3 == 1 — verified — so every chunk after the first was misaligned against the 6144-byte AACS aligned unit and would be REJECTED by DecryptingSectorSource's alignment gate. On an encrypted disc the forced-subtitle probe I added last round would have read almost nothing past its first chunk. Now 1023 sectors (341 aligned units) with a const assertion that fails the build if it stops dividing, plus set_unit_base per extent so the source's gate is anchored where the extent actually starts. **The same probe skipped sectors on a short read**, advancing by the REQUESTED count rather than the bytes actually returned, so a partial read silently left a gap in the middle of the evidence. It now advances by n/SECTOR_BYTES and clamps n to the buffer. **Its cache key omitted the PGS PID set**, so a playlist declaring an extra subtitle PID got another playlist's verdict for a track that had never been probed. And the key was the whole extent list, so partial clip sharing missed entirely. Both fixed by keying (start_lba, sector_count, pid) — and per-extent keying was shown SOUND rather than assumed: ForcedTracker is two monotone booleans, so per-extent evidence composes by field-wise OR, order- and grouping-independently. Making that honest required per-extent demux state, so an extent's evidence comes only from its own bytes, and memoising only extents whose read reached a designed stop. **A reachable panic in the timeline.** mkvstream::parse_block accepts a TimestampScale up to i64::MAX, so a video frame can set high_ns = i64::MAX and the next passive frame panicked adding the backstep. In release it wrapped negative instead, firing the straggler clamp for essentially every passive frame — audio and subtitles rewritten onto the wrong point of the output timeline. All four sites saturate. **A public constructor divided by zero**: PrefetchedSectorSource::new_with_events with unit_align == 0. Now InvalidInput, matching its batch_sectors sibling. **Two Debug impls printed key material.** DiscInputs (volume_id, mkb, unit_key_ro, samples) and UnitKeyFile both derived Debug. Nothing logs them today — fixed as prevention, because the next tracing::debug! someone adds is the leak. A doc claim that DiscInputs "contains no secrets" was false and is corrected. **An env-var multiply could overflow** in file_sector_source; now bounded at 64 GiB like its writeback sibling, with the parse split out so the bound is testable without touching process env. **The mp4 demuxer allowed one sample per file byte** — ~64x RAM amplification. Now file_len/16, since only vide/soun tracks are indexed and the shortest legal AC-3 frame is 128 bytes. **Two pipeline concurrency defects**: a consumer apply() error was invisible to the producer, and abandon/finalise had a TOCTOU where a caller could report an unfinalised output. Both fixed with compare-exchange state rather than a bool. **Two per-frame copies removed**, both MEASURED rather than reasoned: the AU assembler now hands its allocation to the frame (same pointer, unchanged capacity, proven by asserting the pointer) and tsmux reuses one Annex-B buffer across frames. Both keep capacity deliberately — a naive split_off would have cost more than it saved. **A comment pointed at the wrong file** for a mirrored constant; the mirror is now compiler-enforced with a const assertion converting 90 kHz ticks to ns, so drift fails the build. Deferred to another agent's files, all confirmed: detect_rate's fractional-twin snap, the mp4 reserve's u32 truncation, round_up_grain's overflow, the quadratic base-key gap fill, and MkvStream's frame cap counting frames rather than bytes. Every fix verified red by reverting it. Also noted for later: DecodeSampleSet still derives Debug over multi-MB of on-disc ciphertext.
This commit is contained in:
+83
-2
@@ -321,8 +321,7 @@ impl AuAssembler {
|
||||
self.disc_marks.pop_front();
|
||||
}
|
||||
|
||||
let data = self.buf[..end].to_vec();
|
||||
self.buf.drain(..end);
|
||||
let data = self.take_front(end);
|
||||
self.base += end as u64;
|
||||
self.reset_scan();
|
||||
out.push(AssembledAu {
|
||||
@@ -336,6 +335,41 @@ impl AuAssembler {
|
||||
out
|
||||
}
|
||||
|
||||
/// Detach `buf[..end]` as the emitted AU's own `Vec` and leave `buf` holding
|
||||
/// the tail.
|
||||
///
|
||||
/// The AU's bytes are HANDED OVER — `buf`'s allocation becomes the returned
|
||||
/// `Vec` and a fresh buffer (pre-sized to the same capacity, so the next AU
|
||||
/// accumulates without re-growing) takes its place holding only the short
|
||||
/// tail. `buf[..end].to_vec()` + `drain(..end)` instead copied every AU out
|
||||
/// in full: on a UHD HEVC title that is a whole-frame memcpy (hundreds of KB)
|
||||
/// per coded picture, ~200k times, for bytes that are about to be discarded
|
||||
/// from `buf` anyway.
|
||||
///
|
||||
/// The allocation COUNT is unchanged (one per AU either way — the frame `Vec`
|
||||
/// before, the replacement buffer now), so the only difference is the copy
|
||||
/// that no longer happens. Nothing depends on `buf` keeping its identity: the
|
||||
/// only state tied to `buf[0]`'s position is `base`/`scan_pos`/`opener_pos`,
|
||||
/// which the caller updates immediately after.
|
||||
///
|
||||
/// Falls back to a copy when the buffer's capacity is far larger than the AU
|
||||
/// (a small AU after a multi-MB one): handing over would otherwise attach an
|
||||
/// oversized idle allocation to a small frame for as long as the frame queues
|
||||
/// downstream, trading a copy for resident memory.
|
||||
fn take_front(&mut self, end: usize) -> Vec<u8> {
|
||||
let cap = self.buf.capacity();
|
||||
if cap > end.saturating_mul(2) {
|
||||
let data = self.buf[..end].to_vec();
|
||||
self.buf.drain(..end);
|
||||
return data;
|
||||
}
|
||||
let mut tail = Vec::with_capacity(cap.max(self.buf.len() - end));
|
||||
tail.extend_from_slice(&self.buf[end..]);
|
||||
let mut data = std::mem::replace(&mut self.buf, tail);
|
||||
data.truncate(end);
|
||||
data
|
||||
}
|
||||
|
||||
/// Reset the incremental boundary-scan cursor. Called whenever `buf[0]` moves
|
||||
/// (an AU drained, or leading bytes discarded) so the next scan starts fresh
|
||||
/// from the new AU opener.
|
||||
@@ -841,4 +875,51 @@ mod tests {
|
||||
"over-cap AU is force-flushed, not buffered forever"
|
||||
);
|
||||
}
|
||||
|
||||
/// MEASURED: a drained AU must be HANDED the accumulation buffer's
|
||||
/// allocation, not copied out of it. The emitted `Vec`'s data pointer is the
|
||||
/// buffer's own pointer — which is only true if no full-frame copy happened.
|
||||
/// (`buf[..end].to_vec()` allocates fresh, so the pointers differ.) One
|
||||
/// whole-AU memcpy per coded picture is ~200k memcpys of a few hundred KB
|
||||
/// each on a UHD feature.
|
||||
#[test]
|
||||
fn drained_au_takes_over_the_buffer_allocation_without_copying() {
|
||||
let mut a = AuAssembler::for_codec(Codec::H264);
|
||||
// An AU large enough that the buffer's capacity is not >2x its size (the
|
||||
// small-AU copy path exists so a small frame cannot carry an oversized
|
||||
// idle allocation downstream).
|
||||
let au1 = au(0x11, 400 * 1024);
|
||||
let au2 = au(0x22, 400 * 1024);
|
||||
let mut stream = au1.clone();
|
||||
stream.extend_from_slice(&au2);
|
||||
|
||||
// Push everything except the final byte of AU2's delimiter, so no AU has
|
||||
// been emitted yet but the buffer holds the whole of AU1.
|
||||
a.push(&stream[..au1.len() + 3], Some(1), None, None, false);
|
||||
let before = a.buf.as_ptr();
|
||||
let cap_before = a.buf.capacity();
|
||||
let out = a.push(
|
||||
&stream[au1.len() + 3..au1.len() + 4],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(
|
||||
out[0].data, au1,
|
||||
"handover must preserve the AU bytes exactly"
|
||||
);
|
||||
assert_eq!(
|
||||
out[0].data.as_ptr(),
|
||||
before,
|
||||
"the emitted AU must own the buffer's allocation (no whole-frame copy)"
|
||||
);
|
||||
assert_eq!(
|
||||
a.buf.capacity(),
|
||||
cap_before,
|
||||
"the replacement buffer keeps the capacity, so the next AU does not re-grow"
|
||||
);
|
||||
assert_eq!(a.buf.len(), 4, "the buffer holds only AU2's delimiter tail");
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -201,12 +201,21 @@ struct ContentLightLevel {
|
||||
// window (HEVC reorder depth tops out ~16 frames, <1 s at 24 fps). 3 s = 270000
|
||||
// ticks sits well above any legitimate reorder dip and far below any real clip's
|
||||
// duration, so it never false-triggers within a clip. This MIRRORS the mux-side
|
||||
// `DISCONTINUITY_BACKSTEP_NS` (3 s) in `mux/mkv.rs`, which independently rebases
|
||||
// the timeline at the same boundaries; here it drives the CRA→BLA rewrite that
|
||||
// kills the dangling-RASL "Could not find ref with POC N" decode errors a
|
||||
// `DISCONTINUITY_BACKSTEP_NS` (3 s) in `mux/timeline.rs`, which independently
|
||||
// rebases the timeline at the same boundaries; here it drives the CRA→BLA rewrite
|
||||
// that kills the dangling-RASL "Could not find ref with POC N" decode errors a
|
||||
// concatenated multi-clip title otherwise produces.
|
||||
const BACKSTEP_TICKS: i64 = 270_000;
|
||||
|
||||
// The mirror above is enforced, not just described: 90 kHz ticks → ns is
|
||||
// × (1_000_000_000 / 90_000) = × 100_000 / 9, so 270_000 ticks must be exactly
|
||||
// `DISCONTINUITY_BACKSTEP_NS`. Changing either constant without the other fails
|
||||
// the build here, which is the drift the comment exists to prevent.
|
||||
const _: () = assert!(
|
||||
BACKSTEP_TICKS * 100_000 / 9 == crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS,
|
||||
"HEVC BACKSTEP_TICKS must mirror mux::timeline::DISCONTINUITY_BACKSTEP_NS"
|
||||
);
|
||||
|
||||
// The 33-bit 90 kHz PES PTS counter wraps at 2^33 ticks (~26.5 h). When the raw
|
||||
// PTS steps backward by approximately a full period — i.e. it landed just past
|
||||
// the wrap — it is a counter wraparound, NOT a clip reset: unwrap it (add 2^33)
|
||||
|
||||
+70
-15
@@ -35,6 +35,17 @@ const MAX_TRACKS: usize = 512;
|
||||
/// hostile file's allocation without truncating any legitimate track.
|
||||
const MAX_SAMPLE_COUNT: usize = 1 << 24;
|
||||
|
||||
/// Smallest number of FILE bytes one indexed sample is assumed to occupy — the
|
||||
/// divisor that turns `file_len` into a sample-count budget (see `from_reader`).
|
||||
///
|
||||
/// Only `vide` and `soun` tracks are indexed here, and no real coded video or
|
||||
/// audio access unit is anywhere near this small: the shortest legal AC-3 frame is
|
||||
/// 128 bytes, an AAC frame is hundreds, and a video sample carries at least a NAL
|
||||
/// header plus slice data. A 2-hour title runs to thousands of file bytes per
|
||||
/// sample, so this cannot truncate a genuine track — it only stops a crafted
|
||||
/// sample table from claiming more samples than the file could possibly hold.
|
||||
const MIN_FILE_BYTES_PER_SAMPLE: u64 = 16;
|
||||
|
||||
/// Absolute ceiling on a single allocation sized from an untrusted MP4 field (a
|
||||
/// per-sample buffer or the `moov` payload). The EOF check alone is not enough:
|
||||
/// `file_len` is cheaply inflatable with a sparse file (`truncate -s 8G`), so a
|
||||
@@ -98,13 +109,16 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
||||
// crafted file with a fixed-size `stsz` claiming count=0xFFFFFFFF can't
|
||||
// inflate the `sizes`/`Vec<SampleRef>` allocations past the file's own size
|
||||
// (a genuine large title has file_len ≫ sample count, so it is unaffected).
|
||||
// NOTE on the bound this actually gives: each indexed sample costs about
|
||||
// 52 bytes of RAM (SampleRef 40 + u32 size 4 + u64 offset 8, plus 4 each
|
||||
// for the expanded stts/ctts), so the ceiling is ~52x file_len, not 1x —
|
||||
// capped by MAX_SAMPLE_COUNT. That is still a real bound (a 1 MiB crafted
|
||||
// file cannot reach the 16M-sample ceiling), just not the "past the file's
|
||||
// own size" the previous comment implied.
|
||||
let mut sample_budget = MAX_SAMPLE_COUNT.min(file_len.min(usize::MAX as u64) as usize);
|
||||
// The bound is in file BYTES PER SAMPLE, not in samples: each indexed
|
||||
// sample costs ~60 bytes of RAM (SampleRef 40 + u32 size 4 + u64 offset 8,
|
||||
// plus 4 each for the expanded stts/ctts), so a budget of one sample per
|
||||
// file byte still let a 16 MiB crafted file (a fixed-size `stsz` declaring
|
||||
// 16M one-byte samples, a one-entry stsc/stco/stts) force a ~1 GiB eager
|
||||
// allocation and a 16M-element sort before a single frame was read — a 64x
|
||||
// amplification. Dividing by MIN_FILE_BYTES_PER_SAMPLE caps the
|
||||
// amplification at ~4x instead.
|
||||
let mut sample_budget = MAX_SAMPLE_COUNT
|
||||
.min((file_len / MIN_FILE_BYTES_PER_SAMPLE).min(usize::MAX as u64) as usize);
|
||||
|
||||
// Bound the scan at MAX_TRACKS *matches* so a crafted moov packed with tiny
|
||||
// (8-byte) trak headers can't force the scan to materialize a Vec far
|
||||
@@ -1341,10 +1355,10 @@ mod tests {
|
||||
/// NOT the 16M `MAX_SAMPLE_COUNT` ceiling. Mutation check: revert the budget
|
||||
/// to a bare `MAX_SAMPLE_COUNT` and this file yields ~16M samples, failing the
|
||||
/// `<= file_len` (and `< MAX_SAMPLE_COUNT`) assertions below.
|
||||
#[test]
|
||||
fn stsz_sample_count_bounded_by_file_len() {
|
||||
use std::io::Cursor;
|
||||
// A minimal audio trak, but with a fixed-size stsz lying about its count.
|
||||
/// A minimal-but-complete audio `trak` whose fixed-size `stsz` LIES about its
|
||||
/// sample count (`u32::MAX`), with an stsc/stts wide enough to place whatever
|
||||
/// count survives the budget. The shape the sample-table budget bounds.
|
||||
fn audio_trak_hostile_count() -> Vec<u8> {
|
||||
let mdhd = {
|
||||
let mut p = vec![0u8; 24];
|
||||
p[12..16].copy_from_slice(&48_000u32.to_be_bytes()); // timescale
|
||||
@@ -1410,6 +1424,13 @@ mod tests {
|
||||
mdia.extend_from_slice(&hdlr);
|
||||
mdia.extend_from_slice(&minf);
|
||||
let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia));
|
||||
trak
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stsz_sample_count_bounded_by_file_len() {
|
||||
use std::io::Cursor;
|
||||
let trak = audio_trak_hostile_count();
|
||||
let moov = mp4_box(b"moov", &trak);
|
||||
|
||||
let file_len = moov.len() as u64;
|
||||
@@ -1418,11 +1439,16 @@ mod tests {
|
||||
"fixture stays a few hundred bytes ({file_len})"
|
||||
);
|
||||
let rd = Mp4Reader::from_reader(Cursor::new(moov), "hostile".into()).unwrap();
|
||||
// The index must be bounded by the file's byte length, NOT the 16M ceiling.
|
||||
// The index must be bounded by the file's byte length in FILE BYTES PER
|
||||
// SAMPLE, not one sample per byte: each indexed sample costs ~60 bytes of
|
||||
// RAM, so a one-sample-per-byte budget still let a 16 MiB crafted file
|
||||
// force a ~1 GiB allocation (64x amplification) before a frame was read.
|
||||
assert!(
|
||||
(rd.samples.len() as u64) <= file_len,
|
||||
"sample count {} must be bounded by file_len {file_len}, not the count lie",
|
||||
rd.samples.len()
|
||||
(rd.samples.len() as u64) <= file_len / MIN_FILE_BYTES_PER_SAMPLE,
|
||||
"sample count {} must be bounded by file_len/{MIN_FILE_BYTES_PER_SAMPLE} \
|
||||
({}), not by the count lie",
|
||||
rd.samples.len(),
|
||||
file_len / MIN_FILE_BYTES_PER_SAMPLE
|
||||
);
|
||||
assert!(
|
||||
rd.samples.len() < MAX_SAMPLE_COUNT,
|
||||
@@ -1430,6 +1456,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The RAM amplification the byte-per-sample budget actually bounds: a small
|
||||
/// crafted file whose `stsz` claims `u32::MAX` samples must not force an eager
|
||||
/// multi-hundred-MB index. At ~60 bytes of RAM per indexed sample, a budget of
|
||||
/// one sample per file byte gave ~60x the input size; the assertion below pins
|
||||
/// the amplification factor rather than a raw count, so it fails if the budget
|
||||
/// ever goes back to counting samples per byte.
|
||||
#[test]
|
||||
fn sample_index_ram_is_bounded_by_a_multiple_of_the_file() {
|
||||
use std::io::Cursor;
|
||||
// ~64 KiB of `free` padding so file_len is large enough for the ratio to
|
||||
// be meaningful, with the same lying stsz as above.
|
||||
let mut traks = Vec::new();
|
||||
traks.extend_from_slice(&audio_trak_hostile_count());
|
||||
let mut file = mp4_box(b"moov", &traks);
|
||||
file.extend_from_slice(&mp4_box(b"free", &vec![0u8; 64 * 1024]));
|
||||
let file_len = file.len() as u64;
|
||||
|
||||
let rd = Mp4Reader::from_reader(Cursor::new(file), "amp".into()).unwrap();
|
||||
// ~60 bytes of RAM per sample; assert the index cannot exceed ~4x the file.
|
||||
const RAM_PER_SAMPLE: u64 = 60;
|
||||
let ram = rd.samples.len() as u64 * RAM_PER_SAMPLE;
|
||||
assert!(
|
||||
ram <= file_len * 4,
|
||||
"sample index RAM {ram} B from a {file_len} B file is more than 4x \
|
||||
amplification ({} samples)",
|
||||
rd.samples.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Build an audio `trak` identical to `audio_trak(48_000)` but with the
|
||||
/// named stbl child box omitted. Used to reach the untrusted-input guards
|
||||
/// that drop a track whose `stsz` says samples exist yet whose `stco`/`co64`
|
||||
|
||||
+57
-4
@@ -130,10 +130,22 @@ impl TimelineContinuity {
|
||||
// within a backstep below the frontier keeps the remap to genuine
|
||||
// tail stragglers; a long audio-only tail, a sparse subtitle, or an
|
||||
// EL frame that simply runs ahead is left on the current offset.
|
||||
//
|
||||
// Every comparison below saturates. `high` is derived from an
|
||||
// untrusted container timestamp (an `mkv://` source's
|
||||
// CLUSTER_TIMESTAMP × TimestampScale is clamped only against
|
||||
// `i64::MAX`, so a hostile file can put the frontier AT `i64::MAX`),
|
||||
// and `raw_pts_ns` can be negative (a SimpleBlock's signed relative
|
||||
// timestamp). Plain `high + BACKSTEP` / `high - BACKSTEP` would then
|
||||
// overflow: a panic out of the public `Stream::write` path in an
|
||||
// overflow-checked build, and in release a wrap to the opposite sign
|
||||
// that fires the straggler clamp on essentially every passive frame.
|
||||
if let Some(high) = self.high_ns {
|
||||
if mapped > high + DISCONTINUITY_BACKSTEP_NS {
|
||||
if mapped > high.saturating_add(DISCONTINUITY_BACKSTEP_NS) {
|
||||
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
|
||||
if prev_mapped <= high && prev_mapped >= high - DISCONTINUITY_BACKSTEP_NS {
|
||||
if prev_mapped <= high
|
||||
&& prev_mapped >= high.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
|
||||
{
|
||||
return prev_mapped;
|
||||
}
|
||||
}
|
||||
@@ -147,12 +159,17 @@ impl TimelineContinuity {
|
||||
return adj;
|
||||
};
|
||||
let adj = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
if adj < high - DISCONTINUITY_BACKSTEP_NS {
|
||||
if adj < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) {
|
||||
// Clip-boundary reset (real multi-clip seam): continue just after the
|
||||
// frontier. Save the previous offset so a lagging non-video tail
|
||||
// frame can be recognised and remapped to the seam (see above).
|
||||
self.prev_offset_ns = self.offset_ns;
|
||||
let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS);
|
||||
// `high - adj` is a backward step, so positive — but both ends are
|
||||
// untrusted (`high` up to i64::MAX, `adj` down to i64::MIN), so
|
||||
// saturate rather than panic in a checked build.
|
||||
let bump = high
|
||||
.saturating_sub(adj)
|
||||
.saturating_add(DISCONTINUITY_GAP_NS);
|
||||
self.offset_ns = self.offset_ns.saturating_add(bump);
|
||||
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
self.high_ns = Some(high.max(adj2));
|
||||
@@ -464,4 +481,40 @@ mod tests {
|
||||
"frame must stay in the new epoch (> frontier), got {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A saturated frontier must not panic the muxer. An `mkv://` source's
|
||||
/// tick→ns multiply saturates at `i64::MAX` (mkvstream's `parse_block`), so a
|
||||
/// hostile TimestampScale/CLUSTER_TIMESTAMP puts `high_ns` AT `i64::MAX`.
|
||||
/// Every subsequent PASSIVE frame then evaluated `high + BACKSTEP`, which
|
||||
/// panicked ("attempt to add with overflow") out of the public
|
||||
/// `Stream::write` path in any overflow-checked build.
|
||||
#[test]
|
||||
fn saturated_frontier_does_not_overflow_on_passive_frame() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
// Video establishes the frontier at the saturation point.
|
||||
assert_eq!(adj_video(&mut tc, i64::MAX), i64::MAX);
|
||||
assert_eq!(tc.high_ns, Some(i64::MAX));
|
||||
// Passive frame: `high + BACKSTEP` overflowed here.
|
||||
let out = adj_other(&mut tc, 0);
|
||||
assert_eq!(out, 0, "a passive frame keeps its own mapping");
|
||||
// And a passive frame AT the frontier: `high - BACKSTEP` is the other
|
||||
// unchecked side of the straggler discriminator.
|
||||
assert_eq!(adj_other(&mut tc, i64::MAX), i64::MAX);
|
||||
}
|
||||
|
||||
/// The epoch-decision side of the same arithmetic: `adj < high - BACKSTEP`
|
||||
/// and the `high - adj` bump both took untrusted ends. A frontier at
|
||||
/// `i64::MIN`-adjacent values (a negative SimpleBlock-relative timestamp) and
|
||||
/// a `i64::MAX` frontier are both reachable from container data.
|
||||
#[test]
|
||||
fn extreme_video_pts_does_not_overflow_the_epoch_bump() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
assert_eq!(adj_video(&mut tc, i64::MAX), i64::MAX);
|
||||
// Hard backward jump to the negative extreme: `high - adj` overflowed.
|
||||
let out = adj_video(&mut tc, i64::MIN);
|
||||
// Saturated bump (`i64::MAX`) applied to `i64::MIN` → -1, and the
|
||||
// frontier never regresses.
|
||||
assert_eq!(out, -1);
|
||||
assert_eq!(tc.high_ns, Some(i64::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
+88
-16
@@ -70,6 +70,15 @@ pub struct TsMuxer<W: Write> {
|
||||
/// so a header-only `m2ts://` output can't be reported as success —
|
||||
/// mirroring `MkvMuxer.frame_count`.
|
||||
frame_count: u64,
|
||||
/// Reusable Annex-B conversion buffer for NAL video, kept across frames so
|
||||
/// the conversion does not allocate (and free) a whole-frame buffer per
|
||||
/// coded picture — the same reason `MkvMuxer` keeps its `block_group_buf`.
|
||||
/// A UHD frame is ~310 KB, i.e. an mmap + first-touch page faults + munmap
|
||||
/// per frame, ~200k times per feature. Cleared (never shrunk) per use, so it
|
||||
/// settles at the largest frame's size. Taken out of `self` while in use, so
|
||||
/// the borrow of the converted bytes does not conflict with the `&mut self`
|
||||
/// the writer needs.
|
||||
annex_b: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<W: Write> TsMuxer<W> {
|
||||
@@ -84,6 +93,7 @@ impl<W: Write> TsMuxer<W> {
|
||||
video_codec: vec![Codec::Hevc; n],
|
||||
base_pts_ns: None,
|
||||
frame_count: 0,
|
||||
annex_b: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,11 +190,20 @@ impl<W: Write> TsMuxer<W> {
|
||||
// start-code ES, never length-prefixed) the bytes pass through
|
||||
// unchanged, so borrow `data` directly rather than copying it; only
|
||||
// NAL video needs an owned Annex-B conversion buffer.
|
||||
let es_data: std::borrow::Cow<'_, [u8]> = if is_video && self.is_nal_video(track) {
|
||||
// Size the buffer once for the whole frame. `Vec::new()` re-grew from
|
||||
// zero capacity on every frame, reallocating repeatedly inside a single
|
||||
// ~310 KB conversion. The slack covers any prepended parameter sets.
|
||||
let mut annex_b = Vec::with_capacity(data.len() + 1024);
|
||||
// Take the reusable conversion buffer out of `self` for the duration of
|
||||
// this frame: that frees the `&mut self` the writer needs below while the
|
||||
// converted bytes are still borrowed, and keeps the allocation across
|
||||
// frames instead of making (and dropping) a whole-frame one per picture.
|
||||
// It is put back at the end of the function, so a mid-frame error path
|
||||
// costs only the buffer's capacity, never correctness.
|
||||
let mut annex_b = std::mem::take(&mut self.annex_b);
|
||||
let convert = is_video && self.is_nal_video(track);
|
||||
if convert {
|
||||
annex_b.clear();
|
||||
// Size once for the whole frame; the slack covers any prepended
|
||||
// parameter sets. `reserve` is a no-op once the buffer has settled at
|
||||
// the largest frame's size.
|
||||
annex_b.reserve(data.len() + 1024);
|
||||
if keyframe && !self.params_written[track] {
|
||||
if let Some(ref cp) = self.codec_privates[track] {
|
||||
// avcC and hvcC are DIFFERENT box layouts; parsing one with
|
||||
@@ -221,13 +240,12 @@ impl<W: Write> TsMuxer<W> {
|
||||
// allocations and two full-frame copies. At ~200k frames averaging
|
||||
// ~310 KB of ES on a UHD, that is ~124 GB of pointless memcpy.
|
||||
append_length_prefixed_as_annex_b(&mut annex_b, data);
|
||||
std::borrow::Cow::Owned(annex_b)
|
||||
} else {
|
||||
if is_video {
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
std::borrow::Cow::Borrowed(data)
|
||||
};
|
||||
} else if is_video {
|
||||
self.params_written[track] = true;
|
||||
}
|
||||
// Borrowed from a LOCAL (the taken-out buffer), never from `self`, so the
|
||||
// `&mut self` writes below are free of it.
|
||||
let es_data: &[u8] = if convert { &annex_b } else { data };
|
||||
|
||||
let pts_90k = if pts_ns >= 0 {
|
||||
(pts_ns as u64).saturating_mul(9) / 100_000
|
||||
@@ -240,15 +258,32 @@ impl<W: Write> TsMuxer<W> {
|
||||
// access units into multiple PES packets. Each emitted PES carries
|
||||
// the same PTS and starts on its own PUSI packet (only the keyframe
|
||||
// RAI rides the first packet of the first PES).
|
||||
if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD {
|
||||
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe, &es_data)?;
|
||||
//
|
||||
// The write result is held rather than `?`-propagated so the conversion
|
||||
// buffer goes back into `self` on every path.
|
||||
let res = if is_video || es_data.len() <= MAX_BD_PES_PAYLOAD {
|
||||
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe, es_data)
|
||||
} else {
|
||||
let mut first_pes = true;
|
||||
let mut res = Ok(());
|
||||
for chunk in es_data.chunks(MAX_BD_PES_PAYLOAD) {
|
||||
self.write_pes_chain(track, pid, pts_90k, is_video, keyframe && first_pes, chunk)?;
|
||||
res = self.write_pes_chain(
|
||||
track,
|
||||
pid,
|
||||
pts_90k,
|
||||
is_video,
|
||||
keyframe && first_pes,
|
||||
chunk,
|
||||
);
|
||||
if res.is_err() {
|
||||
break;
|
||||
}
|
||||
first_pes = false;
|
||||
}
|
||||
}
|
||||
res
|
||||
};
|
||||
self.annex_b = annex_b;
|
||||
res?;
|
||||
// A frame that survived the pre-keyframe drop guard above and reached
|
||||
// the writer counts as emitted. `finish()` checks this so a zero-frame
|
||||
// mux fails loudly instead of producing a header-only "success".
|
||||
@@ -1133,4 +1168,41 @@ mod tests {
|
||||
assert_eq!(got.len(), big.len(), "no bytes lost in the PES split");
|
||||
assert_eq!(got, big, "split audio reassembles byte-for-byte");
|
||||
}
|
||||
|
||||
/// MEASURED: the Annex-B conversion buffer must be REUSED across video
|
||||
/// frames, not allocated per frame. Both the allocation's address and its
|
||||
/// capacity are unchanged after the second and third same-sized frames — if
|
||||
/// the conversion allocated a fresh `Vec` per frame (the old
|
||||
/// `Vec::with_capacity(data.len() + 1024)`), the buffer left on the muxer
|
||||
/// would be empty with zero capacity, and each frame would pay an
|
||||
/// allocate/first-touch/free cycle over the whole ~310 KB frame.
|
||||
#[test]
|
||||
fn annex_b_conversion_buffer_is_reused_across_frames() {
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
|
||||
let idr = fake_hevc_nal(19, 300_000);
|
||||
mux.write_frame(0, 0, true, &idr).unwrap();
|
||||
let cap = mux.annex_b.capacity();
|
||||
let ptr = mux.annex_b.as_ptr();
|
||||
assert!(
|
||||
cap >= idr.len(),
|
||||
"buffer survives the frame with the frame's capacity, got {cap}"
|
||||
);
|
||||
|
||||
for i in 1..4 {
|
||||
let p = fake_hevc_nal(1, 300_000);
|
||||
mux.write_frame(0, i * 41_000_000, false, &p).unwrap();
|
||||
assert_eq!(
|
||||
mux.annex_b.as_ptr(),
|
||||
ptr,
|
||||
"frame {i}: conversion buffer must be the same allocation"
|
||||
);
|
||||
assert_eq!(
|
||||
mux.annex_b.capacity(),
|
||||
cap,
|
||||
"frame {i}: no re-grow once the buffer has settled"
|
||||
);
|
||||
}
|
||||
mux.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user