Skip to content

feat(js/net)!: route publish and consume through Origins, share one connection per relay - #2705

Merged
kixelated merged 22 commits into
devfrom
claude/js-origin-extract
Aug 13, 2026
Merged

feat(js/net)!: route publish and consume through Origins, share one connection per relay#2705
kixelated merged 22 commits into
devfrom
claude/js-origin-extract

Conversation

@kixelated

@kixelated kixelated commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

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

Origin      the hub. A live path -> broadcast table. No transport, no URL, no reconnect.
Connection  a pump. Dials, reconnects. Attached to origins, owns none of them.
Session     one wire connection (Established). Internal to the pump, plus accept().
  • Publish (commit 1): Established.publish is removed. origin.publish(path) creates and returns the producer (the Rust create_broadcast shape); sessions borrow the table via the new publish option on connect/accept/Reload and 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.
  • Subscribe (commit 2): the new subscribe option 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, and origin.consume(path) resolves local first: loopback with no round trip.
  • Requests (commit 3): 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). Reactive origin.discovery tells gated consumers when to fall back. announce.Broadcast gained 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.
  • Sharing (commit 4): Connection.Shared, a reactive handle on a pooled {origin, reconnect loop} keyed by relay URL. Reads like a Reload; url/enabled steer 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

  • A republish now emits ended-then-active on the wire. Both JS publishers diffed announce sets by path presence, so a same-path swap emitted nothing; this was masked by the Rust relay implementing restart semantics itself and surfaced the moment a JS publisher talked to a JS subscriber directly. They now diff front identity. No wire-format change: duplicate/ended announces are already specified and the subscribers already handle the restart form, so no drafts/ update.
  • Session close no longer closes published broadcasts. They belong to the origin; the peer sees the unannounce because the streams die. This is the moq-lite no-cascading-abort principle applied to publishing.
  • Elements share connections. Two <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.Source takes the connection's probe as its own input rather than reaching through the broadcast to a connection that no longer lives there.

Public API

Targets dev: removes Established.publish and reshapes the js/watch and js/publish component inputs (connection -> origin), plus the element connection field 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 no Effect to close. A class publishing a small derived view of its own state had nothing to reach for: Computed brings a lifecycle and an undefined-until-first-run gap, and a hand-written {peek, subscribe, changed} is rejected by getter() 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 now Derived.

Added to @moq/net: Origin.Producer/Consumer/Request (publish, consume, announced, request, discovery, closed), ConnectProps.publish/subscribe (mirroring Client::with_publisher/with_subscriber), the same on AcceptProps and Reload, BroadcastProps.origin on announce.Broadcast, Connection.Shared + SharedProps, and SourceInput.probe on watch's Video.Source.

Test plan

  • 385 js/net tests, 0 fail; full just check green 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).
  • Real relay, real browser: bun WebSocket client publish/discover/consume/retract against 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.md gained an Origins section. rs/moq-net already 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

  • feat(js/net): share one session per relay URL #2655 (session pool on main) is superseded by this design; I'd close it. Its two adjacent fixes are also covered here: the publisher stale-close guard lives in origin.publish with a regression test, and the Reload scope 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.
  • Follow-ups, deliberately out of scope: the final naming pass (whether Reload/Shared collapse into a single Connection class and what connect() returns), lazy announce-interest (a subscribe origin 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 (@internal does not strip without stripInternal), so a caller could forge a handle no origin registered. Private now, built through the module-local factory Consumer already uses.
  • The two public derived getters carried no signals brand, so getter() threw on them and neither could be wired into a component Inputs field. Both are Derived now, which also retired the tuple-overload casts they had grown.
  • ReloadDelay fields could be clobbered by an explicit undefined (exactOptionalPropertyTypes is off), turning the backoff into NaN. Resolved per field with ??, with a regression test verified to fail without the fix.
  • Docs: the Origins section still showed the pre-revision origin.consume(path), and the watch/publish guides still passed connection to their Broadcast components.

just check green, just js test green. Three failures appeared in one just js test run (@moq/flate inflate cap, @moq/token RSA, @moq/publish encoder) 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)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/watch/src/broadcast.ts
Comment on lines +186 to +188
const request = origin.request(path);
effect.cleanup(() => request.close());
return effect.get(request.active);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/net/src/origin.ts Outdated
Comment on lines +121 to +122
broadcasts.get(path)?.close();
broadcasts.set(path, front);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/net/src/connection/forward.ts Outdated
if (!map.has(path)) answered.delete(path);
}

