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
3 changes: 3 additions & 0 deletions quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ QUIC studies there on that rule.
- [Merge queue](/quest/m1/merge-queue.md) - the required checks run on `merge_group`, so a stale green check can no longer break main
- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi and every wrapper configure and observe audio playout delay
- [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, flate, audio, and video namespaces built from the handle below
- [Android logcat](/quest/m1/android-logcat.md) - on dev, Android builds always log to logcat and the `android-logcat` feature is gone
- [Raw stream codes](/quest/m1/raw-stream-codes.md) - raw QUIC stream resets and stops carry the application's code, not an HTTP/3-mapped one
- [IETF drain before close](/quest/m1/ietf-drain-before-close.md) - moq-transport sessions deliver finished tracks before a graceful close, as moq-lite does
- [Close waits for the tail](/quest/m1/close-tail.md) - on lite-07, `close()` returns `Ok` only after each subscriber FINs its Subscribe Stream, having read the track to its end
- [Demo serve-hls renditions](/quest/m1/serve-hls-renditions.md) - `just pub serve-hls` serves 720p and 144p instead of two 256-wide copies
- [moq play drain tail](/quest/m1/play-drain-tail.md) - retired renditions and finite tracks play their last 10 ms of audio
- [WebTransport close upstream](/quest/m1/wt-close-upstream.md) - web-transport-moq delivers the close capsule itself, and moq-tokio's `CLOSE_LINGER` is deleted
Expand All @@ -47,6 +49,7 @@ QUIC studies there on that rule.
- [Legacy end overshoot](/quest/m1/legacy-end-overshoot.md) - browser playback survives a group that starts inside the previous group's estimated end
- [Wire compatibility](/quest/m1/wire-compat.md) - a nightly run tests this checkout against the last published release for tokens, session wire, and catalog/container
- [TS PSI reassembly](/quest/m1/ts-psi-reassembly.md) - `import ts` reads a PAT or PMT that spans packets or follows a nonzero pointer_field instead of aborting, and one corrupted section costs a repetition and a counted `CRC_error`, not the import
- [TS damaged units](/quest/m1/ts-damaged-units.md) - one malformed PES or access unit is dropped, counted as `damaged`, and resynced at the next keyframe instead of ending the import
- [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies
- [TS stats module](/quest/m1/ts-stats-module.md) - on dev, the TS stats types move under `ts::stats` as `Snapshot` and `Stream`, with an owned `track`
- [Same-hop importers](/quest/m1/hop-aligned-import.md) - importers sharing a `--hop` and fed one stream publish identical groups and timestamps, so failover survives
Expand Down
41 changes: 41 additions & 0 deletions quest/m1/android-logcat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# [XS] Android logs always go to logcat

## Goal

On dev, `moq_tokio::Log::init` sends logs to logcat on every Android build, so
the published Kotlin and Dart bindings (and libmoq) stop losing every Rust log
to stderr, which Android app processes discard. The `android-logcat` Cargo
feature is gone from `moq-tokio` and `moq-ffi`.

## Plan

Today the logcat layer in `rs/moq-tokio/src/log.rs` is gated on
`all(target_os = "android", feature = "android-logcat")`, and no shipped build
enables the feature: libmoq has no `[features]`, and `rs/moq-ffi/build.sh`, the
Dart build hook, and the release workflows build Android without it. The
published `dev.moq:moq-ffi-android` 0.4.8 does not link `liblog`.

Decided (maintainer, 2026-09-30): gate the layer on `target_os = "android"`
alone and delete the feature, rather than wire it through every build, so no
Android consumer has to know to turn it on. Logging stays opt-in at runtime,
since `Log::init` runs only when the app calls it. Removing a feature from
published crates is a break, so this targets dev; no no-op feature is kept.

- `tracing-android` becomes a plain dependency under the existing
`cfg(target_os = "android")` target table in `rs/moq-tokio/Cargo.toml`; it
must stay target-gated because `android_log-sys` links `liblog`
unconditionally.
- Fix the `Log::init` doc comment, which already claims logcat.
- Leave the `rs/moq-native` tombstone alone.
- Regression check: the Android CI workflow builds moq-ffi for one Android
target and asserts the `.so` imports `__android_log_*`.

The reporter ([#4456](https://github.com/moq-dev/moq/issues/4456)) has this
working locally and offered the PR.

Public API: breaking on dev, the `android-logcat` feature is removed from
`moq-tokio` and `moq-ffi`. Wire: none.

## Closes

- [#4456](https://github.com/moq-dev/moq/issues/4456) - Rust logs never reach logcat on Android
1 change: 1 addition & 0 deletions quest/m1/broadcast-epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ This README owns:
- [Origin](/quest/m1/broadcast-epoch/origin.md) - moq-net publish mints an epoch, consumers follow the newest live one, and bare requests resolve to it on every version
- [Apps](/quest/m1/broadcast-epoch/apps.md) - moq-cli, the browser publish and watch components, and demo/web publish under epochs and play bare names
- [Gateways](/quest/m1/broadcast-epoch/gateways.md) - RTMP, SRT, and WHIP ingest mint an epoch per incoming connection, so an encoder reconnect is a clean takeover
- [TS restart](/quest/m1/broadcast-epoch/ts-restart.md) - on dev, a signalled backward TS discontinuity finishes the broadcast and continues the same input under a fresh epoch
- [Bindings](/quest/m1/broadcast-epoch/bindings.md) - moq-ffi and every wrapper expose the epoch and inherit the default
- [GStreamer and OBS](/quest/m1/broadcast-epoch/gst-obs.md) - moqsink and the OBS plugin publish each run under a fresh epoch
4 changes: 4 additions & 0 deletions quest/m1/broadcast-epoch/gateways.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ describes ingest paths.
## Required

- [Origin](/quest/m1/broadcast-epoch/origin.md) - the publish default

## Related

- [TS restart](/quest/m1/broadcast-epoch/ts-restart.md) - a signalled restart inside one connection, which this quest's per-connection epoch does not cover
52 changes: 52 additions & 0 deletions quest/m1/broadcast-epoch/ts-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# [M] A signalled TS restart continues as a new epoch

## Goal

On dev, when a TS feed rewinds its time base and signals it
(`discontinuity_indicator` on the PCR PID, ISO/IEC 13818-1 2.4.3.5),
`moq import ts` and `moq-srt` finish the current broadcast cleanly and publish
the rest of the same input as a new broadcast under a fresh epoch, in the same
process and connection. This covers an encoder restart or source switch behind
a gateway that keeps its connection up, and a looping playout server, none of
which makes a new connection for [Gateways](/quest/m1/broadcast-epoch/gateways.md)
to turn into an epoch. An unsignalled rewind stays fatal, as #4543 decided.

## Plan

Since #4543 (on dev), every rewind ends the import with `TimestampRewind` from
`Producer::write`; the importer already reads the flag in `timebase_break`,
and a flagged forward jump publishes break markers and carries on.

Decided (maintainer, 2026-09-30):

- A rewind is new content, so it is always a new broadcast at a new epoch
path, never a continuation of the old name. Viewers of the bare name follow
it through [Origin](/quest/m1/broadcast-epoch/origin.md).
- `decode` stops at the flagged rewind and reports it. The caller finishes the
old broadcast (a clean end, not an abort, so its viewers read to its end),
publishes a new broadcast at a fresh epoch path, and calls
`import.restart(broadcast)`. The importer carries over only the PAT/PMT

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Give restart the new catalog reservation

ts::Import is constructed with both a broadcast and catalog::Reserved, then retains the reservation's catalog producer and binds SI capture to the original broadcast. On every restart, passing only the new broadcast leaves catalog tracks and future catalog edits attached to the old epoch, so the fresh epoch cannot publish the promised fresh catalog and tracks. Specify that restart also receives a newly constructed catalog reservation, or define an explicit catalog-rebinding API and update each caller accordingly. (Written by GPT-5.6 Sol)

AGENTS.md reference: AGENTS.md:L56-L60

Useful? React with 👍 / 👎.

layout and the bytes it has not consumed, so there is no wait for the next
PSI repetition; tracks, groups, and timestamps start fresh. `ts::Programs`
does this per program. Returning unconsumed bytes to a fresh `Import` was
rejected for the PSI wait, and letting the importer publish its own
broadcast was rejected for coupling moq-mux to origin publishing.
- A flag with the next PCR behind the last one is a restart; a flag with a
forward jump stays a break marker. The 33-bit wrap is modular, not a rewind.
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Buffer post-discontinuity packets until PCR classifies the jump

When the discontinuity packet does not itself carry a PCR, the direction is unknown until a later PCR, and valid media packets may occur in between. The current importer explicitly supports a dedicated PCR PID and adaptation-only clock packets (rs/moq-mux/src/container/ts/import.rs:300-318), so an implementation that keeps routing while awaiting the “next PCR” can publish new-epoch media into the old broadcast before reporting the restart. Specify that decoding buffers all packets from the flag through the classifying PCR, and add a fixture with media between them, so those bytes are replayed only into the selected old or fresh broadcast.

Useful? React with 👍 / 👎.

- Callers: `moq import ts` (`rs/moq-cli`) and `moq-srt`'s `Publisher`, single
program and `Program::All`.

Tests: a fixture with a flagged rewind publishes two broadcasts, the second
starting at the rewound PTS; the same rewind unflagged still errors; one SRT
connection carries both epochs. Update `doc/bin/cli.md` and `doc/bin/srt.md`.

Public API: breaking in moq-mux on dev, `ts::Import::decode` reports a restart
and `restart` is new. Wire: none.

## Required

- [Origin](/quest/m1/broadcast-epoch/origin.md) - the fresh epoch path the rest of the feed publishes under

## Closes

- [#4582](https://github.com/moq-dev/moq/issues/4582) - a signalled backward TS discontinuity ends the import
69 changes: 69 additions & 0 deletions quest/m1/close-tail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# [M] A graceful close waits for the subscriber to read the tail

## Goal

When `Session::close()` returns `Ok` on moq-lite-07, every subscriber has read
each finished track to its end: every frame of the final group, then a clean
end. Today close counts a group delivered once the peer's QUIC stack acks it,
then sends `CONNECTION_CLOSE`, and a real QUIC stack discards stream data the
subscriber's moq-net has not read yet. The subscriber loses the final group and
sees `Session(Cancel)`, while the publisher is told the close succeeded.

## Plan

Root cause: a lite serve decrements the publisher's `owed` count once its
group streams and Subscribe Stream FIN are acked (`poll_close` in
`rs/moq-net/src/lite/publisher.rs`), and `Publisher::drained()` reads only that
count. `poll_drain` in `rs/moq-net/src/session.rs` then closes the session with
`Cancel`. The lost final group is unread data the subscriber's QUIC stack
discards on `CONNECTION_CLOSE`; the subscriber's abort then ends the track with
that group still open. `tests/session_close.rs` passes only because the mock
transport keeps unread data after close.

Decided (maintainer, 2026-09-30):

- The fix is a FIN handshake, per subscription. On lite-07 the subscriber FINs
its side of the Subscribe Stream once its tail accounting settles the track's
end, never earlier: every group below the end has been read to its FIN,
reset, or dropped. Today that accounting is SUBSCRIBE_END's `Stream Count`;
once [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) lands it is a received
group or a SUBSCRIBE_DROP per sequence. The FIN rule rides on whichever is in
place, so neither quest waits on the other. The publisher's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for reset streams before waiting for FIN

When an ordinary QUIC reset loses its group header, the subscriber cannot satisfy the Stream Count until the tail grace expires. That grace is one second in rs/moq-net/src/tail.rs, exactly the same as CLOSE_TIMEOUT, and the publisher starts its deadline before the subscriber even receives SUBSCRIBE_END, so close can time out before the subscriber sends this FIN. Make this quest depend on SUBSCRIBE_DROP or reliable reset, rather than claiming the two quests are independent and expecting reset groups to finish successfully. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

drain waits until each served Subscribe Stream is closed both ways (the
subscriber's FIN or a reset), under the same `CLOSE_TIMEOUT`, before
`CONNECTION_CLOSE`. A session-level GOAWAY handshake was rejected: the peer
would need the same per-subscription knowledge to know when to close.
- lite-07 only, since it is still wip and the draft can require the
subscriber FIN there. On lite-05 and lite-06 an old subscriber never FINs,
so close keeps today's ack-based drain rather than time out every close.
#4508's repro is on lite-05, and that path stays as it is: this quest closes
the issue by fixing the close on the version that can carry the FIN rule.
IETF sessions stay with [IETF drain before close](/quest/m1/ietf-drain-before-close.md).
- Both subscribers change: moq-net and `@moq/net`.

Update `drafts/draft-lcurley-moq-lite.md` (the Subscribe section gains the
subscriber FIN rule, with a changelog entry) and `doc/concept/moq-lite.md`
(a subscription ends with both sides' FIN, and a graceful close waits for it). Check that a lite-07 publisher
already treats a subscriber FIN after SUBSCRIBE_END as the end of a finished
subscription, not a cancel of one still in flight
([Request stream cancel](/quest/m1/request-stream-serve.md)).

Tests: the reporter's `close_tail` case (one session, paused clock, a mock
switch that acks a FIN as soon as it is sent, as a real transport does) fails
today and passes with the fix; a subscriber that never FINs makes close return
`Error::Timeout`; a final range with a skipped and a reset group still settles
and FINs. Run `just test interop --all`, since both the Rust and JS subscribers change;
[Track tail interop](/quest/m1/track-tail-interop.md) is the cross-language
proof over a real relay.

Public API: none. Wire: on lite-07 a subscriber FINs its Subscribe Stream
after reading the track's end, and a publisher closing gracefully waits for it.

## Closes

- [#4508](https://github.com/moq-dev/moq/issues/4508) - `close()` returns `Ok` while the subscriber gets a cut track

## Related

- [IETF drain before close](/quest/m1/ietf-drain-before-close.md) - the same drain for moq-transport sessions
- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - replaces the lite-07 tail accounting the FIN rule waits on
1 change: 1 addition & 0 deletions quest/m1/track-tail-interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ delivered and clean. The ordering race itself stays in the unit tests.

- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - owns the lite-07 drop case on top of this harness
- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - keeps a reset stream's header, so the reset acts as a one-group drop
- [Close waits for the tail](/quest/m1/close-tail.md) - the publisher-side fix this test proves across a real relay

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make track-tail interop require the close-tail fix

This newly added link classifies close-tail as merely related even though this quest's finite publisher closes its session and the close-tail quest explicitly calls this test its cross-language proof. With the current graph, track-tail-interop requires only SUBSCRIBE_DROP and is ordered before close-tail in quest/m1/README.md, so it can be dispatched while the acknowledged-but-unread tail bug still exists, forcing either a failing/flaky interop test or duplicate implementation work. Move this link under ## Required and order the interop quest after close-tail.

Useful? React with 👍 / 👎.

60 changes: 60 additions & 0 deletions quest/m1/ts-damaged-units.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# [M] TS import drops a damaged unit instead of ending the ingest

## Goal

`moq import ts`, and `moq-srt` through the same importer, survive a packet,
PES, or access unit they cannot parse: a malformed PES header or media-PID
adaptation field with `transport_error_indicator` clear, or a codec error such
as an H.264 NAL with `forbidden_zero_bit` set. The damaged unit is dropped and
counted, its track waits for its next keyframe, and the ingest carries on.
Today the first such error ends the import, and the gateway hangs up.

## Plan

Today `decode` in `rs/moq-mux/src/container/ts/import.rs` runs
`while let Some(packet) = self.reader.read_ts_packet()? { self.handle_packet(packet)?; }`,
so any PES-header error from the reader and any codec error from `flush` ends
the import. The `transport_error_indicator` branch already drops a flagged
packet (clears `pending[pid]` and calls `stream.desync()`), and it is the model.

Decided (maintainer, 2026-09-30):

- Fail-loud holds at unit granularity, as [TS PSI reassembly](/quest/m1/ts-psi-reassembly.md)
holds it at section granularity: a damaged unit is refused whole, never half
published, and counted, and nothing else about the feed changes. PSI damage
stays that quest's.
- Drop the unit the way the TEI branch does: clear the PID's pending PES and
desync its stream, so a codec that needs one waits for the next keyframe.
Make sure the scratch buffer still advances past the packet on this path
(today an error skips `self.scratch.drain(..off)`).
- Count each drop in a new cumulative per-PID `damaged` counter on the stream's
stats row, beside `resyncs` and `discarded`, reported by `ts::stats::Log`.
It names what happened; no TR 101 290 check matches a codec error, and
[TS import health](/quest/m2/ts-import-health.md) keeps the ETSI names.
The rows are `#[non_exhaustive]`, so the field is additive; follow
[TS stats module](/quest/m1/ts-stats-module.md)'s names if it has landed.
- Errors that are not confined to one unit (the producer refusing a rewind,
origin or catalog failures) stay fatal.
- Built on the demux TS PSI reassembly owns, since that quest moves PES header
parsing into moq-mux.

Tests, from [#4581](https://github.com/moq-dev/moq/issues/4581)'s stimuli on a
fixture: a video PES header with its flag and timestamp bytes zeroed (TEI
clear), and one H.264 NAL with `forbidden_zero_bit` set. Each import carries
on, counts exactly one `damaged` on that PID and none elsewhere, and publishes
again from the next keyframe; a clean fixture counts zero. Document the counter
wherever the TS stats fields are described.

Public API: additive, one stats field. Wire: none.

## Required

- [TS PSI reassembly](/quest/m1/ts-psi-reassembly.md) - moq-mux owns the demux and PES header parsing this drops through

## Closes

- [#4581](https://github.com/moq-dev/moq/issues/4581) - one malformed packet ends the TS import

## Related

- [TS import health](/quest/m2/ts-import-health.md) - the TR 101 290 counters for the same feed