Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6d079c4
quest: claim quest/m0/broadcast-epoch/export-ts
kixelated Oct 9, 2026
7e778da
feat(moq-mux)!: ts::Export::follow replaces resume
kixelated Oct 9, 2026
4ca6d40
feat(moq-cli): export ts follows announcements, --stitch switches pro…
kixelated Oct 9, 2026
7760303
feat(moq-srt): egress follows announcements with linger and stitch
kixelated Oct 9, 2026
f305644
docs: export ts and SRT egress linger within an epoch, --stitch switc…
kixelated Oct 9, 2026
a8787ad
Merge remote-tracking branch 'origin/main' into quest/m0/broadcast-ep…
kixelated Oct 9, 2026
60aae4f
chore: lint and format
kixelated Oct 9, 2026
e802cb5
Merge remote-tracking branch 'origin/main' into iterate/export-ts
kixelated Oct 10, 2026
ffc1c80
Merge remote-tracking branch 'origin/main' into quest/m0/broadcast-ep…
kixelated Oct 10, 2026
7f566d6
test(moq-cli): read the export's stderr to its end before asserting o…
kixelated Oct 10, 2026
383f699
refactor(moq-mux): ts::Follower follows a path for export ts and SRT …
kixelated Oct 10, 2026
e69396c
Merge remote-tracking branch 'origin/quest/m0/broadcast-epoch/export-…
kixelated Oct 10, 2026
0a58099
refactor(mux): poll-based ts::Follower over the async one
kixelated Oct 10, 2026
51c53fa
Merge remote-tracking branch 'origin/main' into iterate/export-ts
kixelated Oct 10, 2026
62b3a66
test(mux): a same-epoch handoff after a gap is no replacement for the…
kixelated Oct 10, 2026
2c54040
fix(mux): the Follower follows a same-epoch handoff that ended its re…
kixelated Oct 10, 2026
5a5ea60
Merge remote-tracking branch 'origin/main' into iterate/export-ts
kixelated Oct 10, 2026
674780e
refactor(mux): drop the Follower's same-epoch Update handling now tha…
kixelated Oct 10, 2026
9481286
fix(mux,srt): the linger bounds a return until its catalog answers; S…
kixelated Oct 10, 2026
b2062f9
fix(mux): the Follower keeps lingering when a return goes before it r…
kixelated Oct 10, 2026
38f852c
fix(mux): a stitch whose replacement goes before it resolves keeps th…
kixelated Oct 10, 2026
e18326d
fix(mux): the Follower fails loud on a refusal, and follows a return …
kixelated Oct 10, 2026
66a291a
fix(mux): own instance winning back is no replacement; a pending swit…
kixelated Oct 10, 2026
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
13 changes: 9 additions & 4 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,10 +313,15 @@ keyframe. The output's PCR follows the source's clock within what ISO/IEC
13818-1 allows. `--delay 0` writes frames in arrival order and drops nothing.