await Promise.race([requests.changed(), closed]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kixelated added a commit that referenced this pull request Aug 6, 2026
…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>
@kixelated
kixelated force-pushed the claude/js-origin-extract branch from 6fe41db to e8371e0 Compare August 6, 2026 20:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/announced.ts Outdated
Comment on lines +314 to +316
const discovery = effect.get(origin.discovery);
// Nothing is attached yet, so nothing can resolve; wait rather than request from nobody.
if (discovery === undefined) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/net/src/announced.ts Outdated
* (a `Connection.Reload`'s `established`), which is how the handle survives reconnects.
*/
connection: GetterInit<Established | undefined>;
connection?: GetterInit<Established | undefined>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/net/src/connection/pool.ts Outdated
* 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>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/origin.ts Outdated

// Derived per access rather than cached: a lightweight mapped view over the session
// counts, avoiding a Computed's lifecycle.
readonly #discovery: Getter<boolean | undefined> = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread doc/lib/js/@moq/net.md Outdated

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@kixelated

Copy link
Copy Markdown
Collaborator Author

API revision in 74fdf67, from design discussion:

  • origin.request(path) is now the one way to consume by path. Request.active is table-first: it resolves whatever the table routes (a local publish with no round trip, or any announced broadcast, swapping on a republish) and only falls back to a blind session answer when nothing does. Resolution is derived per access, so a routed path resolves synchronously. The racy sync lookup (Origin.Consumer.consume(path)) left the public surface, mirroring the Rust side's removal; it survives as an internal one-shot get().
  • Origin.Table is the full borrowed surface (publish, request, announced, discovery, closed), and Producer implements it with passthroughs, so the consume().consume() stutter is gone everywhere.
  • Shared no longer exposes the established session. It is shared, so no handle may close or reconfigure it; a transport getter covers the one legitimate read, and stats() stays session-aggregate pending per-broadcast estimates (per-broadcast bandwidth estimates and reservation #2709). Reconnects stay on by default and GOAWAY will slot into the same loop, so individual connections stay an implementation detail.
  • watch's Sync takes probe as its own input (matching Video.Source), and the demo stats page consumes node broadcasts through the origin.

Also filed from the same discussion: #2708 (lazy announce interest, both languages) and #2709 (per-broadcast bandwidth estimates and reservation).

(written by Fable 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/origin.ts Outdated
#closed = false;

/** @internal Created by {@link Consumer.request}. */
constructor(path: Path.Valid, active: Getter<broadcast.Consumer | undefined>, dispose: Dispose) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/net/src/origin.ts Outdated
};
const sources = [this.#state.local, this.#state.remote, slot.front] as const;

return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread js/watch/src/broadcast.ts
Comment on lines +60 to +62
// 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>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/connection/reload.ts Outdated
#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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kixelated added a commit that referenced this pull request Aug 11, 2026
…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>
@kixelated
kixelated force-pushed the claude/js-origin-extract branch from b71089b to 0a65ffe Compare August 11, 2026 22:20
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/origin.ts
*
* Borrowed, not yours to close: take a `clone()` for a lifetime of your own.
*/
readonly active: Getter<broadcast.Consumer | undefined>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Browser pass re-run after the rebase, against a real moq-relay (self-signed, http://localhost:4453) with two moq import fmp4 publishers and the demo tile wall.

One connection for the whole page (#2628). With alice.hang and bob.hang both live, a page load accepted exactly one session at the relay:

22:56:42 conn{id=0} session accepted transport=quic role=None
22:57:36 conn{id=1} session accepted transport=quic role=Some(Publisher)

id=0 is the browser: two tiles plus the discovery connection, one QUIC session. The reload-with-tiles-already-live case is the sharper one, since a per-tile connection would show up as three new sessions, and it added one.

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 dev introduced), then restarted it with a freshly generated certificate. The page recovered with no reload and no interaction: Disconnected -> retrying (connection closed, reconnecting, then repeated attempts through the whole outage rather than giving up) -> Connected, then both tiles reappeared once publishers came back, and playback resumed. The fingerprint is fetched inside each attempt, so the new certificate was picked up on its own.

Media path. Both tiles decoding concurrently over the one session: alice.hang 1422 -> 1905 frames / 19.4 MB and bob.hang 1881 frames / 19.2 MB across a six-second sample, audio at 118 kbps. Video needs visible="always" here only because the automated tab reports document.hidden, which gates render.

The Rust CLI publishers did not survive the outage (reconnect timed out after 10s). That is the deliberate fail-fast budget on the native side (#2647), not the pooled JS loop, which is why the browser recovered and they did not.

(written by Opus 5)

kixelated added a commit that referenced this pull request Aug 12, 2026
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>
kixelated added a commit that referenced this pull request Aug 12, 2026
…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>
@kixelated
kixelated force-pushed the claude/js-origin-extract branch from 0a65ffe to 13095c4 Compare August 12, 2026 03:32
kixelated added a commit that referenced this pull request Aug 12, 2026
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>
kixelated added a commit that referenced this pull request Aug 12, 2026
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>
kixelated added a commit that referenced this pull request Aug 12, 2026
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>
@kixelated

Copy link
Copy Markdown
Collaborator Author

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, js/net/src/connection/forward.ts). The serving loop tracked answered paths, but a path outlives its slot: withdrawing the last handle defers the teardown a microtask, and a request taken after that teardown installs a fresh slot. Both writes coalesce into one wakeup, so the loop saw a slot it had never answered under a path it had, skipped it, and then refused to withdraw the stale answer because the path was still occupied. The request never resolved. The claim is on the slot now, not the path, and a replaced slot reads as withdrawn. Regression test verified to fail without the fix, on a no-discovery session where blind answers are the only path.

Derived filtered out two real edges (Medium x2, js/signals/src/index.ts). Both come from the dedupe I added, which compared against the value at subscribe time. A source applies its change synchronously and only queues the notification, so a Derived subscribing inside that window snapshotted the already-updated value and suppressed the very flush it was waiting for. Separately, an in-place mutate() force-notifies precisely because identity cannot change, so a mapping returning the value as-is compared it against itself and dropped it. Both are lost wakeups, and the hand-written getters this class replaced had neither, so the dedupe is gone: the view relays what its sources report. A redundant rerun is the cheaper failure. Both cases are now pinned by tests.

Announcement pumps accumulated (Low, js/net/src/connection/pool.ts). Each announced() parked a cleanup closure on the handle scope, which has no unregister path, so closed pumps were retained until the whole Shared closed. Effect.run already hands back a self-removing disposer for this pattern.

Not accepted: the report also notes JS package version bumps are due before dev promotes to main. That is real but belongs to the release process, not this PR, which is why release-plz and the separate version-bump commits exist.

Unrelated to this branch, found while rebasing: dev HEAD is red. integration: lite applies initial and updated group bounds fails with Expected: 1, Received: undefined. Both parents of merge af7b572 pass individually and the merge fails, so it is a semantic conflict between #2716 subscription bounds and #2741 reworking the same group-serving path. A scenario matrix shows startGroup/endGroup are non-functional on dev entirely, live or pre-written, while unbounded subscribes are fine. This branch inherits that one failure and does not cause it.

(written by Opus 5)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread js/net/src/announced.ts
// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@kixelated

Copy link
Copy Markdown
Collaborator Author

Two notes on the current state.

The CI failure is not this branch. The run has exactly one failing test, integration: lite applies initial and updated group bounds, which fails identically on a clean checkout of dev. Both parents of merge af7b572 pass it individually and the merge does not, so it is a semantic conflict between #2716 subscription bounds and #2741 reworking the same group-serving path. Subscription bounds are non-functional on dev generally, not just in that test: a scenario matrix over a mock transport shows startGroup/endGroup yielding nothing whether the groups are pre-written or arrive live, while an unbounded subscribe is fine. This PR cannot go green until that is fixed on dev.

A doc landmine for the next rebase. #2753 landed a Derived section on doc/lib/js/@moq/signals.md on main, documenting a class that only exists on this branch. It states "It notifies only when the derived value actually changes, matching Signal", which 2d86215 made false: the dedupe is gone precisely because it dropped two real edges. This branch still carries the old version of that page, so when dev next merges main git will take main version without a conflict and quietly ship the wrong description. Flagging it here so it gets corrected at that rebase rather than discovered later.

(written by Opus 5)

kixelated added a commit that referenced this pull request Aug 12, 2026
…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>
kixelated and others added 21 commits August 12, 2026 20:53
…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>
@kixelated
kixelated force-pushed the claude/js-origin-extract branch from ed0816f to 75ac33e Compare August 13, 2026 03:56
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@kixelated
kixelated merged commit 3349b35 into dev Aug 13, 2026
1 check passed
@kixelated
kixelated deleted the claude/js-origin-extract branch August 13, 2026 04:16
This was referenced Aug 15, 2026
fperex pushed a commit to fperex/moq that referenced this pull request Aug 21, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant