diff --git a/js/net/src/lite/connection.test.ts b/js/net/src/lite/connection.test.ts index 3d6027d3a8f..211bde08a52 100644 --- a/js/net/src/lite/connection.test.ts +++ b/js/net/src/lite/connection.test.ts @@ -1,5 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; +import Qmux from "@moq/qmux"; import { accept } from "../connection/accept.ts"; import { connect } from "../connection/connect.ts"; import { SessionCode, SessionError } from "../error.ts"; @@ -23,7 +24,17 @@ import { SubscribeStart, } from "./subscribe.ts"; import { TrackInfo, Track as TrackMessage } from "./track.ts"; -import { ALPN_04, ALPN_05, ALPN_06, ALPN_07_WIP, Version } from "./version.ts"; +import { + ALPN, + ALPN_03, + ALPN_04, + ALPN_05, + ALPN_06, + ALPN_07_WIP, + hasTrackStream, + Version, + versionName, +} from "./version.ts"; /** A transport whose `getStats` behaves as described, or is absent entirely. */ function transport(getStats?: () => Promise): WebTransport { @@ -203,6 +214,70 @@ function holdFins(transport: MockTransport, streams: "uni" | "incoming-bidi") { }; } +// Hand `transport` each incoming unidirectional stream only once its FIN arrived, buffered outside +// the transport so a session close cannot discard it. QMux keeps a finished stream's unread bytes +// across a close this way, so the reads still owed land after the close itself. +function deliverWhole(transport: MockTransport) { + Object.defineProperty(transport, "incomingUnidirectionalStreams", { + value: transport.incomingUnidirectionalStreams.pipeThrough( + new TransformStream, ReadableStream>({ + transform(stream, controller) { + controller.enqueue( + new ReadableStream({ + async start(whole) { + const chunks = await Array.fromAsync(stream); + for (const chunk of chunks) whole.enqueue(chunk); + whole.close(); + }, + }), + ); + }, + }), + ), + }); +} + +// Deliver the bytes of every bidirectional stream `transport` opens at once, but each FIN only a task +// after the session closed, so every reaction to the close runs before a SUBSCRIBE FIN sent first. +function finAfterClose(transport: MockTransport) { + const create = transport.createBidirectionalStream.bind(transport); + transport.createBidirectionalStream = async (options) => { + const stream = await create(options); + const readable = stream.readable.pipeThrough( + new TransformStream({ + async flush() { + await transport.closed; + await new Promise((resolve) => setTimeout(resolve, 0)); + }, + }), + ); + return { readable, writable: stream.writable }; + }; +} + +// A qmux session pair over an in-memory socket. Unlike the mock, its receive streams are +// pull-driven, so a stream's FIN surfaces only to a read. +function qmuxPair(): { client: WebTransport; server: WebTransport } { + const socket = (readable: ReadableStream, writable: WritableStream) => { + const closed = Promise.withResolvers<{ closeCode: number; reason: string }>(); + return { + url: "", + opened: Promise.resolve({ readable, writable, extensions: "", protocol: "qmux-01" }), + closed: closed.promise, + close: () => closed.resolve({ closeCode: 1000, reason: "" }), + }; + }; + // Copied, since a sender may reuse its buffer once a write resolves. + const pipe = () => + new TransformStream({ transform: (chunk, c) => c.enqueue(chunk.slice()) }); + const up = pipe(); + const down = pipe(); + const config = { idleTimeout: null }; + const client = new Qmux(socket(down.readable, up.writable), { config }); + const server = Qmux.accept(socket(up.readable, down.writable), { config }); + return { client, server }; +} + // Holds the one-second close deadline on a manual clock, so a test fires it by hand instead of waiting. function mockDeadline() { const clock = new Manual(); @@ -217,9 +292,17 @@ async function settle() { for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); } -for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { +for (const [alpn, version] of [ + [ALPN, Version.DRAFT_01], + [ALPN, Version.DRAFT_02], + [ALPN_03, Version.DRAFT_03], + [ALPN_04, Version.DRAFT_04], + [ALPN_05, Version.DRAFT_05], + [ALPN_06, Version.DRAFT_06], + [ALPN_07_WIP, Version.DRAFT_07], +] as const) { // A publisher that finishes a track and closes must not cut the final group short. - test(`close delivers a finished track's final group over ${alpn}`, async () => { + test(`close delivers a finished track's final group over ${versionName(version)}`, async () => { const pair = createMockTransportPair(alpn); const fin = holdFins(pair.server, "uni"); const origin = new Producer(); @@ -228,10 +311,8 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { broadcastPublication.announce(); const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); const url = new URL("https://localhost/test"); - const [client, server] = await Promise.all([ - connect({ url, transport: pair.client }), - accept({ url, transport: pair.server, publish: origin.consume() }), - ]); + const client = new Connection({ url, quic: pair.client, version }); + const server = new Connection({ url, quic: pair.server, version, publish: origin.consume(), client: false }); const remote = wireOf(client).consume(Path.from("room")); const track = remote.track("video").subscribe().ordered(); let closed = false; @@ -239,7 +320,9 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { closed = true; }); try { - producer.appendGroup().writeFrame(textFrame("first")); + const first = producer.appendGroup(); + first.writeFrame(textFrame("first")); + first.finish(); expect(await (await track.nextGroup())?.readString()).toBe("first"); fin.enable(); @@ -258,6 +341,7 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { fin.release(); await closing; expect(closed).toBe(true); + expect(await track.nextGroup()).toBeUndefined(); } finally { fin.release(); track.close(); @@ -268,6 +352,100 @@ for (const alpn of [ALPN_05, ALPN_06, ALPN_07_WIP]) { origin.close(); } }); + + // The peer's close can overtake the final group or, where it is the track's only end, the + // SUBSCRIBE FIN it sent first; the closing session reads them. + for (const overtaken of hasTrackStream(version) ? ["group"] : ["group", "fin"]) { + test(`a peer close drains the overtaken ${overtaken} over ${versionName(version)}`, async () => { + const pair = createMockTransportPair(alpn); + deliverWhole(pair.client); + if (overtaken === "fin") finAfterClose(pair.client); + const origin = new Producer(); + const broadcastPublication = origin.createBroadcast(Path.from("room")); + const broadcast = broadcastPublication.broadcast; + broadcastPublication.announce(); + const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); + const url = new URL("https://localhost/test"); + const client = new Connection({ url, quic: pair.client, version }); + const server = new Connection({ + url, + quic: pair.server, + version, + publish: origin.consume(), + client: false, + }); + const remote = wireOf(client).consume(Path.from("room")); + const track = remote.track("video").subscribe().ordered(); + try { + const first = producer.appendGroup(); + first.writeFrame(textFrame("first")); + first.finish(); + expect(await (await track.nextGroup())?.readString()).toBe("first"); + + const frames = Array.from({ length: 32 }, (_, i) => `frame ${i}`); + const last = producer.appendGroup(); + for (const frame of frames) last.writeFrame(textFrame(frame)); + last.finish(); + producer.finish(); + producer.close(); + await server.close(); + + const group = await track.nextGroup(); + for (const frame of frames) expect(await group?.readString()).toBe(frame); + expect(await group?.readString()).toBeUndefined(); + expect(await track.nextGroup()).toBeUndefined(); + } finally { + track.close(); + remote.close(); + client.abort(); + server.abort(); + broadcast.close(); + origin.close(); + } + }); + } + + // The subscriber reads a legacy subscribe stream to its FIN, since QMux closes a stream only + // on a read; awaiting the FIN instead would hold the closing session's drain forever. + if (!hasTrackStream(version)) { + test(`a peer close finishes a FIN'd track over qmux ${versionName(version)}`, async () => { + const pair = qmuxPair(); + const origin = new Producer(); + const broadcastPublication = origin.createBroadcast(Path.from("room")); + const broadcast = broadcastPublication.broadcast; + broadcastPublication.announce(); + const producer = broadcast.createTrack("video", { timescale: Timescale.MILLI }); + const url = new URL("https://localhost/test"); + const client = new Connection({ url, quic: pair.client, version }); + const server = new Connection({ + url, + quic: pair.server, + version, + publish: origin.consume(), + client: false, + }); + const remote = wireOf(client).consume(Path.from("room")); + const track = remote.track("video").subscribe().ordered(); + try { + const group = producer.appendGroup(); + group.writeFrame(textFrame("only")); + group.finish(); + expect(await (await track.nextGroup())?.readString()).toBe("only"); + + producer.finish(); + producer.close(); + await server.close(); + expect(await track.nextGroup()).toBeUndefined(); + } finally { + track.close(); + remote.close(); + client.abort(); + server.abort(); + broadcast.close(); + origin.close(); + } + }); + } } /** A lite-07 publisher serving a finished track to a raw subscriber that has read up to the FIN. */ diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index e570afff863..e33e4e70991 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -104,6 +104,9 @@ export class Connection implements Established { // Module for distributing tracks. #subscriber: Subscriber; + // The received unidirectional streams still being read, which a closing session drains. + #unis = new Set>(); + /** The peer's PROBE estimates; see {@link Established.probe}. */ readonly probe: Getter; @@ -265,6 +268,10 @@ export class Connection implements Established { this.#publisher.close(); this.#subscriber.close(); + this.#closeTransport(); + } + + #closeTransport(): void { try { // TODO: For whatever reason, this try/catch doesn't seem to work..? this.#quic.close(); @@ -316,9 +323,16 @@ export class Connection implements Established { probes.close(); // A session that ends before the peer's SETUP never learns its implementation. this.#peerImplementation.resolve(undefined); + const err = fatal ?? (await closeError(this.#quic)); + // A failed task can end the session over a live transport. Closing it first ends + // every read the drain waits on. + this.#closeTransport(); + // The peer's close can overtake the group streams and SUBSCRIBE FIN it sent first. + // Read what already arrived, so a track its publisher ended finishes rather than aborts. + await Promise.all([...this.#unis, this.#subscriber.drain()]); // The session died under every track it was receiving, so they end with its // error. A deliberate close() already ended them cleanly, which makes this a no-op. - this.#fail(fatal ?? (await closeError(this.#quic))); + this.#fail(err); } } @@ -448,7 +462,7 @@ export class Connection implements Established { const stream = await readers.next(); if (!stream) break; - this.#runUni(stream) + const task = this.#runUni(stream) .then(() => { stream.stop(StreamCode.Cancel); }) @@ -458,7 +472,9 @@ export class Connection implements Established { closeViolation(this.#quic, err); this.#fail(err); } - }); + }) + .finally(() => this.#unis.delete(task)); + this.#unis.add(task); } } diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index e61a68f46f3..638ccaa7bce 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -30,12 +30,13 @@ import { encodeSubscribeResponse, SubscribeDrop, SubscribeEnd, + SubscribeOk, type SubscribeResponse, SubscribeStart, } from "./subscribe.ts"; import { SUBSCRIBE_SETUP_TIMEOUT_MS, Subscriber } from "./subscriber.ts"; import { Track, TrackInfo } from "./track.ts"; -import { Version } from "./version.ts"; +import { Version, versionName } from "./version.ts"; test("closing the subscriber suppresses probe stream warnings", async () => { let readable!: ReadableStreamDefaultController; @@ -2255,6 +2256,42 @@ test("a lite-07 publisher FIN ends a covered bounded subscription", async () => } }); +// Before lite-05 the publisher FINs the subscribe stream only once the track is over, so that FIN +// finishes the track once the tail settles, rather than releasing it for another route. +test.each( + [Version.DRAFT_01, Version.DRAFT_02, Version.DRAFT_03, Version.DRAFT_04].map((v) => [versionName(v), v] as const), +)("a %s publisher FIN finishes the track", async (_, version) => { + const clock = new Manual(); + const installed = clock.install(); + const { quic, streams, opened, closes } = fakeSession(); + const subscriber = new Subscriber(quic, version, HopSchema.parse(1n)); + const broadcast = subscriber.consume(Path.from("room")); + const track = broadcast.track("video").subscribe(); + try { + const stream = await opened(0); + await stream.reading; + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ write: (chunk) => void chunks.push(new Uint8Array(chunk)) }), + version, + ); + await encodeSubscribeResponse(writer, { ok: new SubscribeOk({}) }, version); + for (const chunk of chunks) streams[0].inbound.enqueue(chunk); + streams[0].inbound.close(); + await drainReactions(); + + await clock.advance(Nano.fromMilli(GRACE)); + expect(await track.finished()).toEqual({ group: 0, frame: 0 }); + expect(await track.closed).toBeNull(); + expect(closes).toEqual([]); + } finally { + track.close(); + broadcast.close(); + subscriber.close(); + installed.close(); + } +}); + // The FIN may answer the bounded range before the publisher read an update that widened it, // so the track fails rather than ending clean short of the demand. test.each([ diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 7b3debc7861..db145b4a6c0 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -138,6 +138,11 @@ interface SubscribeEntry { // (SUBSCRIBE_END), once it declares them. start?: number; end?: number; + // Set by the subscribe stream's FIN before lite-05, the track's only end there. + finished?: boolean; + // Settles once the subscribe stream's responses are read to its FIN or reset, which + // Subscriber.drain waits on. + responses?: Promise; // Whether the stream ever asked for a bounded range, which a lite-07 publisher FINs without // SUBSCRIBE_END once it covers it. bounded: boolean; @@ -961,11 +966,11 @@ export class Subscriber { // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there // and just wait on the stream/track like before. // - // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until - // its FIN; older drafts just close it. Either way group streams can still be in - // flight, so the track ends only once the tail is accounted for. A reset rejects - // instead, so the track ends with that error rather than a clean tail. - const responses = hasTrackStream(this.version) ? this.runResponses(stream, entry) : stream.reader.closed; + // The publisher sends responses on this stream until its FIN. Group streams can + // still be in flight, so the track ends only once the tail is accounted for. A reset + // rejects instead, so the track ends with that error rather than a clean tail. + const responses = this.runResponses(stream, entry); + entry.responses = responses.catch(() => {}); let tailSettled = false; // Set when the publisher ended the subscription short of the demand. let short: Error | undefined; @@ -1008,10 +1013,14 @@ export class Subscriber { // A publisher that covered the range before reading an update widening it sent // fewer groups than the track has, so it fails rather than ending clean. Otherwise - // only a received SUBSCRIBE_END declares the track's end: a range covered or a - // subscription nobody wants anymore is a released copy, not finished content. + // only a received SUBSCRIBE_END, or before lite-05 a FIN, declares the track's end: + // a range covered or a subscription nobody wants anymore is a released copy, not + // finished content. if (short) producer.abort(short); - else hooks.releaseTrack(producer); + else { + if (tailSettled && entry.finished) producer.finish(); + hooks.releaseTrack(producer); + } // A settled lite07 tail acknowledges completion with FIN, without cancelling // the publisher's already-finished receive half. if (tailSettled && waitsForSubscriberFin(this.version)) stream.writer.close(); @@ -1435,11 +1444,13 @@ export class Subscriber { } } - // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording - // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right - // away, so a consumer learns it before the last groups arrive. The publisher must declare - // the end before FIN unless we cancelled locally, or on lite-07 it covered a bounded range; - // resets and malformed responses reject. + // Reads the subscribe stream's responses until FIN, recording the range the tail is + // accounted against: SUBSCRIBE_START/END/DROP on lite-05+, and any SUBSCRIBE_DROP after the + // OK before then, where the FIN itself ends the track. Reading to the FIN, rather than + // awaiting it, matters: a pull-driven transport stream closes only on a read. + // SUBSCRIBE_END declares the track's end right away, so a consumer learns it before the + // last groups arrive. The publisher must declare the end before FIN unless we cancelled + // locally, or on lite-07 it covered a bounded range; resets and malformed responses reject. private async runResponses(stream: Stream, entry: SubscribeEntry): Promise { for (;;) { const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); @@ -1449,6 +1460,11 @@ export class Subscriber { entry.answered?.(); entry.answered = undefined; if (!resp) { + // Before lite-05 the FIN is the track's only end. + if (!hasTrackStream(this.version)) { + entry.finished = true; + return; + } // A bounded range may have widened since, in an update the publisher never read. const covered = hasRanges(this.version) && entry.bounded; if (entry.end === undefined && !covered && hooks.trackReleased(entry.track).peek() === undefined) @@ -1456,6 +1472,7 @@ export class Subscriber { return; } + if ("ok" in resp) throw new ProtocolViolation("duplicate SUBSCRIBE_OK"); if ("start" in resp) { entry.start = resp.start.group; hooks.startTrack(entry.track, { start: entry.start, requested: entry.requested }); @@ -1843,9 +1860,20 @@ export class Subscriber { } } + /** + * Wait until every subscribe stream's responses are read to its FIN or reset. Bounded only + * once the transport is closed, so that its reads end. + * + * @internal + */ + async drain(): Promise { + await Promise.all(Array.from(this.#subscribes.values(), (entry) => entry.responses)); + } + /** * Ends every subscribed track: cleanly without declaring an end for a deliberate close, or * with `err` when the session died, since those tracks were cut off rather than ended. + * A track whose publisher already ended it finishes either way. */ close(err?: Error) { // A fetch or setup exchange cut off by the session is incomplete even on a deliberate @@ -1853,7 +1881,8 @@ export class Subscriber { const cut = err ?? new StreamError(StreamCode.SessionClosed, { message: "session closed" }); this.#closed.abort(cut); - for (const { track } of this.#subscribes.values()) { + for (const { track, finished } of this.#subscribes.values()) { + if (finished) track.finish(); if (err) track.abort(err); else hooks.releaseTrack(track); } diff --git a/quest/m1/README.md b/quest/m1/README.md index 3f93058e5c5..c6679827add 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -141,7 +141,6 @@ removed). - [Moxygen minimal interop](/quest/m1/smoke-moxygen.md) - waiting on Moxygen to send a registered filter type, then the smoke lane stops hiding its failure - [Smoke token subject](/quest/m1/smoke-token-subject.md) - condition: grant subject ships; then the release smoke test signs with `--subject` - [Moxygen pin bump](/quest/m1/moxygen-pin-bump.md) - waiting on moxygen#264, then the TRACK_STATUS patch is dropped -- [Broadcast lifecycle](/quest/m1/broadcast-lifecycle/README.md) - broadcasts are online or offline with no finished state in moq-net, finished-broadcast retention is a fixed moq-mux policy, and each linger timer has one meaning - [Upgrade notes match the code](/quest/m1/upgrade-guide-facts.md) - the upgrade and CLI migration guides stop promising drafts 19-21, capped grants, and an ignored `MOQ_EPOCH` - [A smaller, safer API than release](/quest/m1/api-audit/README.md) - every surface added since release has a consumer or is gone, overlapping operations are collapsed, names mirror across Rust, JS, and the bindings, and every break says how to migrate - [moq-transport 18 and 22 only](/quest/m1/ietf-drop/README.md) - drafts 14-17 and 19-21 are deleted from Rust, JS, tests, interop lanes, and docs, removing the legacy control-stream adapter and its request limit diff --git a/quest/m1/broadcast-lifecycle/README.md b/quest/m1/broadcast-lifecycle/README.md deleted file mode 100644 index a2451c570cc..00000000000 --- a/quest/m1/broadcast-lifecycle/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# Broadcasts are online or offline, and retention is app policy - -## Goal - -A moq-net broadcast has no lifecycle state of its own: it is online while -announced and offline once closed. The app marks the end of its content with -a track FIN (hang: finish the catalog). Keeping a finished broadcast up for -late readers is a fixed-duration app policy in moq-mux, not a deadline moq-net -derives from track max-age. Every other timer called "linger" has one meaning -per name. - -## Plan - -Decided with the maintainer on 2026-10-23, from an audit of every linger -timer on main at 1582fdc07d: - -- `broadcast::Producer::finish` exists only in moq-net. Nothing reaches the - wire: ANNOUNCE active/ended already says online/offline, and a track FIN - already says a track's content ended. A BROADCAST wire message was - considered and rejected; it adds no behavior over those two and would need a - moq-transport mapping. -- The finished linger was inconsistent and partly accidental: - - Its duration is the max of track max-ages, which describe how long a - superseded group stays fresh and never the newest one. The JS publish - catalog declares `Number.MAX_SAFE_INTEGER` max-age, so every browser - publisher lingered the full 30s; Rust catalogs declare none. - - It counted from different events: Rust from the first poll of `wait`, - JS from the `wait` call, UniFFI from `finish`, and C from the caller's - own clock. - - Only Rust ended it when a newer epoch appeared at the path. -- Closing a broadcast already leaves tracks handed out playing to their end, - and session drain delivers their tail to attached subscribers. Retention - only serves readers that arrive after the end. -- A retaining publisher ignores a newer instance at its path: if the newer - one leaves, the old one is announced again, and readers see its finished - catalog and stop. Simpler than watching for supersession, and acceptable - under online/offline. -- The other timers stay. Each is a cache or debounce with its own name and - owner: the Rust idle track copy (30s), discovery (2s), the JS connection - pool (2s), and the relay's `[stats] linger` (5m). They are not cross-language - lifecycle guarantees. -- After this epic, user-facing "linger" means only publisher retention - (`moq` publishing subcommands, moqsink) and the relay's stats setting. The - export-side wait becomes `--follow`. -- Docs change inline in each quest; no separate lifecycle page. - -## Required - -- [js/net close FIN](/quest/m1/broadcast-lifecycle/js-close-fin.md) - js/net finishes tracks whose FIN arrives with a graceful close - -## Related - -- [SRT on the shared socket](/quest/m2/one-port/srt-demux.md) - an SRT ingest whose peer goes silent aborts instead of finishing as if it had shut down - -- [moq-transport finish interop](/quest/m2/ietf-finish.md) - the interop `finish` case, whose late readers rely on retention -- [CLI and gst flags](https://github.com/moq-dev/moq/pull/6522) - removed the opt-in `linger` booleans, leaving completion to this epic diff --git a/quest/m1/broadcast-lifecycle/js-close-fin.md b/quest/m1/broadcast-lifecycle/js-close-fin.md deleted file mode 100644 index 19ea400ce0e..00000000000 --- a/quest/m1/broadcast-lifecycle/js-close-fin.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] js/net finishes tracks whose FIN arrives with the close - -## Goal - -When a peer closes a session gracefully right after finishing its tracks, a -js/net relay or subscriber finishes those tracks instead of aborting them, on -every lite version, as Rust does since -[moq-dev/moq#6741](https://github.com/moq-dev/moq/pull/6741). - -## Plan - -In Rust the close could reach the subscriber before it read an already -buffered SUBSCRIBE FIN, so the track ended `Unroutable`. The fix polls the -sources once after the closed transport's group streams drain. Check whether -js/net has the same ordering; if so, reproduce with a two-`Connection` test and -mirror the fix. If not, delete this quest with the evidence. - -Public API: none. Wire: none. diff --git a/quest/m1/cut-audit/js-close-cause.md b/quest/m1/cut-audit/js-close-cause.md index 933df20c237..47311d8951c 100644 --- a/quest/m1/cut-audit/js-close-cause.md +++ b/quest/m1/cut-audit/js-close-cause.md @@ -31,7 +31,7 @@ Choose whether to retain that clean local route-close behavior or abort the received track with CANCELLED as well. Recommend retaining the mirrored Rust behavior, with cancellation at the unfinished-group boundary. No broader session redesign is included; the independent FIN-and-peer-close ordering -work remains in [JS close FIN](/quest/m1/broadcast-lifecycle/js-close-fin.md). +work lands in [moq-dev/moq#6969](https://github.com/moq-dev/moq/pull/6969). Public API: breaking in the prepared PR. Callers pass an error to `Request.reject`, and finish groups before closing when they need a complete diff --git a/quest/m2/ietf-finish.md b/quest/m2/ietf-finish.md index bdd9e7f072c..9c214702090 100644 --- a/quest/m2/ietf-finish.md +++ b/quest/m2/ietf-finish.md @@ -4,8 +4,7 @@ The interop `finish` case passes on every even moq-transport draft, not only moq-lite: each reader that joins a finished broadcast while its publisher retains it -([broadcast lifecycle](/quest/m1/broadcast-lifecycle/README.md)) gets -the whole ten-frame tail, natural media and catalog EOF, the exact broadcast's +(moq-mux's fixed linger) gets the whole ten-frame tail, natural media and catalog EOF, the exact broadcast's withdrawal, and the publisher's later health broadcast through the same session. Until then `interop.sh` records those cells as skipped; delete that skip in `run_finishes` and its README note.