feat(js/net)!: route publish and consume through Origins, share one connection per relay - #2705
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fe41dbd02
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const request = origin.request(path); | ||
| effect.cleanup(() => request.close()); | ||
| return effect.get(request.active); |
There was a problem hiding this comment.
Keep blind requests alive after they resolve
When reload is false, or a sibling broadcast is requested from a relay without discovery, effect.get(request.active) subscribes the current effect to the answer. Resolving the request reruns that same effect, whose cleanup immediately calls request.close() and closes the just-provided front; the next run creates another request, so a remote broadcast never remains active and can enter a repeated subscribe/close cycle. Keep the request in a nested effect or otherwise preserve it across changes to active. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e8371e0: withdrawing the last request handle now tears the slot down a microtask later, so the effect rerun triggered by the answer resolving re-acquires the same slot with its answer intact instead of closing the front and re-dialing. Regression tests: the origin-level same-tick re-acquisition test and a watch-level no-flap test that answers a standing request and asserts the front stays held across several ticks.
(written by Fable 5)
| broadcasts.get(path)?.close(); | ||
| broadcasts.set(path, front); |
There was a problem hiding this comment.
Preserve alternate routes when replacing a remote entry
When two live sessions feed the same origin and announce the same path, the second insertion closes and discards the first session's front. If the second session then disconnects, its disposer deletes the only map entry, so the path becomes unavailable even though the first session remains connected and still announces it. Retain the per-session candidates, or restore another live candidate when the selected one retracts. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e8371e0: the remote table now keeps every session's front per path, newest first. Consumers resolve [0], and disposing it promotes the next front, emitting the retract-then-announce restart pair so watchers re-consume onto the fallback. Regression tests at both levels: an origin unit test disposing the newer of two fronts, and a wire-level integration test with two overlapping sessions announcing the same path where the newer session dies and the survivor keeps serving.
(written by Fable 5)
| if (!map.has(path)) answered.delete(path); | ||
| } | ||
|
|
||
| await Promise.race([requests.changed(), closed]); |
There was a problem hiding this comment.
Wake backup sessions when a blind answer is withdrawn
With multiple sessions attached to an origin, only the first session answers a blind request because the others skip slots whose front is set. When that first session closes, it clears slot.front, but the backup loops are waiting only for requests.changed() or their own closure; changing the nested front signal does not mutate the requests map, so the still-live sessions never provide a replacement until some unrelated request-map change occurs. Wait on front changes as well, or notify the requests signal when withdrawing an answer. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e8371e0: answering now goes through origin.answer(), whose withdraw vacates the slot and pokes the requests table, which is exactly what standby serving loops sleep on, so an already-attached session re-answers immediately. answer() also reports whether it took the slot, so a session that lost the race stays eligible instead of marking the path as its own. Regression test: a wire-level two-concurrent-session test where the answering session dies and the standby must provide the next front.
(written by Fable 5)
| readonly #origin = new Signal<Origin.Producer | undefined>(undefined); | ||
| #signals = new Effect(); | ||
|
|
||
| constructor(props?: SharedProps) { |
There was a problem hiding this comment.
Document the public Shared constructor
Add a doc comment describing the exported constructor's options and ownership behavior. Shared is a new published API, but its public constructor currently has no generated API documentation despite the repository requirement to document exported JS/TS symbols and their notable public members. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L83-L86
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Documented in e8371e0. The same commit also fixes a real lifecycle bug adjacent to this spot: announcedBroadcast parked its origin-mapping Computed on the connection-lifetime scope, retaining one per call; it now belongs to the returned handle and is released via the handle's closed promise.
(written by Fable 5)
…onnection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6fe41db to
e8371e0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e8371e089f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const discovery = effect.get(origin.discovery); | ||
| // Nothing is attached yet, so nothing can resolve; wait rather than request from nobody. | ||
| if (discovery === undefined) return; |
There was a problem hiding this comment.
Observe local routes before checking discovery
When an origin has a local publish but no session is attached, discovery is undefined, so this return leaves active empty even though origin.consume(path) would succeed. With a no-discovery session, the later one-time consume is also not subscribed to table changes, so closing or republishing that local path leaves this handle holding the superseded consumer. Follow the origin's announcement table for local routes independently of session discovery, using discovery only to decide whether a blind request is needed. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 60fcd0f: the origin-mode handle now follows the announcement table unconditionally, so a local publish resolves with no session attached at all (and reactively swaps on a republish, since the table's announcements diff front identity). Discovery only decides whether to additionally stand a blind request, gated on exactly false: with no session there is nobody to ask, and with discovery the announcement gate is the point. The table wins when both resolve. Regression tests: loopback with no session including republish and unpublish, and local-route-wins on a no-discovery origin.
(written by Fable 5)
| * (a `Connection.Reload`'s `established`), which is how the handle survives reconnects. | ||
| */ | ||
| connection: GetterInit<Established | undefined>; | ||
| connection?: GetterInit<Established | undefined>; |
There was a problem hiding this comment.
Require exactly one Broadcast source
Making connection optional while also adding optional origin means { path } now type-checks and creates a permanently inactive handle, while providing both silently ignores connection, despite the interface requiring exactly one source in prose. Represent BroadcastProps as a union such as connection-without-origin or origin-without-connection so these invalid calls cannot compile. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L132-L137
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 60fcd0f: BroadcastProps is now a union requiring exactly one source, so a call with neither or with a real connection alongside an origin no longer compiles, with a ts-expect-error test pinning the neither case.
(written by Fable 5)
| * Publish into it, consume from it, or stand requests on it; it is the same origin every | ||
| * other handle on this URL uses, and it spans the connection's reconnects. | ||
| */ | ||
| readonly origin: Getter<Origin.Producer | undefined>; |
There was a problem hiding this comment.
Hide ownership of the pooled origin
Exposing the shared entry's owning Origin.Producer lets any handle call its public close() method, which closes every routed broadcast and permanently breaks the origin for all other Shared handles leasing the same URL. This contradicts the handle-level ownership model where releasing one handle must not disturb others; expose a non-owning publish/consume facade and keep the close-capable owner inside the pool. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L132-L137
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 60fcd0f: Shared now lends its origin as Origin.Table, a non-owning surface (publish, consume, closed) that Producer implements structurally. A borrower cannot express close(), so no handle can tear the shared origin down under the others; the close-capable Producer stays inside the pool.
(written by Fable 5)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 60fcd0f6ad
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // Derived per access rather than cached: a lightweight mapped view over the session | ||
| // counts, avoiding a Computed's lifecycle. | ||
| readonly #discovery: Getter<boolean | undefined> = { |
There was a problem hiding this comment.
Return a signals-branded discovery getter
When a consumer passes origin.discovery to @moq/signals' getter() or to any component Inputs field, this hand-built object has the Getter methods but lacks the signals brand. getter() consequently classifies it as a foreign readable and throws instead of wiring it reactively. Expose this mapped value through a branded signals primitive so the new public Getter interoperates with the component API.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cff7726. discovery is a Derived now, a small addition to @moq/signals (3870cd1) for exactly this shape: a mapped view over named sources, carrying the package brand, with no Effect to close and no undefined-until-first-run gap. getter() passes it through, so it wires into a component Inputs field. Regression test in origin.test.ts asserts getter() accepts both public getters, plus Derived unit tests in io.test.ts covering the pass-through, the synchronous read, and that it only notifies when the derived value actually moves.
(written by Opus 5)
|
|
||
| Closing the connection unannounces the broadcasts but does not close them; they stay in the origin for the next session. Closing a broadcast's producer unpublishes just that path. | ||
|
|
||
| The other direction works the same way: pass an origin as the `subscribe` option and everything the peer announces appears in its table, consumable by path and gone when the session dies. `origin.consume(path)` resolves local publishes first, so a page that publishes and watches the same broadcast reads its own copy with no round trip. For a path nothing announces (a relay without discovery, or subscribing before the publisher exists on purpose), `origin.request(path)` asks the attached sessions to resolve it blind; the request stands across reconnects. |
There was a problem hiding this comment.
Correct the origin consumer examples
These documented calls do not exist on Origin.Producer: consume takes no path and returns an Origin.Consumer, while request is also defined only on that consumer. A user following this new API guide will either get an argument error or find origin.request undefined; introduce a reader such as const consumer = origin.consume() and show consumer.consume(path) and consumer.request(path).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0a65ffe. The Origins section now shows origin.request(path) reading through request.active, says what it resolves in order (local publish, then any session announcement, then a blind answer), and points at origin.announced(prefix) for discovery. The example wires one origin into both directions of a connection so the loopback claim is visible rather than asserted.
(written by Opus 5)
|
API revision in 74fdf67, from design discussion:
Also filed from the same discussion: #2708 (lazy announce interest, both languages) and #2709 (per-broadcast bandwidth estimates and reservation). (written by Fable 5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74fdf673cd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| #closed = false; | ||
|
|
||
| /** @internal Created by {@link Consumer.request}. */ | ||
| constructor(path: Path.Valid, active: Getter<broadcast.Consumer | undefined>, dispose: Dispose) { |
There was a problem hiding this comment.
Make the Request constructor private
External consumers can currently call this constructor with an arbitrary getter and disposer, manufacturing a Request that was never registered with an origin and whose ownership/lifecycle guarantees are false. The @internal tag does not prevent this because the TypeScript configuration does not enable stripInternal, so the constructor remains in the published declaration; use a private constructor plus a module-local factory, as Consumer already does. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L132-L137
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cff7726. The constructor is private and construction goes through a module-local makeRequest, matching Consumer. Confirmed the reasoning: @internal does not strip without stripInternal, so the tag alone left it callable in the published declarations.
(written by Opus 5)
| }; | ||
| const sources = [this.#state.local, this.#state.remote, slot.front] as const; | ||
|
|
||
| return { |
There was a problem hiding this comment.
Brand the request's active getter
When a caller wires request.active into a component Inputs property or passes it to @moq/signals' getter(), this hand-built object is classified as a foreign readable because it lacks the package's getter brand, and getter() throws at runtime even though the value type-checks as Getter. Return the mapped value through a branded signals primitive so this new public getter interoperates with the component API. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cff7726, same Derived as the discovery getter. It also retired the two as Getter<T>["changed"] casts and the spelled-out tuple wiring that the hand-built version needed.
(written by Opus 5)
| // The origin to consume from. Independent of any connection: whichever sessions feed | ||
| // the origin resolve the broadcast, and the handle spans their reconnects. | ||
| origin: Getter<Moq.Origin.Table | undefined>; |
There was a problem hiding this comment.
Update component examples to pass an origin
After replacing the watch component's connection input with origin, the examples in doc/lib/js/@moq/watch.md at lines 98-103, 120-125, and 276-281 still construct Watch.Broadcast with { connection }; doc/lib/js/@moq/publish.md lines 120-123 has the same stale input for Publish.Broadcast. These examples now fail excess-property checking in TypeScript and leave the component without an origin in JavaScript, so the published guides should create/pass the corresponding origin. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 0a65ffe. All three Watch.Broadcast examples and the Publish.Broadcast one take origin: connection.origin, and each block now constructs the Connection.Shared it reads from so it stands alone. Also corrected the manual-catalog prose that still said "the connection and broadcast name are still required".
(written by Opus 5)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b71089b684
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| #retry(effect: Effect, connected: DOMHighResTimeStamp | undefined, cause?: unknown): void { | ||
| // Resolved per sequence rather than at construction, so an edit to `delay` (including | ||
| // one that drops a field back to its default) applies to the next retry. | ||
| const { initial, multiplier, max, timeout } = { ...DEFAULT_DELAY, ...this.delay }; |
There was a problem hiding this comment.
Preserve defaults for explicitly undefined delay fields
When a caller constructs a partial configuration such as { initial: maybeInitial } and the value is undefined, which ReloadDelay permits because exactOptionalPropertyTypes is disabled, this spread overwrites the default with undefined instead of falling back. An undefined initial, multiplier, or max turns the retry delay into NaN and can hammer the relay with zero-delay retries, while an explicitly undefined timeout becomes an infinite deadline; resolve each field with ?? rather than relying on object spread.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in cea92b6. Each field resolves with ?? against its default instead of by spread, so an explicit undefined falls back. Verified against the failure mode you describe: the regression test constructs { initial: undefined, multiplier: undefined, max: undefined, timeout: 0 } and asserts one dial in 100ms; without the fix the NaN backoff redials as fast as the event loop allows and it fails. timeout: 0 still means unlimited, since ?? only falls back on nullish.
(written by Opus 5)
…onnection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b71089b to
0a65ffe
Compare
|
You have reached your Codex usage limits for security reviews. Please try again later. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0a65ffedcb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * | ||
| * Borrowed, not yours to close: take a `clone()` for a lifetime of your own. | ||
| */ | ||
| readonly active: Getter<broadcast.Consumer | undefined>; |
There was a problem hiding this comment.
Return an owned handle from Request.active
When a caller closes the broadcast.Consumer returned by request.active, it closes the origin's table-owned front rather than a per-request handle. For a local route this can close the producer and unpublish the path for every other requester; for a remote route it can leave the table pointing at a closed cached consumer. The documentation warning does not prevent this ordinary Consumer.close() call, so expose a per-request clone or a non-closing facade instead. (Written by GPT-5.6 Sol)
AGENTS.md reference: AGENTS.md:L132-L137
Useful? React with 👍 / 👎.
|
Browser pass re-run after the rebase, against a real One connection for the whole page (#2628). With
Unaided recovery through an outage longer than the retry window. This is the case the previous rebase broke, so it was the point of the exercise: killed the relay, held the outage ~90s (well past the 10s default window that Media path. Both tiles decoding concurrently over the one session: The Rust CLI publishers did not survive the outage ( (written by Opus 5) |
Derived is not on main or dev. It exists only on the #2705 branch, so the section documented a class the published package does not export, which is the same failure mode this rewrite exists to fix. Moved to a follow-up that lands once #2705 reaches main. The rest of the page describes only what main ships today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onnection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0a65ffe to
13095c4
Compare
Derived is not on main or dev. It exists only on the #2705 branch, so the section documented a class the published package does not export, which is the same failure mode this rewrite exists to fix. Moved to a follow-up that lands once #2705 reaches main. The rest of the page describes only what main ships today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits back out of #2752, which had to drop this section because Derived does not exist on main or dev yet. Covers what Derived buys over Computed (named sources instead of tracking, a correct first read, no close()) and what it costs (fn runs per read, so it must stay cheap and pure), plus the notify-on-actual-change behavior that lets a source move without moving the view. Blocked on #2705, which adds the class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splits back out of #2752, which had to drop this section because Derived does not exist on main or dev yet. Covers what Derived buys over Computed (named sources instead of tracking, a correct first read, no close()) and what it costs (fn runs per read, so it must stay cheap and pure), plus the notify-on-actual-change behavior that lets a source move without moving the view. Blocked on #2705, which adds the class. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Adversarial Codex review, plus the fixes I agreed with. Four findings, three real, all fixed. A request whose slot was replaced under it was never answered (High,
Announcement pumps accumulated (Low, Not accepted: the report also notes JS package version bumps are due before Unrelated to this branch, found while rebasing: (written by Opus 5) |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff12a4cfe1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // exactly `false`: with no session there is nobody to ask, and with discovery the | ||
| // announcement gate is the point, so a blind subscribe would defeat it. | ||
| effect.run((nested) => { | ||
| if (nested.get(origin.discovery) !== false) return; |
There was a problem hiding this comment.
Keep mixed-discovery sessions reachable
When an origin has both a discovery-capable session and a session without discovery, origin.discovery is true, so this condition never opens a request. A path available only through the no-discovery session can never enter the announcement table, and serveRequests cannot subscribe to it because no request slot exists. Track blind fallback per attached session, or otherwise ensure that a no-discovery session can answer paths even while another session supports discovery. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
|
Two notes on the current state. The CI failure is not this branch. The run has exactly one failing test, A doc landmine for the next rebase. #2753 landed a (written by Opus 5) |
…onnection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onnection per relay Squash of the four-step origin reshape; the full narrative is in PR #2705. - Origin.Producer/Consumer: a broadcast routing table independent of any connection, mirroring rs/moq-net's origin module. origin.publish(path) creates and returns the producer; Established.publish is removed. Sessions borrow the table via the publish option on connect/accept/Reload and announce it while they last; closing a session unannounces but closes nothing, and a reconnect re-announces the untouched table. - The subscribe option feeds the table with the peer's announcements as lazily-subscribing fronts scoped to the discovering session. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions cannot echo, and consume(path) resolves local first: loopback with no round trip. - origin.request(path) mirrors Rust's dynamic origin (#1772): any attached session answers blind, answers die with their session and are re-answered by the next, never announced. Reactive origin.discovery drives the gated fallback. announce.Broadcast gained an origin mode; js/watch and moq-boy consume through origins; watch's duplicated no-discovery machinery is gone. - Both wire publishers diff announce sets by front identity, so a republish emits ended-then-active (the restart form subscribers already handle). - Connection.Shared: a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL, with a short linger past the last handle. The watch and publish elements and the demo pages share one connection per relay. Closes #2628. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review fixes, from the Codex pass and the PR comments. All four share a root cause: the origin machinery assumed one feeding session at a time, while its own API (and the coming GOAWAY drain, where old and new sessions overlap on purpose) allows several. - Remote entries keep every session's front per path, newest first: [0] is the route consumers resolve, and disposing it promotes the next, emitting the retract-then-announce restart so consumers re-consume onto the fallback. Previously a second session announcing the same path closed the first session's front, and its own death then black-holed a path a live session still carried (and would never re-announce). - Answering a request goes through origin.answer(), whose withdraw vacates the slot and pokes the requests table, waking standby serving loops so an already-attached session re-answers immediately. Previously only the slot's own signal changed, which reaches requesters but not servers, so a standing request went unanswered forever despite a live standby. A loser also stays eligible: answer() reports whether it took the slot, so a session that lost the race does not mark the path as its own. - Withdrawing the last request handle tears the slot down a microtask later. An effect whose rerun was triggered by the answer resolving closes its old request and takes a new one in the same tick; tearing down in between closed the answered front and re-dialed the subscription forever (the watch blind path flapped on every resolve). - Shared.announcedBroadcast ties its origin-mapping Computed to the returned handle instead of parking it on the connection-lifetime scope, which retained one per call until the connection closed. announce.Broadcast gained the closed promise the cleanup hangs off. - Documented the Shared constructor. Each fix carries the regression test the reviews asked for: two sessions on one path with the newer dying, a standby re-answering a dead answerer's request (wire-level, concurrent sessions), same-tick request re-acquisition, and the watch-level no-flap test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…out ownership Second review round, all three on the API contract: - An origin-mode Broadcast handle now follows the announcement table unconditionally and treats discovery only as the trigger for the blind request fallback. Previously it returned early while no session was attached (discovery undefined), so a local publish never resolved without a connection, and the no-discovery branch consumed the local route once, non-reactively, so a republish left the handle holding the superseded broadcast. The table is knowledge and the request is assumption, so the table wins when both resolve. - BroadcastProps is a union requiring exactly one of connection or origin: a call with neither (a permanently dead handle) or both (a silently ignored connection) no longer compiles. - Shared lends its origin as the new Origin.Table, the non-owning surface (publish, consume, closed). Producer implements it; the borrowing type cannot express close(), which would have torn the shared origin down under every other handle on the URL. Regression tests: loopback with no session attached including the republish swap and unpublish, local-route-wins on a no-discovery origin, and the BroadcastProps type error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…the shared session API revision from review discussion. - Request.active is now table-first: it resolves whatever the table routes (a local publish with no round trip, or any announced broadcast, swapping on a republish) and falls back to a session's blind answer only when nothing does. Resolution is derived per access, so a routed path resolves synchronously. That makes request() the single per-path consume primitive. - Origin.Consumer.consume(path) is gone from the public surface, renamed to an internal get(): a one-shot snapshot that neither waits nor follows a republish is a footgun next to a reactive handle, the same reasoning that removed the sync lookup on the Rust side. - Origin.Table grows the full borrowed surface (publish, request, announced, discovery, closed) and Producer implements it with passthroughs, so holding either side never needs the consume().x() stutter. - Shared no longer exposes the established session: it is shared, so no handle may close or reconfigure it, and everything else it offered is reachable through the origin. A transport getter covers the one legitimate read (labeling the negotiated transport); stats() stays as the session-aggregate snapshot pending per-broadcast estimates (#2709). - watch's Sync takes the probe estimates as its own input instead of reaching through a connection, matching Video.Source; the blind/gated resolution in watch collapses onto request(); the demo stats page consumes node broadcasts through the origin. Also filed #2708 (lazy announce interest, both languages) from the same discussion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three failures found in adversarial review of the origin reshape, each of which leaves a page permanently dark after a condition it should ride out. A pooled connection gave up for good after a 10s outage. `Shared` built its `Reload` with the default retry window, which is short on purpose: it assumes whoever built the loop observes `closed` and reacts. Nothing observes a pooled loop, so once the window expired every handle on that URL, and every handle taken later, was bound to a loop that had stopped. Pooled loops now retry without a deadline. An auth rejection is still terminal, and now also evicts the entry so the next handle dials fresh instead of joining a dead loop. A relay may refuse or reset the announce stream without closing the session, and the forwarder swallowed that: it retracted the session's entries and exited while the origin still counted the session as discovering. Every announcement-gated consumer then waited forever for a table nothing could fill. Discovery ending under a live session now downgrades the attachment to non-discovery (and logs the cause), so `origin.discovery` flips to false and gated consumers fall back to the standing requests the session still answers. `Origin.Table.get()` was a one-shot snapshot that races a republish, kept because `Announce.Broadcast` needed to resolve an announced path. An `@internal` tag does not strip it from the emitted declarations, so it shipped as a second, race-prone way to consume by path. It is gone: the announce-gated follower holds a request across the announced window and resolves through it, which is race-free because a session no longer answers a request the table already routes. That skip is a fix in its own right; without it the follower's request could resolve to a blind answer and defeat the announcement gate. `ReloadDelay` fields are now optional so a caller can set one knob (here, `timeout: 0`) without restating the backoff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A class publishing a small derived view of its own state had no good way to expose it. Computed carries an Effect (so it needs a close(), reads undefined until its first run, and propagates on a microtask), and a hand-written object with peek/subscribe/changed is rejected by getter() as a foreign readable, so it cannot be wired into a component input. Derived names its sources up front instead of tracking them, which buys a synchronous read and no teardown. It notifies only when the derived value actually changes, matching Signal: a source can move without moving the view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`origin.discovery` and `request.active` were hand-built objects with the Getter methods and none of the package's brand, so `getter()` classified them as foreign readables and threw: a consumer could not wire either into a component input even though both type-check as Getter. They are Derived now, which also retires the tuple-overload workarounds they had grown. `Request`'s constructor was public in the emitted declarations (`@internal` does not strip without `stripInternal`), so a caller could forge a handle no origin ever registered and whose lifecycle guarantees were therefore false. It takes the module-local factory Consumer already uses. The Producer's reader is built once rather than per property access, so `discovery` keeps its identity across reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… field
`exactOptionalPropertyTypes` is off, so `{ initial: maybeInitial }` built from an
optional value passes an explicit undefined, and spreading it over the defaults
took that as the answer. An undefined `initial`, `multiplier`, or `max` turned
the backoff into NaN, which redials as fast as the event loop allows; an
undefined `timeout` became an infinite retry window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Origins section still called `origin.consume(path)` and `origin.request` on a producer, neither of which survived the API revision that made Request the one way to consume by path. The watch and publish guides still built their Broadcast components with a `connection` input, which is now `origin`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase onto dev pulled in tests written against `Established.publish` and `Publisher.publish`, which this branch removes. Git merged them without a conflict because the surrounding lines never moved, so they compiled as calls into a surface that no longer exists. They publish through an origin now, which is the same coverage: the subscribe still reaches the same producer, only by way of the table rather than the session. `Video.Source` also lost the `Moq` import in the merge, since dev's copy of the file no longer needed it and this branch's `probe` input does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deduplicating against the value at subscribe time swallowed two real edges. A source applies a change synchronously and only queues the notification, so a Derived subscribing inside that window snapshotted the already-updated value and then suppressed the flush it was waiting for. Subscribing to the source directly delivered it, which is what the hand-written getters this class replaced did. An in-place `mutate()` force-notifies precisely because the object identity cannot change, so a mapping that returns the value as-is compared it against itself and dropped the notification. Both are lost wakeups. A redundant rerun is the cheaper failure, so the view relays what its sources report and leaves the filtering to them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A serving session tracked the paths it had answered, but a path outlives its slot: withdrawing the last handle tears the slot down a microtask later, and a request taken after that teardown installs a fresh one. Both writes land in a single coalesced wakeup, so the loop saw a slot it had never answered under a path it had, skipped it, and refused to withdraw the stale answer because the path was still occupied. The new request then never resolved. The claim is on the slot, not the path. A replaced slot now reads as withdrawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each `announced()` parked a cleanup closure on the handle's own scope, which has no unregister path, so every closed pump was retained until the whole Shared handle went away. Effect.run hands back a disposer that also drops itself from the parent, which is what this repeated open/close pattern wants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`discovery` was true as soon as one attached session announced, so an origin fed by both a discovering session and a blind one read as fully discoverable. A consumer gated on it then trusted the announcement table, and a path only the blind session could serve never entered that table and never got a request slot, so nothing ever asked for it and it stayed unreachable. It now means what consumers use it for: the table is complete. One session that cannot announce makes it false, which is what keeps the blind fallback armed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Request.active` handed out the table's own consumer. Closing a consumer closes the broadcast once it was the last live handle, and the table usually holds the only other one, so an ordinary `close()` by one requester could unpublish a local path for every other holder, or leave the table pointing at a consumer somebody else had closed. Only a doc comment stood between a caller and that, and a compile error beats a runtime check beats a warning nobody reads. Each request now clones the route it resolves, memoized on the route's identity so repeated reads return the same handle and only a real swap clones again. The clone happens on the read rather than in a subscription callback, because a callback lands a microtask late and a routed path resolves synchronously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`@internal` is how this workspace hides a symbol from the published surface, but without `stripInternal` the tag was only a comment: the declaration still shipped, still type-checked for consumers, and read as supported API. That let an origin's mutable request table out, where `slot.front.set(...)` corrupts routing for every handle on the path, and it is the same trap that already shipped a rival consume path and a forgeable constructor here. Enabled at the workspace root, so the convention holds for every package rather than the one that noticed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s read Swapping the handle on the read is what keeps a routed path resolving synchronously, but it also meant a holder that only ever peeked pinned a route that had already been retracted, keeping a dead session's broadcast alive until it happened to read again or closed the request. The request follows its route as well now. The memo makes the two paths agree, since whichever runs first does the swap, so this costs nothing on the read path and bounds retention by the retraction rather than by the next reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each request derived its route over the whole `local`/`remote` tables, and a Derived relays every source notification without comparing the value it produced. Any publish or retraction anywhere therefore woke every open request: a measured 10 wakeups for 5 unrelated publish/close cycles on a different path, scaling with the number of open requests times the churn of the whole table. A request now subscribes to a route signal owned by its own path, refreshed by whichever mutator touched that path. This is what `OriginNode` does in rs/moq-net, where each node carries its own notify and only prefix announcements walk the tree. The tables stay the storage, since announcement streams and the wire publishers legitimately iterate them and are few. Reaching for a Computed instead would dedupe the propagation but keep the per-request recompute, and its Effect schedules on a microtask, which would cost the synchronous resolution a routed path has today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A request parked forever with no way to distinguish "no session has answered yet" from "nothing here will ever answer". Waiting on a path no connection can serve looked exactly like waiting on one that is about to arrive, so a caller had no signal to stop waiting. rs/moq-net has drawn this line since #1772: `request_broadcast` resolves to `Unroutable` when nothing is announced and no handler is registered. `Request.unroutable` is that line, as a reactive fact rather than a terminal error, so a request still spans reconnects. It is true when nothing routes the path and nothing is prepared to answer it. "Prepared to answer" deliberately counts more than attached sessions. A reconnecting connection holds an expectation for its whole life, so the window between wiring up an origin and completing the first handshake stays pending. Keying this on attached sessions alone would make every page load report a missing broadcast for the length of a handshake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several sessions on one origin race to answer a request and the winner may not carry the path. Nothing detects it: a missing broadcast resets the track and deliberately leaves the handle open, because the wire cannot distinguish "not here" from "not yet" and a blind handle is expected to survive until a publisher arrives (integration.test.ts covers this for both protocols). So the three obvious repairs are all worse than the gap. Rejecting on failure needs a signal that does not exist, failing over between answers cannot tell which answer works, and having announcing sessions decline unannounced paths breaks a relay serving them from a dynamic upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebase picked up #2796's new serving tests, which still build a broadcast directly and hand it to `Publisher.publish`. This branch moves that table into the origin, so the helper now publishes through an origin and gives the publisher a consumer of it, matching every other test in the file. No textual conflict, so only the type checker caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ed0816f to
75ac33e
Compare
…oute Three ways a request could sit on a stale answer, all introduced by the two commits before this one. Seeding the route with a silent set looked free, but `Signal.set` captures the previous value as the baseline for the next comparison before it checks the notify flag, and a silent set returns before the flush that would clear it. A path routed at request time therefore kept `undefined` as its baseline, so the route retracting back to `undefined` compared equal and woke nobody: consumers kept showing a broadcast that was gone, and the request never released its clone. The seed now goes through the Signal constructor, which touches none of that machinery. Closing an origin left the answerer count alone, so a request taken out before the close stayed merely unanswered forever rather than unroutable. The count is zeroed on close, and the disposers clamp so the sessions that were attached at the time can still release without driving it negative. A reconnect loop that gives up, on an auth rejection or a retry timeout, kept its expectation and so kept promising an answer that was never coming. Both terminal paths release it now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f4aba1625
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // Woken by the table too, not just the requests: a path that stops being routed needs | ||
| // the blind answer this loop skipped while it was. | ||
| await Promise.race([origin.changed(), closed]); |
There was a problem hiding this comment.
Cancel the table wait when its session closes
When closed wins this race, the pending origin.changed() is not canceled, so its subscriptions to the request, local, and remote signals remain registered until some unrelated table mutation occurs. An idle origin therefore retains three subscriptions for every disconnected/reconnected session; after enough reconnects this leaks memory and, in development builds, reaches the signals package's 100-subscriber guard and rejects future serving loops. Use a cancellable subscription or explicitly dispose the table wait when the session closes. (Written by GPT-5.6 Sol)
Useful? React with 👍 / 👎.
Semantic conflicts resolved beyond the textual ones: - js/net origin.ts: dev put the broadcast routing table there (moq-dev#2705), main moved the origin *id* module there from lite/ (moq-dev#2910). The table keeps origin.ts; the id module is now the internal js/net/src/hop.ts, shared by both wire protocols as main intended. - js/watch broadcast.ts: dev rejects a whole catalog when a rendition's broadcast reference escapes the root (moq-dev#2630); main hides renditions whose broadcast is not announced (moq-dev#2918). Both kept. The announcement gate moved into #relativeTarget so playback and rendition selection cannot disagree about what is reachable, and filterCatalog now covers text renditions. - js/net ietf publisher: main's options-object constructor plus dev's origin-backed broadcasts and main's cluster advert. - moq-ffi session: main's wasm32 browser client alongside dev's reconnecting moq_tokio::Connection, with Inner::Connection, MoqBackoff, and the moq_tokio::Status conversion gated to native. - moq-net ietf subscriber: main's Arrival parameter plus dev's GOAWAY drain cost. web-transport-wasm 0.6 implements the poll traits moq-net requires (moq-dev/web-transport#369), so the hand-written adapters in moq-wasm and moq-ffi are gone; both files are now just the dial. This is what unblocks moq-ffi's wasm32 build (moq-dev#2911) under dev's poll-only transport (moq-dev#2736). Two of main's additions were written against APIs dev had already changed, and merged cleanly because neither side touched the other's lines: - test/wasm harness published through Established.publish, removed by moq-dev#2705. It now publishes into an Origin and passes publish: origin.consume(). - test/wasm, rs/justfile, moq-bench's hd.toml, and the new iroh doc invoked --server-bind / --server-version / --client-connect, which moq-dev#2915 refuses. moq_net::model::resume::consecutive_updates_wake asserted an absolute wake count. main's kio Park now reuses a still-registered waiter (moq-dev#2905), so applying a subscription change notifies a list the poll is parked on and self-wakes once. That costs a redundant poll and nothing else, while a lost wakeup parks the task forever, so the test measures the delta instead. moq-mux's tdt_round_trips_as_latest_value fails here and on dev alike; it is not a merge regression. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wFQ5wqKbvWwET3G5MJXY9
Closes #2628.
Summary
The JS mirror of Rust's origin model, replacing session-affine publish/consume with connection-independent routing tables, and then using them to share one connection per relay URL. Four commits, meant to be read in order; each landed green (
just check+ full JS test suite) and was verified against a real relay in a real browser.This grew out of reviewing #2655. That PR pooled sessions and leased them out, which meant
close()no longer meant close,Established's docs stopped being true, and publish+watch on one page silently broke (a session never sees its own announcements). The root cause was deeper: publish and consume were owned by sessions, so nothing survived a session and nothing could be shared. This PR moves ownership to where Rust already has it, the origin, and the pooling problems dissolve rather than needing rules.The model
Established.publishis removed.origin.publish(path)creates and returns the producer (the Rustcreate_broadcastshape); sessions borrow the table via the newpublishoption onconnect/accept/Reloadand announce it while they last. Closing a session unannounces but closes nothing; a reconnect re-announces the untouched table via the initial-scan path that already existed.subscribeoption feeds the table with the peer's announcements, as lazily-subscribing fronts scoped to the session that discovered them. Local and remote entries are separate maps and a session only announces the local one, so one origin on both directions of a connection is echo-free by construction, andorigin.consume(path)resolves local first: loopback with no round trip.origin.request(path)mirrors Rust's dynamic origin (feat(moq-net): add OriginDynamic for unannounced fallback broadcasts #1772). Any attached session answers blind; answers are withdrawn when their session dies and re-answered by the next, so a standing request spans reconnects, and they are never announced (assumed present is not availability). Reactiveorigin.discoverytells gated consumers when to fall back.announce.Broadcastgained an origin mode, js/watch and moq-boy consume through origins, and js/watch's duplicated no-discovery warn cache and blind fallback are gone.Connection.Shared, a reactive handle on a pooled{origin, reconnect loop}keyed by relay URL. Reads like aReload;url/enabledsteer which entry it leases;close()releases the handle; the last one out starts a ~2s linger so a DOM move reuses the warm connection. The watch/publish elements and the demo's discovery+stats connections all use it.Behavior changes
drafts/update.<moq-watch>tiles plus the demo page's discovery connection previously opened three sessions; they now open one. A page publishing and watching the same path resolves it locally (previously a relay round trip).Video.Sourcetakes the connection'sprobeas its own input rather than reaching through the broadcast to a connection that no longer lives there.Public API
Targets
dev: removesEstablished.publishand reshapes the js/watch and js/publish component inputs (connection->origin), plus the elementconnectionfield type (Reload->Shared, same member surface, so UI-level consumers compile untouched).Added to
@moq/signals:Derived, a mapped view over named source readables with noEffectto close. A class publishing a small derived view of its own state had nothing to reach for:Computedbrings a lifecycle and an undefined-until-first-run gap, and a hand-written{peek, subscribe, changed}is rejected bygetter()as a foreign readable, so it cannot be wired into a component input. Both of this PR's derived getters (Consumer.discovery,Request.active) were that hand-written shape and are nowDerived.Added to
@moq/net:Origin.Producer/Consumer/Request(publish,consume,announced,request,discovery,closed),ConnectProps.publish/subscribe(mirroringClient::with_publisher/with_subscriber), the same onAcceptPropsandReload,BroadcastProps.originonannounce.Broadcast,Connection.Shared+SharedProps, andSourceInput.probeon watch'sVideo.Source.Test plan
just checkgreen across the workspace. New coverage: origin unit tests (publish/supersede with the stale-close guard, remote entries, requests, discovery accounting, identity-diffed announcements), integration over mock transport pairs on both wire protocols (discover -> consume real frames -> retract; session death scoping; shared-origin no-echo; blind requests; reconnect re-population of both directions; republish swap through the wire), and pool tests (share, linger, reuse, enabled toggle, URL switch, loopback).moq-relay; browser<moq-publish>-> origin -> relay -> external subscribe-origin probe; watch playback including a full relay outage and unaided recovery (retract -> backoff -> reconnect -> re-populate -> resume); and the acceptance test for Every <moq-watch> dials its own session: reuse one WebTransport connection per relay URL and keep it alive briefly while detached #2628: the demo tile wall with two live broadcasts plus discovery accepted exactly one QUIC session at the relay.Cross-package sync
No wire-format change, so no
drafts/update (the republish fix uses already-specified messages).doc/lib/js/@moq/net.mdgained an Origins section.rs/moq-netalready has this model; #2614 landed the Rust reconnecting-Connection handle mid-branch, so the two languages now converge from opposite ends.js/hang,@moq/token, demo pages updated where they consume the changed APIs.Relationship to #2655 and follow-ups
main) is superseded by this design; I'd close it. Its two adjacent fixes are also covered here: the publisher stale-close guard lives inorigin.publishwith a regression test, and theReloadscope leak's paths were restructured out. The <moq-watch> re-insertion grace is one microtask: a detach spanning any yield closes and redials the WebTransport session #2627's DOM-move case is addressed by the linger window keeping the origin's discovered state warm.Reload/Sharedcollapse into a singleConnectionclass and whatconnect()returns), lazy announce-interest (asubscribeorigin currently opens the announce stream eagerly per session), and GOAWAY, which this structure was shaped for: the redirect handler dials the new URI and attaches it to the same origin, and everything above survives because nothing above holds a session.(Written by Fable 5)
Takeover round (Opus 5)
Rebased onto current
dev(five Rust-side commits, no JS overlap), then cleared the last two review rounds:Request's constructor was public in the emitted declarations (@internaldoes not strip withoutstripInternal), so a caller could forge a handle no origin registered. Private now, built through the module-local factoryConsumeralready uses.getter()threw on them and neither could be wired into a componentInputsfield. Both areDerivednow, which also retired the tuple-overload casts they had grown.ReloadDelayfields could be clobbered by an explicitundefined(exactOptionalPropertyTypesis off), turning the backoff intoNaN. Resolved per field with??, with a regression test verified to fail without the fix.origin.consume(path), and the watch/publish guides still passedconnectionto theirBroadcastcomponents.just checkgreen,just js testgreen. Three failures appeared in onejust js testrun (@moq/flateinflate cap,@moq/tokenRSA,@moq/publishencoder) and none reproduced on a repeat of the same tree or in isolation; all three are timing assertions under parallel package load, and two are in packages this branch does not touch.Still open from the original write-up: the browser pass has not been re-run since the rebase.
(written by Opus 5)