A stdout export ends with the broadcast. `export ts --linger 10s` waits that
long for a restarted publisher instead, and marks the break in the stream
(PCR discontinuity, PAT/PMT re-sent). An export that fails while the broadcast
stays up, such as on a codec TS cannot carry, exits 1 without lingering. Only
`ts` can mark a restart, so the other formats refuse `--linger`.
long for the same publisher instance to come back, and carries on with the same
stream. A replacement (a restarted publisher, under a new
[epoch](/concept/moq-lite#publisher-epochs)) exits 1 unless `--stitch` follows
it as a full program switch: a new PMT from its catalog, with every PID flagging
the break. Without `--stitch` the export stays on the old publisher while it is
up. Only lite-07 sessions carry epochs, so on the default version every return
is a replacement. An export that fails while the broadcast stays up, such as on
a codec TS cannot carry, exits 1 without lingering. Only `ts` can mark a break,
so the other formats refuse `--linger`.

## Debugging

Expand Down
5 changes: 4 additions & 1 deletion doc/bin/srt.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ moq --connect https://relay.example.com/anon --broadcast event.hang import srt -
Each connection publishes under a fresh
[epoch](/concept/moq-lite#publisher-epochs), so an encoder that reconnects
while its stale connection is still open replaces it at once: viewers see the
broadcast restart, and their next subscribe reaches the new feed.
broadcast restart, and their next subscribe reaches the new feed. An
`export srt` stream ends with its broadcast; `--linger` and `--stitch` follow a
return or a replacement on the same connection, as they do for
[`export ts`](/bin/cli#retention-and-latency).

A multi-program feed is refused unless `--program`
picks one: `--program 2` imports program 2 alone, and `--program all`
Expand Down
6 changes: 6 additions & 0 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ These land with the next breaking release, not the 2026-09-23 train.
moq-lite 06 and older and moq-transport. moq-lite 07 adds `ANNOUNCE_RESTART`
(0x3), and lite-06's `ANNOUNCE_RESTART` is named `ANNOUNCE_UPDATE`, as it
always meant.
- **`moq export ts --linger` waits only for the same publisher instance.** A
restarted publisher (a new epoch, or any return on a version without one)
exits 1 unless `--stitch` follows it as a program switch. `export srt` takes
the same two flags. In Rust, moq-mux's `ts::Export::resume` is
`ts::Export::follow`, `Source::returned` is gone, and `ts::Follower` follows a
path's announcements the way both exports do.
- **fMP4 export of Annex-B H.264 and H.265 inits from the catalog.** When the
catalog codec string and dimensions are enough, `moq export fmp4` writes an
`avc3` or `hev1` init segment before the first keyframe and leaves SPS, PPS,
Expand Down
1 change: 0 additions & 1 deletion quest/m0/broadcast-epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ maintainer's pre-release merge of `release` into `main` keeps `main`'s

- [JS consume identity](/quest/m0/broadcast-epoch/js-consume-identity.md) - requests carry the serving prefix's epoch and new winners cannot reuse an old cached broadcast
- [JS restart keeps the request](/quest/m0/broadcast-epoch/js-restart-keeps-request.md) - a resolved request remains on its old instance, matching Rust's sticky subscriptions
- [Export ts](/quest/m0/broadcast-epoch/export-ts.md) - `export ts` and SRT egress linger only for the same epoch, and switch to a replacement only with `--stitch`, as a full program switch
- [Publish catalog restart](/quest/m0/broadcast-epoch/publish-catalog-restart.md) - `@moq/publish` never reuses catalog group numbers under one name and epoch after a re-announce
- [Bindings](/quest/m0/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and let a publisher announce one
- [moqsrc](/quest/m0/broadcast-epoch/moqsrc.md) - moqsrc switches to the new broadcast on a `Restart`, keeping its pads by rendition name
Expand Down
105 changes: 0 additions & 105 deletions quest/m0/broadcast-epoch/export-ts.md

This file was deleted.

2 changes: 1 addition & 1 deletion quest/m2/ts-hitless.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ skips and source drift, continuity counters included, within one epoch. A ST 202
can then switch between them hitlessly.

Non-goal: identity across a replaced broadcast. A leg ends on a replacement, or
starts a fresh stream with `--stitch` ([Export ts](/quest/m0/broadcast-epoch/export-ts.md)),
starts a fresh stream with `--stitch`,
so legs may differ after one (#5101, maintainer 2026-10-09: byte-identical
output across restarts is not supported).

Expand Down
43 changes: 40 additions & 3 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,7 @@ pub enum ExportSink {
/// RTMP: push to a remote (`--connect`) or serve plays (`--listen`).
Rtmp(crate::rtmp::ExportArgs),
/// SRT: push to a remote (`--connect`) or serve requests (`--listen`).
Srt(crate::srt::Args),
Srt(crate::srt::ExportArgs),
/// WebRTC: WHIP client pushing to a remote (`--connect`) or WHEP server serving plays (`--listen`).
Rtc(crate::rtc::Args),
/// Record the broadcast into an object store until it ends.
Expand Down Expand Up @@ -970,6 +970,7 @@ impl ExportSink {
format,
max_delay: container.max_delay.into_std(),
linger: container.linger.into_std(),
stitch: false,
fragment_duration: None,
mux_rate: None,
};
Expand All @@ -986,6 +987,7 @@ impl ExportSink {
format: SubscribeFormat::Ts,
max_delay: args.delay.into_std(),
linger: args.linger.into_std(),
stitch: args.stitch,
fragment_duration: None,
mux_rate: args.mux_rate,
},
Expand All @@ -1003,6 +1005,8 @@ pub struct Stdout {
/// The staleness budget, which `ts` also holds every frame for (`--delay`).
pub max_delay: Duration,
pub linger: Duration,
/// Follow a replacement as a program switch (`ts` only).
pub stitch: bool,
pub fragment_duration: Option<Duration>,
pub mux_rate: Option<u64>,
}
Expand Down Expand Up @@ -1052,12 +1056,17 @@ pub struct Transport {
#[usage(long, default = "500ms")]
pub delay: crate::duration::Duration,

/// How long to wait for the broadcast to come back once it ends (e.g. `10s`).
/// The output stops while it is gone and resumes flagged as a break.
/// How long to wait for the same publisher instance to come back once it ends (e.g. `10s`),
/// then carry on with the same stream. A replacement exits 1 unless `--stitch` is passed.
/// An export that fails while the broadcast is still up exits without waiting.
#[usage(long, default = "0s")]
pub linger: crate::duration::Duration,

/// Follow another publisher instance that replaces the broadcast, as a full program switch:
/// a new PMT from its catalog, flagged as a break on every PID.
#[usage(long)]
pub stitch: bool,

/// Pad the output with null packets to this constant rate, in bits per second.
/// Defaults to the multiplex rate the catalog recorded from a constant-rate
/// source (`mpegts.muxRate`); without either the output is unpadded.
Expand Down Expand Up @@ -1328,6 +1337,34 @@ mod tests {
);
}

/// `export srt` follows its broadcast with the same `--linger` and `--stitch` as `export ts`.
#[test]
fn export_srt_takes_linger_and_stitch() {
let cli = Invocation::try_parse_from([
"moq",
"export",
"srt",
"--listen",
"[::]:9000",
"--linger",
"10s",
"--stitch",
])
.unwrap();
let Command::Export(export) = &cli.stages[0] else {
panic!("an export stage");
};
let ExportSink::Srt(args) = &export.sink else {
panic!("an export srt stage");
};
assert_eq!(args.linger.into_std(), Duration::from_secs(10));
assert!(args.stitch);
assert!(
Invocation::try_parse_from(["moq", "import", "srt", "--listen", "[::]:9000", "--stitch"]).is_err(),
"an ingest has nothing to follow"
);
}

/// A released spelling is refused, and the error names what to write instead.
///
/// The alternative is what this replaced: the flag parsed onto a hidden field,
Expand Down
12 changes: 6 additions & 6 deletions rs/moq-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ fn spawn_export(
format: stdout.format,
max_delay: stdout.max_delay,
linger: stdout.linger,
stitch: stdout.stitch,
fragment_duration: stdout.fragment_duration,
mux_rate: stdout.mux_rate,
catalog: export.catalog_format,
Expand All @@ -765,11 +766,11 @@ fn spawn_export(
}
}
ExportSink::Srt(srt) => {
if let Some(addr) = srt.listen {
if let Some(addr) = srt.endpoint.listen {
let name = require_broadcast(name, "export srt --listen")?;
tasks.spawn(srt::listen_export(origin.consume(), addr, name, srt.latency.into_std()));
} else if let Some(url) = srt.connect {
tasks.spawn(srt::connect_export(origin.consume(), url, name, srt.latency.into_std()));
tasks.spawn(srt::listen_export(origin.consume(), addr, name, srt));
} else if let Some(url) = srt.endpoint.connect.clone() {
tasks.spawn(srt::connect_export(origin.consume(), url, name, srt));
}
}
ExportSink::Rtc(rtc) => {
Expand Down Expand Up @@ -816,8 +817,7 @@ async fn run_stdout(consumer: moq_net::origin::Consumer, name: String, args: Sub
.await
.ok_or_else(|| anyhow::anyhow!("origin closed before broadcast `{name}` was announced"))?;

let source = moq_mux::Source::new(consumer, &name);
Subscribe::new(source, catalog, args).run().await
Subscribe::new(consumer, &name, catalog, args).run().await
}

/// Run every endpoint until the first finishes (stdin EOF, SIGINT, SIGTERM, or an
Expand Down
32 changes: 28 additions & 4 deletions rs/moq-cli/src/srt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ pub struct ImportArgs {
pub program: Option<TsProgram>,
}

/// SRT export args: the endpoint, plus how a broadcast ending or being replaced is followed.
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct ExportArgs {
#[usage(flatten)]
pub endpoint: Args,

/// How long to wait for the same publisher instance to come back once it ends (e.g. `10s`),
/// then carry on with the same stream. A replacement ends the stream unless `--stitch`
/// is passed.
#[usage(long, default = "0s")]
pub linger: crate::duration::Duration,

/// Follow another publisher instance that replaces the broadcast, as a full program switch
/// on the same SRT connection.
#[usage(long)]
pub stitch: bool,
}

impl ImportArgs {
/// The library's selection for `--program`.
pub fn program(&self) -> Option<moq_srt::Program> {
Expand Down Expand Up @@ -121,9 +140,10 @@ pub async fn listen_export(
origin: moq_net::origin::Consumer,
addr: SocketAddr,
name: String,
latency: Duration,
args: ExportArgs,
) -> anyhow::Result<()> {
let mut server = Server::bind(addr, latency).await?;
let mut server = Server::bind(addr, args.endpoint.latency.into_std()).await?;
let (linger, stitch) = (args.linger.into_std(), args.stitch);
tracing::info!(%addr, %name, "SRT listening (export)");
notify_ready();

Expand All @@ -133,6 +153,7 @@ pub async fn listen_export(
let origin = origin.clone();
let name = name.clone();
tokio::spawn(async move {
let subscribe = subscribe.with_linger(linger).with_stitch(stitch);
if let Err(err) = subscribe.accept(&origin, &name).await {
tracing::warn!(%name, %err, "SRT request ended with error");
}
Expand Down Expand Up @@ -175,13 +196,16 @@ pub async fn connect_export(
origin: moq_net::origin::Consumer,
url: Url,
name: String,
latency: Duration,
args: ExportArgs,
) -> anyhow::Result<()> {
let (addr, resource) = parse_url(&url).await?;
tracing::info!(url = %RedactedUrl::new(&url), %name, "SRT client pushing");
notify_ready();

let client = moq_srt::Client::new(addr, resource).with_latency(latency);
let client = moq_srt::Client::new(addr, resource)
.with_latency(args.endpoint.latency.into_std())
.with_linger(args.linger.into_std())
.with_stitch(args.stitch);
Ok(client.publish(&origin, &name).await?)
}

Expand Down
Loading
Loading