Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion quest/m1/ts-reliability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ catalog linger (#5305).

- [PAT changes](/quest/m1/ts-reliability/pat-lifecycle.md) - a running export follows a moved PMT PID
- [TS workflow passes](/quest/m1/ts-reliability/validation-green.md) - the scheduled TS lanes pass on main again, with no lane loosened
- [Export SI order](/quest/m1/ts-reliability/export-si-order.md) - a TS export sends SI and media after its PMT and a PCR interval, so a hop keeps first emissions
- [A demoted subscriber priority reaches the publisher](/quest/m1/ts-reliability/stitch-catch-up.md) - a mid-stream priority demotion reaches the publisher on lite-07 and moq-transport, including an old instance after an epoch switch

## Related
Expand Down
27 changes: 0 additions & 27 deletions quest/m1/ts-reliability/export-si-order.md

This file was deleted.

15 changes: 14 additions & 1 deletion rs/moq-mux/src/container/ts/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ fn sanitize_mux_rate(rate: u64) -> Option<u64> {
/// Use [`next`](Self::next) to pull one [`Frame`] per PCR grid slot: its `payload`
/// is the TS packets belonging to that slot, stamped at the slot's boundary. The
/// leading PAT/PMT rides on the first frame (so it inherits a real timestamp), and
/// is re-emitted at video keyframes and periodically for mid-stream tune-in.
/// is re-emitted at video keyframes and periodically for mid-stream tune-in. SI and
/// whole PES or sections wait until a PCR interval has followed it, since a reader
/// places them on the program clock and the PMT is what names it.
/// Returns `None` when the broadcast ends.
///
/// A rendition the catalog moves to another track (a new `track` or `broadcast`) goes on out
Expand Down Expand Up @@ -2759,6 +2761,11 @@ where

self.program.schedule.set_tables(pmt_pid);
self.program.schedule.set_clock(pcr_pid);
// A reader of the first tables has no program clock yet. Tables that take over from
// an earlier program's flag a break in the clock it already reads instead.
if !self.program.pcr_discontinuity {
self.program.schedule.open();
}
for track in self.program.tracks.values() {
self.program.liveness.register(track.pid);
}
Expand Down Expand Up @@ -3139,6 +3146,12 @@ where
self.write_section(&mut out, pid, section)?;
}
}
// A reader drops SI it cannot place on the program clock, so while the opening's PCR
// interval may still be ahead of the unit this frame goes in, the schedule holds the SI
// for it rather than the media it would lead.
if self.program.schedule.clocking() {
self.program.schedule.push_clocked(std::mem::take(&mut out));
}

// The tables lead the unit that carries this frame, behind whatever went before it.
if kind.placed() {
Expand Down
258 changes: 238 additions & 20 deletions rs/moq-mux/src/container/ts/export_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,6 @@ where
(out, exporter.stats())
}

/// An adaptation-field-only single-packet frame: the exporter's PCR carriage.
fn is_pcr_frame(frame: &Frame) -> bool {
frame.payload.len() == 188 && frame.payload[3] & 0x30 == 0x20
}

