Repository navigation
quest(m1): plan the subscribe-close throw fix and its backport #5515
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| # [S] Backport the subscribe-close fixes to 0.4.x | ||
|
|
||
| ## Goal | ||
|
|
||
| On `release`, closing a `@moq/net` subscription never leaves an unhandled | ||
| rejection and never throws for an end of stream the client caused itself, so | ||
| a 0.17 patch after the pending cut (#5420) ships it. The repro below, run | ||
| against a `release` build of `@moq/net` and `@moq/signals`, reports | ||
| `unhandled=0` over 20 iterations; on 0.4.3 it reports 20. | ||
|
|
||
| ## Plan | ||
|
|
||
| [The main fix](/quest/m1/subscribe-close-throw.md) explains the three | ||
| defects. `release` has all three. | ||
|
|
||
| Decided 2026-10-14: | ||
|
|
||
| - One PR onto `release` (CONTRIBUTING) that ports two fixes: | ||
| - the `#runResponses` fix from the main quest; | ||
| - the `raceUntil` loser fix from ca8995280, its `js/signals` hunk and test | ||
| only (the rest of #5232 is unrelated). | ||
| - It does not hold #5420; it ships in a later 0.17 patch, like the | ||
| [FETCH edge cases](/quest/m1/fetch-edge-cases.md). The `@moq/signals` and | ||
| `@moq/net` patch bumps happen at that cut. | ||
| - `#openSubscribe` keeps sending a SUBSCRIBE for a producer closed during | ||
| setup. With both fixes that is a SUBSCRIBE cancelled at once, and `main` | ||
| avoids it through its retire logic, so the backport stays minimal. | ||
|
|
||
| Tests: port the main quest's tests. The close-during-setup test, which | ||
| withholds TRACK_INFO until the close, is the gate and fails on `release` | ||
| without this PR. | ||
|
|
||
| The repro below only sees the public API, so it times the close with | ||
| microtasks rather than observing the TRACK_INFO read. Run it on the unpatched | ||
| `release` build first and confirm 20 of 20, so a 0 on the patched build | ||
| means the fix and not a shifted race. | ||
|
|
||
| Repro, with `moq-relay` on this `relay.toml`: | ||
|
|
||
| ```toml | ||
| [listen] | ||
| bind = "127.0.0.1:14443" | ||
| tls.generate = ["localhost"] | ||
| [web.http] | ||
| listen = "127.0.0.1:14443" | ||
| [auth] | ||
| public = "**" | ||
| ``` | ||
|
|
||
| and `bun repro.ts http://127.0.0.1:14443/ 20 micro`: | ||
|
|
||
| ```ts | ||
| import * as Moq from "@moq/net"; | ||
| const url = new URL(process.argv[2] ?? "http://127.0.0.1:14443/"); | ||
| const iterations = Number(process.argv[3] ?? 20); | ||
| const delay = process.argv[4] ?? "0"; | ||
| if (!process.env.VERBOSE) console.debug = () => {}; | ||
| let unhandled = 0; | ||
| process.on("unhandledRejection", (err) => { unhandled++; console.log(`UNHANDLED #${unhandled}: ${err}`); }); | ||
| const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | ||
| const wait = () => delay === "micro" | ||
| ? (async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); })() | ||
| : sleep(Number(delay)); | ||
| const path = Moq.Path.from(`repro/${crypto.randomUUID()}`); | ||
|
|
||
| // Publisher A: one track, one group left open. | ||
| const originA = new Moq.Origin.Producer(); | ||
| const broadcast = originA.createBroadcast(path); | ||
| const track = broadcast.createTrack("room.json"); | ||
| const group = track.appendGroup(); | ||
| group.writeFrame({ payload: new TextEncoder().encode("snapshot"), timestamp: Moq.Time.Timestamp.now() }); | ||
| broadcast.announce(); | ||
| const connA = await Moq.Connection.connect({ url, publish: originA.consume() }); | ||
|
|
||
| // Subscriber B. | ||
| const originB = new Moq.Origin.Producer(); | ||
| const connB = await Moq.Connection.connect({ url, consume: originB }); | ||
| for (let i = 0; i < iterations; i++) { | ||
| const request = originB.request(path, { announced: true }); | ||
| let active = request.active.peek(); | ||
| while (!active) { | ||
| await new Promise((r) => { const d = request.active.subscribe(() => { d(); r(undefined); }); }); | ||
| active = request.active.peek(); | ||
| } | ||
| const sub = active.track("room.json").subscribe({ priority: 0 }); | ||
| await wait(); // close while the subscribe still waits for TRACK_INFO | ||
| request.close(); | ||
| sub.close(); | ||
| await sleep(20); | ||
| } | ||
| await sleep(200); connB.close(); connA.close(); await sleep(100); | ||
| console.log(`delay=${delay} iterations=${iterations} unhandled=${unhandled}`); | ||
| process.exit(0); | ||
| ``` | ||
|
|
||
| ## Required | ||
|
|
||
| - [A local close never throws](/quest/m1/subscribe-close-throw.md) - the `main` fix this ports | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| # [S] A local close never throws for a missing SUBSCRIBE_END | ||
|
|
||
| ## Goal | ||
|
|
||
| On `main`, the `@moq/net` lite subscriber no longer raises | ||
| `ProtocolViolation: subscribe stream ended without SUBSCRIBE_END` for an end | ||
| of stream it caused itself by closing the subscription. A publisher that FINs | ||
| without SUBSCRIBE_END on a track the subscriber still holds open still fails | ||
| with it. The lite draft states that a subscriber's cancel owes no | ||
| SUBSCRIBE_END. | ||
|
|
||
| Non-goals: the release line, which | ||
| [the backport](/quest/m1/subscribe-close-backport.md) carries, and skipping | ||
| the SUBSCRIBE for a producer closed during setup. | ||
|
|
||
| ## Plan | ||
|
|
||
| Found on `@moq/net` 0.4.3 against moq-relay `main`: a peer that closes a | ||
| subscription while it still waits for TRACK_INFO gets that ProtocolViolation | ||
| as an unhandled rejection (a console error in browsers, a failed test in | ||
| Bun's runner, an exit in Node). Three client defects combine: | ||
|
|
||
| 1. On `release`, `#openSubscribe` sends SUBSCRIBE for a track producer that | ||
| `request.close()` already closed. `main` retires that setup instead. | ||
| 2. `race` stopped attaching handlers once an entry settled, so the | ||
| serve loop's `subscriptionUpdates.then(() => closed)` rejected unobserved. | ||
| Fixed on `main` with a test in ca8995280 (#5232); no `@moq/signals` release | ||
| carries it yet. | ||
| 3. `#runResponses` throws when the subscribe stream ends without | ||
| SUBSCRIBE_END, including on every ordinary unsubscribe. The serve loop runs | ||
| `producer.close(); stream.close()`, and `Stream.close()` cancels the reader, | ||
| so the decode sees end of stream. With a FIN alone the relay answers with | ||
| its own FIN and no SUBSCRIBE_END, which is right: the draft has a peer echo | ||
| a FIN, and `rs/moq-net/src/lite/publisher.rs` treats the requester's FIN as | ||
| the normal end. Today `closed.catch` swallows the throw, so it is harmless | ||
| only while every caller keeps swallowing it. This quest fixes 3. | ||
|
|
||
| Decided 2026-10-14: | ||
|
|
||
| - Skip the throw when the track producer is already closed: | ||
| `entry.end === undefined && entry.track.closed.peek() === undefined`. | ||
| Every local close closes the producer before the stream (the serve loop, | ||
| `Subscriber.close()`), and a locally closed track owes no end. Chosen over | ||
| an explicit cancel flag on the entry, which every close path would have to | ||
| remember to set. | ||
| - One sentence in `drafts/draft-lcurley-moq-lite.md`, next to the subscribe | ||
| stream's SUBSCRIBE_END and FIN text: a subscriber cancels by closing its | ||
| side (FIN), the publisher answers with its own FIN, and no SUBSCRIBE_END is | ||
| owed. No wire change. | ||
|
|
||
| Tests in `js/net/src/lite/subscriber.test.ts`, with mock streams: | ||
|
|
||
| - An ordinary unsubscribe (subscribe, receive a group, close) whose peer | ||
| echoes the FIN without SUBSCRIBE_END ends without a ProtocolViolation. | ||
| `closed.catch` swallows the rejection today, so the test observes the | ||
| responses promise itself through a narrow seam; a track-closure or log | ||
| assertion would pass without the fix. | ||
| - A publisher FIN without SUBSCRIBE_END on a track still open closes the | ||
| track with ProtocolViolation. | ||
| - Closing the broadcast request after the track subscribe is dequeued but | ||
| before TRACK_INFO leaves no unhandled rejection, using the | ||
| `unhandledRejection` pattern from `js/net/src/lite/publisher.test.ts`. The | ||
| mock withholds TRACK_INFO until the close, so the ordering is explicit. | ||
|
|
||
| ## Related | ||
|
|
||
| - [Backport to 0.4.x](/quest/m1/subscribe-close-backport.md) - carries this fix and the `race` fix onto `release` |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.