From be830d53383141fec7c447df2cd04c0903b05b65 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 00:59:21 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m0/interop-green/js-pause-resume.md Quest-Claim: d2c69a3b497d5edf From eb0941c1d348dbb2f0dff68dffd8a4b656a5ea59 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:08:10 -0800 Subject: [PATCH 2/8] test(net): keep subscriptions usable after refusing track listings Co-Authored-By: GPT-6.1 Sol --- js/net/src/ietf/connection.test.ts | 64 +++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 793f1c92109..4aaec6ee6b4 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -1,20 +1,20 @@ import { expect, spyOn, test } from "bun:test"; import { offered } from "../connection/extensions.ts"; import { exchangeSetup } from "../connection/handshake.ts"; -import { SessionCode } from "../error.ts"; +import { SessionCode, StreamCode, StreamError } from "../error.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; -import { Stream, Writer } from "../stream.ts"; +import { Reader, Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; import { Connection } from "./connection.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { Group } from "./object.ts"; +import { Frame, Group } from "./object.ts"; import { RequestError, RequestOk } from "./request.ts"; import { Setup } from "./setup.ts"; import { SetupOption, SetupOptions } from "./setup_options.ts"; -import { SubscribeOk } from "./subscribe.ts"; +import { Subscribe, SubscribeOk } from "./subscribe.ts"; import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; @@ -125,16 +125,22 @@ for (const [version, alpn] of [ [Version.DRAFT_20, ALPN.DRAFT_20], [Version.DRAFT_22, ALPN.DRAFT_22], ] as const) { - test(`SUBSCRIBE_TRACKS is refused on its own stream on ${alpn}`, async () => { + test(`a refused SUBSCRIBE_TRACKS leaves subscriptions usable after a pause on ${alpn}`, async () => { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); + const origin = new OriginProducer(); + const publication = origin.createBroadcast(Path.from("prefix")); + publication.announce(); + const track = publication.broadcast.createTrack("video", {}); const connection = new Connection({ url: new URL("https://example.com"), quic: pair.server, control, version, client: false, + publish: origin.consume(), }); + const uni = pair.client.incomingUnidirectionalStreams.getReader(); try { // A second listing of the same prefix is refused the same way, each spending its Request ID. @@ -150,8 +156,56 @@ for (const [version, alpn] of [ expect((await RequestError.decode(stream.reader, version)).errorCode).toBe(0x3); expect(await stream.reader.done()).toBe(true); } + + // The browser pauses by cancelling its subscription, then opens a fresh one. + // Neither the refused listing nor the old request may end the returning reader. + for (let i = 0; i < 2; i++) { + const stream = await Stream.open(pair.client, { version }); + await Message.write( + stream.writer, + Subscribe.id, + new Subscribe({ + requestId: BigInt(4 + i * 2), + trackNamespace: Path.from("prefix"), + trackName: "video", + subscriberPriority: 128, + filter: { kind: "nextObject" }, + }), + version, + ); + expect(await stream.reader.u53()).toBe(SubscribeOk.id); + const ok = await SubscribeOk.decode(stream.reader, version); + const group = track.appendGroup(); + group.writeFrame({ payload: Uint8Array.of(i + 1) }); + group.finish(); + + for (;;) { + const incoming = await uni.read(); + if (incoming.done) throw new Error("the subscription served no group"); + const reader = new Reader(incoming.value, undefined, version); + const header = await Group.decode(reader, version); + expect(header.trackAlias).toBe(ok.trackAlias); + // Next Object may first open an empty tail of the previous group. + if (header.groupId < i) { + while (!(await reader.done())) { + const frame = await reader.decode((cursor) => Frame.decode(cursor, header.flags, undefined)); + expect(frame.payload).toBeUndefined(); + } + continue; + } + expect(header.groupId).toBe(i); + const frame = await reader.decode((cursor) => Frame.decode(cursor, header.flags, undefined)); + expect(frame.payload).toEqual(Uint8Array.of(i + 1)); + break; + } + + stream.abort(new StreamError(StreamCode.Cancel, { message: "pause" })); + while (track.subscription.peek() !== undefined) await track.subscription.changed(); + } } finally { + uni.releaseLock(); connection.abort(); + origin.close(); } }); } From 2050662984e4e448f8d02f5a87953bbf5b4e0487 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:08:39 -0800 Subject: [PATCH 3/8] style(net): format the scripted peer frame reader Co-Authored-By: GPT-6.1 Sol --- js/net/src/ietf/connection.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 4aaec6ee6b4..96633676243 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -188,7 +188,9 @@ for (const [version, alpn] of [ // Next Object may first open an empty tail of the previous group. if (header.groupId < i) { while (!(await reader.done())) { - const frame = await reader.decode((cursor) => Frame.decode(cursor, header.flags, undefined)); + const frame = await reader.decode((cursor) => + Frame.decode(cursor, header.flags, undefined), + ); expect(frame.payload).toBeUndefined(); } continue; From f6ea329ff1536545159b8449900b63668f22daf9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:17:30 -0800 Subject: [PATCH 4/8] test(net): cover IETF timestamp negotiation on draft 20 Co-Authored-By: GPT-6.1 Sol --- js/net/src/ietf/connection.test.ts | 66 +++--------------------------- js/net/src/ietf/publisher.test.ts | 8 ++-- 2 files changed, 9 insertions(+), 65 deletions(-) diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 96633676243..793f1c92109 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -1,20 +1,20 @@ import { expect, spyOn, test } from "bun:test"; import { offered } from "../connection/extensions.ts"; import { exchangeSetup } from "../connection/handshake.ts"; -import { SessionCode, StreamCode, StreamError } from "../error.ts"; +import { SessionCode } from "../error.ts"; import { createMockTransportPair } from "../mock.ts"; import { Producer as OriginProducer } from "../origin.ts"; import * as Path from "../path.ts"; -import { Reader, Stream, Writer } from "../stream.ts"; +import { Stream, Writer } from "../stream.ts"; import { Timescale } from "../time.ts"; import { Connection } from "./connection.ts"; import * as Message from "./message.ts"; import * as Namespace from "./namespace.ts"; -import { Frame, Group } from "./object.ts"; +import { Group } from "./object.ts"; import { RequestError, RequestOk } from "./request.ts"; import { Setup } from "./setup.ts"; import { SetupOption, SetupOptions } from "./setup_options.ts"; -import { Subscribe, SubscribeOk } from "./subscribe.ts"; +import { SubscribeOk } from "./subscribe.ts"; import { SUBSCRIBE_TRACKS_ID, SubscribeNamespace, SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; import { ALPN, type IetfVersion, Version } from "./version.ts"; @@ -125,22 +125,16 @@ for (const [version, alpn] of [ [Version.DRAFT_20, ALPN.DRAFT_20], [Version.DRAFT_22, ALPN.DRAFT_22], ] as const) { - test(`a refused SUBSCRIBE_TRACKS leaves subscriptions usable after a pause on ${alpn}`, async () => { + test(`SUBSCRIBE_TRACKS is refused on its own stream on ${alpn}`, async () => { const pair = createMockTransportPair(alpn); const control = await Stream.open(pair.server, { version }); - const origin = new OriginProducer(); - const publication = origin.createBroadcast(Path.from("prefix")); - publication.announce(); - const track = publication.broadcast.createTrack("video", {}); const connection = new Connection({ url: new URL("https://example.com"), quic: pair.server, control, version, client: false, - publish: origin.consume(), }); - const uni = pair.client.incomingUnidirectionalStreams.getReader(); try { // A second listing of the same prefix is refused the same way, each spending its Request ID. @@ -156,58 +150,8 @@ for (const [version, alpn] of [ expect((await RequestError.decode(stream.reader, version)).errorCode).toBe(0x3); expect(await stream.reader.done()).toBe(true); } - - // The browser pauses by cancelling its subscription, then opens a fresh one. - // Neither the refused listing nor the old request may end the returning reader. - for (let i = 0; i < 2; i++) { - const stream = await Stream.open(pair.client, { version }); - await Message.write( - stream.writer, - Subscribe.id, - new Subscribe({ - requestId: BigInt(4 + i * 2), - trackNamespace: Path.from("prefix"), - trackName: "video", - subscriberPriority: 128, - filter: { kind: "nextObject" }, - }), - version, - ); - expect(await stream.reader.u53()).toBe(SubscribeOk.id); - const ok = await SubscribeOk.decode(stream.reader, version); - const group = track.appendGroup(); - group.writeFrame({ payload: Uint8Array.of(i + 1) }); - group.finish(); - - for (;;) { - const incoming = await uni.read(); - if (incoming.done) throw new Error("the subscription served no group"); - const reader = new Reader(incoming.value, undefined, version); - const header = await Group.decode(reader, version); - expect(header.trackAlias).toBe(ok.trackAlias); - // Next Object may first open an empty tail of the previous group. - if (header.groupId < i) { - while (!(await reader.done())) { - const frame = await reader.decode((cursor) => - Frame.decode(cursor, header.flags, undefined), - ); - expect(frame.payload).toBeUndefined(); - } - continue; - } - expect(header.groupId).toBe(i); - const frame = await reader.decode((cursor) => Frame.decode(cursor, header.flags, undefined)); - expect(frame.payload).toEqual(Uint8Array.of(i + 1)); - break; - } - - stream.abort(new StreamError(StreamCode.Cancel, { message: "pause" })); - while (track.subscription.peek() !== undefined) await track.subscription.changed(); - } } finally { - uni.releaseLock(); connection.abort(); - origin.close(); } }); } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 9bc2e781775..7a01ec0e506 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2771,9 +2771,9 @@ test("LARGEST_OBJECT is the live edge", async () => { * INCLUDE_PROPERTIES=0 opts the response out of Track Properties, which also opts the track * out of timestamps: with no declared Timescale there are no units to read one in. */ -test("draft-22: an opt-out peer gets no track properties", async () => { +test.each([Version.DRAFT_20, Version.DRAFT_22])("draft-%i: an opt-out peer gets no track properties", async (version) => { for (const propertiesWanted of [true, false]) { - const fx = fixture(); + const fx = fixture(version); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); const { client, ok } = await runSubscribe( @@ -2796,8 +2796,8 @@ test("draft-22: an opt-out peer gets no track properties", async () => { const served = await nextUni(fx.uni); if (!served) throw new Error("the group was never served"); - const reader = new Reader(served, undefined, V22); - const header = await GroupMessage.decode(reader, V22); + const reader = new Reader(served, undefined, version); + const header = await GroupMessage.decode(reader, version); expect(header.flags.hasExtensions).toBe(propertiesWanted); await reader.u53(); // object id delta From f2a82ffc1d86c0557a57b24a30889098dfa9d522 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:19:23 -0800 Subject: [PATCH 5/8] style(net): keep the parameterized test title concise Co-Authored-By: GPT-6.1 Sol --- js/net/src/ietf/publisher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 7a01ec0e506..b8523278f8a 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2771,7 +2771,7 @@ test("LARGEST_OBJECT is the live edge", async () => { * INCLUDE_PROPERTIES=0 opts the response out of Track Properties, which also opts the track * out of timestamps: with no declared Timescale there are no units to read one in. */ -test.each([Version.DRAFT_20, Version.DRAFT_22])("draft-%i: an opt-out peer gets no track properties", async (version) => { +test.each([Version.DRAFT_20, Version.DRAFT_22])("draft-%i: opt-out omits timestamps", async (version) => { for (const propertiesWanted of [true, false]) { const fx = fixture(version); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI }); From 857920d2c50772baab74b8bb3a2e7ae8f743d993 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:21:25 -0800 Subject: [PATCH 6/8] chore(quest): complete browser IETF pause and resume verification Co-Authored-By: GPT-6.1 Sol --- quest/m0/interop-green/README.md | 4 -- quest/m0/interop-green/js-pause-resume.md | 69 ----------------------- 2 files changed, 73 deletions(-) delete mode 100644 quest/m0/interop-green/js-pause-resume.md diff --git a/quest/m0/interop-green/README.md b/quest/m0/interop-green/README.md index b0c9617b4c4..90238abca9f 100644 --- a/quest/m0/interop-green/README.md +++ b/quest/m0/interop-green/README.md @@ -28,7 +28,3 @@ This README's own work, after the children: confirm a scheduled Interop and Video run each pass. Public API: none. Wire: none. - -## Required - -- [The browser js -> js moq-transport cell stays fixed](/quest/m0/interop-green/js-pause-resume.md) - interop's browser `js -> js` cell passes on moq-transport drafts 20 and 22 in the scheduled run on `main`, with a regression test for the fix diff --git a/quest/m0/interop-green/js-pause-resume.md b/quest/m0/interop-green/js-pause-resume.md deleted file mode 100644 index c25da4bf2fe..00000000000 --- a/quest/m0/interop-green/js-pause-resume.md +++ /dev/null @@ -1,69 +0,0 @@ -# [S] The browser js -> js moq-transport cell stays fixed - -## Goal - -Interop's browser `js -> js` cell passes on moq-transport drafts 20 and 22 -in the scheduled run on `main`: the browser subscriber gets the catalog -and media through the relay, and playback resumes after a pause, without -the relay forwarding a `PUBLISH_DONE` with an internal error. - -## Plan - -Decided 2026-10-31 (user, /quest-plan of the follow-ups from the 2026-10-31 /quest-complete run), from [moq-dev/moq#7236](https://github.com/moq-dev/moq/pull/7236). Milestone: ✅ m0 (user, "all recommended" for quests 1-8). - -The cell fails on `main` in scheduled Interop -[41075505055](https://github.com/moq-dev/moq/actions/runs/41075505055) -(2026-10-30) and -[40896908858](https://github.com/moq-dev/moq/actions/runs/40896908858) -(2026-10-29). Two symptoms so far: - -- #7236's local run (`just test interop --versions ietf --publishers rust,js - --subscribers rust,js,js-native-node,js-native-bun`): every cell passes - on drafts 18 and 22 except browser `js -> js` on draft 22, which times out - waiting for playback to resume after a pause. After the resume, the - browser publisher ends the resumed subscriptions and the relay forwards - `PUBLISH_DONE` INTERNAL_ERROR. It fails the same way on main at - `1d8bf24998`. That report names no draft-20 result; record it in the - reproduction. The Goal's drafts are 20 and 22; draft 18's cells must stay - green but are not this quest's target. -- In 41075505055 the cell fails earlier: the browser subscriber's - `catalog.json` SUBSCRIBE gets SUBSCRIBE_OK and then - `publish done: status=0x0 reason=internal error`, so playback never starts. - -It has since gone green: the next scheduled run, -[41317901057](https://github.com/moq-dev/moq/actions/runs/41317901057) -(2026-11-01, `c95a9b1201`), passes browser `js -> js` on drafts 18, 20, and -22, where -[41209722015](https://github.com/moq-dev/moq/actions/runs/41209722015) -(2026-10-31, `38566f5a84`) failed it on 20 (`35s of 30s`) and 22 (`5s of -30s`). That run's own failure is the browser media tests in -[browser media flakes](/quest/m0/test-flakes-2/browser-media-flakes.md), -not this cell. Both PRs this quest suspected landed in between: -[moq-dev/moq#7383](https://github.com/moq-dev/moq/pull/7383) (merged, -draft-22 resume keeps DYNAMIC_GROUPS) and -[moq-dev/moq#7236](https://github.com/moq-dev/moq/pull/7236) (merged, -refuses inbound IETF track listings), along with `js/net`'s -[moq-dev/moq#7355](https://github.com/moq-dev/moq/pull/7355) (merged, ends -IETF requests once through `RequestStream`). - -What is left: - -- Confirm the cell stays green in the next scheduled Interop on `main`, or - locally with `just test interop --versions ietf --publishers js - --subscribers js` repeated. If it fails again, the original plan applies: - start at the `js/net` IETF publisher's side of a relay-forwarded - subscription and find which path ends it with an internal error, from the - relay log and the browser console in the cell's output. -- Name the fix: bisect `38566f5a84..c95a9b1201` over `js/net`, - `rs/moq-net/src/ietf`, and `rs/moq-relay` with the command above. -- Add a scripted-peer or unit test for that cause in `js/net` (or - `rs/moq-net` if the fix was there), so the regression shows without the - browser matrix, unless the fixing PR already added one that covers the - pause and resume. - -Public API: none expected. Wire: none expected; a fix that changes what -the publisher sends is reported in the PR. - -## Related - -- [Browser buffering and Firefox audio-tone tests pass under load](/quest/m0/test-flakes-2/browser-media-flakes.md) - other browser failures in the same Interop runs From 4de9f7def5dd9763bd449eabd2804ceb9f86fb1e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:21:59 -0800 Subject: [PATCH 7/8] chore(quest): size the remaining scheduled workflow verification Co-Authored-By: GPT-6.1 Sol --- quest/m0/interop-green/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quest/m0/interop-green/README.md b/quest/m0/interop-green/README.md index 90238abca9f..964bfdbd7ae 100644 --- a/quest/m0/interop-green/README.md +++ b/quest/m0/interop-green/README.md @@ -1,4 +1,4 @@ -# Interop and Video pass on main +# [XS] Interop and Video pass on main ## Goal From 7d8841657cc9fdb65cc66e521ef15c46df33cf9d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 01:26:50 -0800 Subject: [PATCH 8/8] test(net): label IETF timestamp cases with their ALPN Co-Authored-By: GPT-6.1 Sol --- js/net/src/ietf/publisher.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index b8523278f8a..b8c97b0c84b 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -2771,7 +2771,10 @@ test("LARGEST_OBJECT is the live edge", async () => { * INCLUDE_PROPERTIES=0 opts the response out of Track Properties, which also opts the track * out of timestamps: with no declared Timescale there are no units to read one in. */ -test.each([Version.DRAFT_20, Version.DRAFT_22])("draft-%i: opt-out omits timestamps", async (version) => { +test.each([ + [ALPN.DRAFT_20, Version.DRAFT_20], + [ALPN.DRAFT_22, Version.DRAFT_22], +] as const)("%s: opt-out omits timestamps", async (_alpn, version) => { for (const propertiesWanted of [true, false]) { const fx = fixture(version); const track = fx.broadcast.createTrack("video", { timescale: Timescale.MILLI });