fn assert_packet_aligned(ts: &[u8]) {
assert!(!ts.is_empty(), "no TS output");
assert_eq!(ts.len() % 188, 0, "output not a whole number of 188-byte packets");
Expand Down Expand Up @@ -3504,6 +3499,226 @@ async fn service_layer_survives_roundtrip() {
assert_eq!(program2.pmt_pid, program.pmt_pid, "PMT PID survived");
}

/// An export leads with its PAT and PMT, then a PCR interval on the clock they name, before
/// its SI: a re-import places every SI table's first emission instead of dropping it until it
/// repeats, padded or not, and every PES with it.
#[tokio::test(start_paused = true)]
async fn a_reimport_keeps_the_first_si() {
let data = include_bytes!("test_data/bbb.ts");
let nit = make_long_section(0x40, 0x1234, 1, 0, 0, &[0xf0, 0x00]);
// Past bbb's PCRs, where its import keeps SI: bbb's own SDT precedes them.
let mut input = data.to_vec();
input.extend_from_slice(&si_packet(0x0010, &nit));
let mut sdt = data[..188].to_vec();
sdt[3] = (sdt[3] & 0xf0) | ((sdt[3] + 1) & 0x0f);
input.extend_from_slice(&sdt);
let sdt = ts_sections(&sdt, 0x0011);

// Each export reads an import of its own.
for rate in [None, Some(5_000_000)] {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve());
import.decode(&BytesMut::from(&input[..])).unwrap();
import.finish().unwrap();
let units = |stats: super::stats::Snapshot| {
stats
.streams
.iter()
.filter(|(_, stream)| stream.units > 0)
.map(|(pid, stream)| (*pid, stream.units))
.collect::<Vec<_>>()
};
let imported = units(import.stats());

let mut export = open_ts(
crate::source::announced(&consumer).await,
crate::catalog::CatalogFormat::Hang,
)
.await
.unwrap();
if let Some(rate) = rate {
export = export.with_mux_rate(rate);
}
let ts = drain_with(export).await;
assert_packet_aligned(&ts);

// No SI goes out before the PMT and two PCRs after it.
let packets = ts.as_chunks::<188>().0;
let pid = |packet: &[u8; 188]| u16::from(packet[1] & 0x1f) << 8 | u16::from(packet[2]);
let pmt = packets.iter().position(|packet| pid(packet) == 0x1000).expect("a PMT");
let clock = packets
.iter()
.enumerate()
.skip(pmt)
.filter(|(_, packet)| super::import::pcr(packet).is_some())
.nth(1)
.expect("two PCRs after the PMT")
.0;
let first = packets
.iter()
.position(|packet| [0x0010, 0x0011].contains(&pid(packet)))
.expect("SI");
assert!(
first > clock,
"{rate:?}: SI at packet {first}, the PMT at {pmt} and its second PCR at {clock}"
);

let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let consumer2 = broadcast2.consume();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import2 = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import2.decode(&BytesMut::from(ts.as_ref())).unwrap();
import2.finish().unwrap();
let stats = import2.stats();
for (pid, stream) in &stats.streams {
assert_eq!(
stream.unplaced, 0,
"{rate:?}: PID {pid:#x} dropped what it could not place"
);
}
assert_eq!(units(stats), imported, "{rate:?}: every PES");

let si = catalog2.snapshot().ext.mpegts.si;
assert_eq!(
read_si_sections(&consumer2, &si[&0x0010][&0x40].track).await,
vec![Bytes::from(nit.clone())],
"{rate:?}: the NIT"
);
assert_eq!(
read_si_sections(&consumer2, &si[&0x0011][&0x42].track).await,
sdt,
"{rate:?}: the SDT"
);
}
}

/// A gap longer than a skip after the first frame still leaves the opening's PCR interval
/// whole: the first SI follows two PCRs a reader can measure, and a re-import places it
/// instead of dropping it until it repeats, padded or not.
#[tokio::test(start_paused = true)]
async fn a_reimport_keeps_the_first_si_across_a_gap() {
for rate in [None, Some(5_000_000)] {
let mut broadcast = moq_net::broadcast::Info::new().produce();
let consumer = broadcast.consume();
let mut catalog = crate::catalog::Producer::new(
&mut broadcast,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();

let avcc = crate::codec::h264::build_avcc(&[Bytes::from_static(SPS)], &[Bytes::from_static(PPS)]).unwrap();
let track = broadcast
.create_track(
broadcast.unique_name(".avc1"),
hang::container::track_info(hang::catalog::PRIORITY.video),
)
.unwrap();
let name = track.name().to_string();
let mut sdt_track = broadcast.create_track("0x0011-0x42.si", timed()).unwrap();
sdt_track
.write_frame(
Timestamp::ZERO,
Bytes::from(make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8])),
)
.unwrap();
{
let mut guard = catalog.modify().unwrap();
let mut cfg = VideoConfig::new(H264 {
profile: 0x64,
constraints: 0,
level: 0x1f,
inline: false,
});
cfg.container = Container::Legacy;
cfg.description = Some(avcc);
guard.video.renditions.insert(name.clone(), cfg);
guard.ext.mpegts.si.entry(0x0011).or_default().insert(
0x42,
tscat::SiEntry {
track: "0x0011-0x42.si".to_string(),
interval: Some(Duration::from_secs(2)),
..Default::default()
},
);
}

// Five seconds between frames, past the second a skip keeps before what comes next.
let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data));
for (sec, slice) in [(0, 0x65u8), (5, 0x41)] {
producer
.write(Frame {
timestamp: Timestamp::from_micros(sec * 1_000_000).unwrap(),
duration: None,
payload: length_prefixed(&[&[slice; 300]]),
keyframe: sec == 0,
})
.unwrap();
}
producer.finish().unwrap();

// No delay: each frame settles the slots before it, so the gap is skipped at once.
let mut export = open_ts(
crate::source::announced(&consumer).await,
crate::catalog::CatalogFormat::Hang,
)
.await
.unwrap()
.with_replay();
if let Some(rate) = rate {
export = export.with_mux_rate(rate);
}
let mut ts = BytesMut::new();
while let Ok(frame) = tokio::time::timeout(DRAIN, export.next()).await {
let Some(frame) = frame.expect("exporter error") else {
break;
};
ts.extend_from_slice(&frame.payload);
}
assert_packet_aligned(&ts);

