From 01a29cd95e6c08deb79f33bdac5c4ca0efd5efe9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 07:02:56 -0700 Subject: [PATCH 1/3] quest(m1): plan #4456, #4508, #4581, #4582 - close-tail: on lite-07, close() waits for each subscriber to FIN its Subscribe Stream after reading the track's end (#4508). - android-logcat: on dev, Android always logs to logcat and the android-logcat feature is removed (#4456). - ts-damaged-units: one malformed PES or access unit is dropped and counted instead of ending the TS import (#4581). - broadcast-epoch/ts-restart: a signalled backward TS discontinuity continues the same input under a fresh epoch (#4582). Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 3 ++ quest/m1/android-logcat.md | 41 ++++++++++++++++++ quest/m1/broadcast-epoch/README.md | 1 + quest/m1/broadcast-epoch/gateways.md | 4 ++ quest/m1/broadcast-epoch/ts-restart.md | 53 +++++++++++++++++++++++ quest/m1/close-tail.md | 58 +++++++++++++++++++++++++ quest/m1/track-tail-interop.md | 1 + quest/m1/ts-damaged-units.md | 60 ++++++++++++++++++++++++++ 8 files changed, 221 insertions(+) create mode 100644 quest/m1/android-logcat.md create mode 100644 quest/m1/broadcast-epoch/ts-restart.md create mode 100644 quest/m1/close-tail.md create mode 100644 quest/m1/ts-damaged-units.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 9501773df5..1a94d8808c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -23,7 +23,9 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack +- [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 - [Graceful close in bindings](/quest/m1/bindings-graceful-close.md) - on dev, `shutdown` drains a session in moq-ffi and every wrapper like Rust, so the wrappers keep the tail of a publish +- [Android logcat](/quest/m1/android-logcat.md) - on dev, Android builds always log to logcat and the `android-logcat` feature is gone - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [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 - [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 @@ -46,6 +48,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [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 import shared shift](/quest/m1/ts-import-shared-shift.md) - unflagged loop wraps move audio and video by one shift, so A/V sync holds across wraps - [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 - [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 - [Capture control](/quest/m1/capture-control.md) - on dev, `encode::Capture` replaces `CaptureOptions` without a `clock` field (it reads the catalog's), an unsupported `cut()` errors, and dropping the last `Control` cancels in-flight opens diff --git a/quest/m1/android-logcat.md b/quest/m1/android-logcat.md new file mode 100644 index 0000000000..233f09e880 --- /dev/null +++ b/quest/m1/android-logcat.md @@ -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 diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 337297edc3..2ad57a1fdf 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -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, libmoq, 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 diff --git a/quest/m1/broadcast-epoch/gateways.md b/quest/m1/broadcast-epoch/gateways.md index 89a3df5c85..0b0f059aa5 100644 --- a/quest/m1/broadcast-epoch/gateways.md +++ b/quest/m1/broadcast-epoch/gateways.md @@ -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 diff --git a/quest/m1/broadcast-epoch/ts-restart.md b/quest/m1/broadcast-epoch/ts-restart.md new file mode 100644 index 0000000000..c5bc29653b --- /dev/null +++ b/quest/m1/broadcast-epoch/ts-restart.md @@ -0,0 +1,53 @@ +# [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 + 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. +- 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 the SRT page under +`doc/bin/relay/`. + +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 diff --git a/quest/m1/close-tail.md b/quest/m1/close-tail.md new file mode 100644 index 0000000000..9a01ab88a6 --- /dev/null +++ b/quest/m1/close-tail.md @@ -0,0 +1,58 @@ +# [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: `lite::Publisher::drained()` (`rs/moq-net/src/lite/publisher.rs`) +releases a subscription once its group streams and Subscribe Stream FIN are +acked (`poll_close`), and `poll_drain` in `rs/moq-net/src/session.rs` then +closes the session with `Cancel`. The subscriber's `SubscriptionCleanup::abort` +keeps finished groups but not the unread final one. `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 it has read SUBSCRIBE_END and every + counted Group Stream to its FIN or reset, never earlier. The publisher's + 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. + IETF sessions stay with [IETF drain before close](/quest/m2/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. 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`. [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 + +- [Session close](/quest/m1/session-close.md) - extends the same drain phase to withdraw announces +- [Graceful close in bindings](/quest/m1/bindings-graceful-close.md) - `shutdown` inherits this drain diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md index 7e664668ff..e80b0236b1 100644 --- a/quest/m1/track-tail-interop.md +++ b/quest/m1/track-tail-interop.md @@ -44,3 +44,4 @@ delivered and clean. The ordering race itself stays in the unit tests. ## Related - [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 diff --git a/quest/m1/ts-damaged-units.md b/quest/m1/ts-damaged-units.md new file mode 100644 index 0000000000..8de2efbd99 --- /dev/null +++ b/quest/m1/ts-damaged-units.md @@ -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 From 2e3deae40c493933bbac41edaafd9cc440fa5a61 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 07:09:36 -0700 Subject: [PATCH 2/3] quest(m1): address review on close-tail and ts-restart Co-Authored-By: Claude Opus 5.5 --- quest/m1/broadcast-epoch/ts-restart.md | 3 +-- quest/m1/close-tail.md | 28 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/quest/m1/broadcast-epoch/ts-restart.md b/quest/m1/broadcast-epoch/ts-restart.md index c5bc29653b..2e438ff31b 100644 --- a/quest/m1/broadcast-epoch/ts-restart.md +++ b/quest/m1/broadcast-epoch/ts-restart.md @@ -38,8 +38,7 @@ Decided (maintainer, 2026-09-30): 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 the SRT page under -`doc/bin/relay/`. +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. diff --git a/quest/m1/close-tail.md b/quest/m1/close-tail.md index 9a01ab88a6..1104d583c8 100644 --- a/quest/m1/close-tail.md +++ b/quest/m1/close-tail.md @@ -11,18 +11,24 @@ sees `Session(Cancel)`, while the publisher is told the close succeeded. ## Plan -Root cause: `lite::Publisher::drained()` (`rs/moq-net/src/lite/publisher.rs`) -releases a subscription once its group streams and Subscribe Stream FIN are -acked (`poll_close`), and `poll_drain` in `rs/moq-net/src/session.rs` then -closes the session with `Cancel`. The subscriber's `SubscriptionCleanup::abort` -keeps finished groups but not the unread final one. `tests/session_close.rs` -passes only because the mock transport keeps unread data after close. +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 it has read SUBSCRIBE_END and every - counted Group Stream to its FIN or reset, never earlier. The publisher's + 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 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 @@ -30,6 +36,8 @@ Decided (maintainer, 2026-09-30): - 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/m2/ietf-drain-before-close.md). - Both subscribers change: moq-net and `@moq/net`. @@ -42,7 +50,8 @@ subscription, not a cancel of one still in flight 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`. [Track tail interop](/quest/m1/track-tail-interop.md) is the +`Error::Timeout`; a final range with a skipped and a reset group still settles +and FINs. [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 @@ -56,3 +65,4 @@ after reading the track's end, and a publisher closing gracefully waits for it. - [Session close](/quest/m1/session-close.md) - extends the same drain phase to withdraw announces - [Graceful close in bindings](/quest/m1/bindings-graceful-close.md) - `shutdown` inherits this drain +- [SUBSCRIBE_DROP](/quest/m1/subscribe-drop.md) - replaces the lite-07 tail accounting the FIN rule waits on From 12c34eef0c19877b9d5cd57731ebdce264f85648 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 08:17:08 -0700 Subject: [PATCH 3/3] quest(m1): close-tail updates the concept doc and runs interop --all Co-Authored-By: Claude Opus 5.5 --- quest/m1/close-tail.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/quest/m1/close-tail.md b/quest/m1/close-tail.md index 2cc603d327..1c1e75dfb9 100644 --- a/quest/m1/close-tail.md +++ b/quest/m1/close-tail.md @@ -41,8 +41,9 @@ Decided (maintainer, 2026-09-30): 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. Check that a lite-07 publisher +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)). @@ -51,8 +52,9 @@ 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. [Track tail interop](/quest/m1/track-tail-interop.md) is the -cross-language proof over a real relay. +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.