// The two PCRs between the PMT and the first SI step forward by at most a valid interval.
let packets = ts.as_chunks::<188>().0;
let pid = |packet: &[u8; 188]| u16::from(packet[1] & 0x1f) << 8 | u16::from(packet[2]);
let pmt = packets.iter().position(|packet| pid(packet) == 0x1000).expect("a PMT");
let first = packets.iter().position(|packet| pid(packet) == 0x0011).expect("SI");
let pcrs: Vec<u64> = packets[pmt..first].iter().filter_map(super::import::pcr).collect();
let [.., before, last] = pcrs[..] else {
panic!("{rate:?}: {} PCRs between the PMT and the first SI", pcrs.len());
};
let step = (last + super::mux_rate::PCR_WRAP - before) % super::mux_rate::PCR_WRAP;
assert!(
(1..=super::mux_rate::MAX_INTERVAL).contains(&step),
"{rate:?}: the PCR steps {before} to {last} before the first SI"
);

let mut broadcast2 = moq_net::broadcast::Info::new().produce();
let catalog2 = crate::catalog::Producer::new(
&mut broadcast2,
crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::<tscat::Ext>::default()),
)
.unwrap();
let mut import = crate::container::ts::Import::new(broadcast2, catalog2.reserve());
import.decode(&BytesMut::from(ts.as_ref())).unwrap();
import.finish().unwrap();
for (pid, stream) in &import.stats().streams {
assert_eq!(
stream.unplaced, 0,
"{rate:?}: PID {pid:#x} dropped what it could not place"
);
}
}
}

/// Each SI PID must be re-emitted on its own interval, independently of the PSI cadence
/// and of video keyframes. The fixtures are a fraction of a second long, so nothing else
/// distinguishes a correct interval from "emitted once and never again": this builds a
Expand Down Expand Up @@ -4769,9 +4984,7 @@ async fn a_selected_programs_si_survives_export() {
);
}

/// The sections `ts` carries on `pid`, each whole in one packet. Read off the wire rather
/// than by a re-import: SI an export leads with precedes its PCRs, so an import drops it
/// until it repeats.
/// The sections `ts` carries on `pid`, each whole in one packet, read off the wire.
fn ts_sections(ts: &[u8], pid: u16) -> Vec<Bytes> {
ts.chunks(188)
.filter(|p| (u16::from(p[1] & 0x1f) << 8 | u16::from(p[2])) == pid && p[1] & 0x40 != 0)
Expand Down Expand Up @@ -5576,12 +5789,17 @@ async fn late_join_matches_a_running_exporter_without_video() {
let (a, b) = export_twice(false).await;

// The joiner's first frame leads with PAT/PMT, while the running exporter has no
// reason to repeat them there. It can spread over several PCR slices, so compare
// from the next PAT/PMT refresh, which both exporters anchor to the media grid.
// reason to repeat them there, and its SDT waits for a PCR interval after them. They can
// spread over several PCR slices, so compare from the next PAT/PMT refresh past that SDT,
// which both exporters anchor to the media grid.
let sdt = b
.iter()
.find(|frame| count_pid(std::slice::from_ref(*frame), 0x0011) > 0)
.expect("the joiner's SDT")
.timestamp;
let from = b
.iter()
.filter(|frame| count_pid(std::slice::from_ref(*frame), 0) > 0)
.nth(1)
.find(|frame| frame.timestamp > sdt && count_pid(std::slice::from_ref(*frame), 0) > 0)
.expect("a periodic PAT/PMT refresh")
.timestamp;
assert_only_continuity_differs(&a, &b, from);
Expand Down Expand Up @@ -6404,18 +6622,18 @@ async fn si_revision_after_final_media_frame_is_flushed() {
)
.await
.unwrap();
let first = loop {
// v1 goes out with the media frame's slices, once the export's PCR interval is out.
let contains = |haystack: &[u8], needle: &[u8]| haystack.windows(needle.len()).any(|w| w == needle);
loop {
let frame = tokio::time::timeout(DRAIN, exporter.next())
.await
.expect("the only media frame")
.expect("v1 goes out (control)")
.unwrap()
.unwrap();
if !is_pcr_frame(&frame) {
break frame;
.expect("v1 goes out (control)");
if contains(&frame.payload, &sdt_v1) {
break;
}
};
let contains = |haystack: &[u8], needle: &[u8]| haystack.windows(needle.len()).any(|w| w == needle);
assert!(contains(&first.payload, &sdt_v1), "v1 rode the media frame (control)");
}

// The revision lands after the last media frame; only the trailing flush can
// carry it. Closing the catalog lets the exporter reach end of stream.
Expand Down
Loading
Loading