net: migrate subscriptions transparently across connections#2241
Merged
Conversation
Invert broadcast ownership: the origin owns each network-fed broadcast (and its tracks and cached groups), while sessions attach to it as routes via origin::Producer::attach_route. Track requests dispatch to the best route (shortest hop chain); when a route detaches, its tracks re-dispatch to the next-best route and resume at the first missing group sequence, writing into the same track producers. Applications never observe a route change: no announce churn, no resubscribe, the same broadcast::Consumer and track::Subscriber keep working. When the last route detaches the broadcast lingers for 5s so a reconnecting session can re-attach and resume seamlessly; a deliberate unannounce closes immediately (no linger) so clean shutdowns still propagate promptly and a re-announce is a fresh broadcast. Announced::Restart is removed: route churn is invisible, and a direct publish replaced by a better one is delivered as Ended + Active since the broadcast identity changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A resume::Producer holds an ordered list of segments, each a per-session track::Consumer bounded to a range of group sequences. switch(track, N) appends a segment starting at N and caps the previous one at N - 1, so the segments partition the sequence space. A resume::Subscriber reads across segments as one logical track: bounds are enforced on the read side (a route delivering past its cap is filtered, never an error), a dead segment stalls the subscriber until the next switch resumes it, and demand forwards to each underlying track intersected with its bounds, so a serving session just sees an ordinary subscription that happens to start or end at a boundary. Fetches route to the newest segment: bounds slice demand, not access. Standalone model layer for now; wiring it into the origin's route handling (replacing the shared-track-state approach) comes next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rework how route-fed broadcasts are served, replacing the shared-track approach: sessions own their tracks completely again, and the origin splices them into logical tracks with resume::Producer::switch/takeover. A route-fed (spliced) broadcast mints logical tracks backed by segments, each a per-session track bounded to a range of group sequences. A route serves an origin::Assignment by creating its own track::Producer; the origin splices it in at a boundary (one past the newest spliced group on takeover) and the previous segment is capped at boundary - 1. Bounds are enforced on the read side, so a route racing past its cap is filtered rather than an error, and demand forwards to each session intersected with its segment bounds: the session just sees a subscription that happens to start or end at a boundary, and the resume start_group rides the normal SUBSCRIBE with no special-casing. This also enables live handover: a strictly shorter route attaching mid-subscription takes tracks over at an explicit group boundary, with the old session winding down as its demand caps. Fetches route to the newest segment, waiting for the first one if no route has served yet. track::Consumer and track::Subscriber dispatch internally between plain and spliced backings, so nothing changes for consumers of the track API except a few &self receivers becoming &mut self. The shared-track machinery (track::Keeper, resume requests, origin::RouteHandle and redispatch_track, broadcast request re-queueing) is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The resume model goes crate-internal: applications only ever see track::Consumer/Subscriber (the whole point of transparent migration), nothing outside moq-net used it, and re-exposing it later is additive. The async wrappers only its tests drive are cfg(test). origin::Route now owns the route lifecycle directly (drop detaches, unannounce(self) detaches gracefully) and hands the serving task a separate origin::Assignments queue, replacing the inverted Route/RouteGuard split where the polling handle spawned its own lifecycle guard. Assignment::serve consumes the assignment and returns origin::Serving, which derefs to the session's own track::Producer and ends with complete(self) or retire(self), absorbing the track finish. Serving twice or completing without serving is now unrepresentable, matching the origin::Broadcast producer-plus-guard precedent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
broadcast::Info is now purely static identity; the path a broadcast
takes to reach the origin lives in a new broadcast::Route { hops, cost }
that changes over the broadcast's lifetime. update_route() publishes a
change and route()/route_updated() observe it, current value first and
then every update.
Route selection (the origin's front and same-path direct publishes)
now orders by (cost, hop length, deterministic hash), so a publisher
can advertise how expensive it is to serve, e.g. a standby transcoder
at cost 10 that drops to 0 once warm, and change its mind as capacity
shifts. Cost stays local until the wire carries it. A new
origin::Route::update() changes an attached route's metadata in place,
re-picking the winner and handing live tracks over at a group boundary
exactly like an attach.
Sessions propagate updates end to end: the lite publisher watches every
announced broadcast's route and forwards hop changes as a restart (a
duplicate ANNOUNCE on lite-05, ANNOUNCE_RESTART by id on lite-06+),
including announces that cross the forwarding filter in either
direction. The lite subscriber applies a received restart as an
in-place route update instead of a detach/attach swap, so nothing
re-dispatches unless the winner actually changed. This closes the
deferred gap where a failover was not re-advertised downstream: a front
promoting a standby now updates its advertised route and downstream
sessions re-announce automatically.
Wire format unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The origin's route-serving machinery (attach_route, Route, Assignments, Assignment, Serving) goes pub(crate): its only consumers are the crate's own sessions, and the app-facing story is the broadcast-level Route. Exposing it later, shaped against a real external consumer, is additive. moq-ffi drops MoqAnnouncement.hops (a static snapshot of what is now dynamic state) and mirrors the broadcast Route API instead: a MoqRoute record (hops + cost), MoqBroadcastProducer.update_route, and on the consumer a route() snapshot plus route_updates(), a cancellable watch yielding the current route first and then every change, following the MoqAnnounced stream pattern so Go can cancel via context. Wrappers per cross-package sync: Python (Route re-export, BroadcastConsumer.route/route_updated, BroadcastProducer.update_route), Swift (Route typealias, route property, routeUpdates() AsyncSequence, updateRoute), Kotlin (Route/RouteWatch typealiases, routes() Flow), Go (Route alias, Route()/RouteUpdates()/UpdateRoute), plus the Python docs. libmoq never exposed hops, so its C surface is untouched; a route watch there is deferred until a C consumer needs one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lient libmoq.a (and moq-ffi's staticlib) bundle moq-video, so a static link on macOS now needs Foundation, CoreMedia, CoreVideo, AVFoundation, and VideoToolbox, plus the C++ runtime for openh264. The smoke harness, the libmoq CMake config (both the build and the installed moq-config), and the Go binding's cgo flags all carried the pre-moq-video framework list, so every static C/CMake/Go consumer failed to link on macOS. Smoke CI runs on Linux, which is why this rotted unnoticed, same as the earlier CoreServices gap. The smoke js-native client still called Json.Consumer from before the @moq/json snapshot/stream split; it is Json.Snapshot.Consumer now, matching js/watch's catalog consumption. With both fixes the full cross-language matrix passes from this checkout: rust/python/js publishers x rust/python/js/js-native-node/ js-native-bun/c/gst subscribers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-subscriptions-7b5067 # Conflicts: # rs/moq-mux/src/container/consumer.rs # rs/moq-net/src/lite/subscriber.rs # rs/moq-net/src/model/broadcast.rs # rs/moq-net/src/model/track.rs # test/smoke/clients/js-native/subscribe.ts # test/smoke/smoke.sh
Route gets a builder: new() is empty and with_hop/with_hops/with_cost fill it in, so a caller naming a single synthetic hop no longer has to construct an OriginList first. with_hop is fallible because the chain is capped at the wire's MAX_HOPS. update_route/route_updated become set_route/route_changed: the producer side is a plain setter, and the consumer side reads as the event it waits for. Producer::route() is gone, since a producer already knows the route it set and nothing called it; Consumer::route() stays as the cheap snapshot that selection, the FFI, and tests all use. resume::Consumer::get_group now enforces segment bounds like the reader does. A segment that raced a switch and kept delivering past its cap could surface those groups through the cache lookup while a subscriber filtered them, so the two handles disagreed on which segment owned a sequence. track::Subscriber::get_group keeps &mut self and now says why: a spliced track subscribes to each segment lazily, so waiting for a group is what registers the demand that delivers it. Consumer::get_group is the &self cache lookup for callers that just want a cached group. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing outside moq-net called it, and inside the crate its only caller was itself: the spliced arm delegated to resume::Consumer::get_group, which walked back to each segment's plain Consumer::get_group. A cycle with no entry point, which is why the dead-code lint only spoke up once the method stopped being pub. Consumers reach a past group with fetch_group, which asks a Dynamic to serve it whether or not it is still cached, rather than guessing at residency. The one thing that did use get_group was the cache-pool eviction tests, probing what survived; that probe moves into the tests as a helper over the private cached_group, which is what it always wanted to be. The bounds filtering added to resume::Consumer::get_group goes with it: that path was unreachable, so the inconsistency it fixed could not happen. Readers walk the segments through resume::Subscriber, which enforces bounds on its own. Subscriber::get_group stays: it waits for this subscription to deliver a sequence, which fetch_group (a wire FETCH) does not express, and it is now the only get_group in the API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Waiting for a specific sequence on a live subscription is a footgun: a group may never arrive. Gaps are legal (groups ride independent QUIC streams and can be dropped), a sequence below final_sequence keeps waiting in case it shows up out of order, and an evicted one waits for the track to close. On a live track that is forever, with no error to act on. The test name said as much: get_group_finishes_without_waiting _for_gaps asserted that a gap below the final sequence blocks. fetch_group covers the same ground without the trap: a cached group resolves immediately, an uncached one asks a Dynamic to serve it (a wire FETCH), and a group that can never be served fails with NotFound instead of parking. Its docs already drew the distinction, calling a missing group a failure rather than an end-of-stream. The only caller was the relay's ?group=latest, which fetched a sequence latest() had just reported, so it was always cached and never waited. It now fetches, which also gives it a bounded failure if the group is evicted in between, and matches how ?group=<n> already worked. This drops the last &mut self oddity from the reader API and squares the Rust surface with js/net, which never had getGroup at all. The spliced random-access path goes with it; a past group on a route-fed broadcast is reachable via FETCH like any other loss, which resume::Fetching already covers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated
marked this pull request as ready for review
July 16, 2026 19:03
There was a problem hiding this comment.
Sorry @kixelated, your pull request is larger than the review limit of 150000 diff characters
Model layer: - complete()/retire() release their served-table entry, so route churn never re-queues (and eventually aborts) a finished track - resume::Producer::abort refuses after finish; a clean end stays clean - a successful serve resets the retry budget; transient pre-serve failures no longer accumulate into a permanent abort - takeover computes its boundary under the write lock (no TOCTOU) and switch replaces any empty trailing segments, so a successor route dying groupless no longer bricks the logical track - resume::Subscriber: prefs polls re-register (consecutive updates were losing wakeups), poll_finished no longer consumes a buffered group, datagram-only polling activates the subscription, end_at parks groups at the cap instead of ignoring or dropping them, and next_group's dedup no longer re-delivers the last returned sequence - Serving::superseded() lets a losing route observe a handover; Assignments::poll_next registers on both channels (a detach while parked on an empty queue was never waking the feeder) and re-checks the active route between the gate and the pop - attach_route re-checks for a concurrently installed live front before replacing it; route adverts re-read the table at apply time Session layer: - the losing TrackServe exits via superseded() and retires (it used to park forever, leaking the task and upstream subscription per handover, and a route flapping back double-served the track) - upstream FIN always completes the track (the demand-derived capped heuristic stalled bounded subscribers on a genuinely ended track) - a restart is applied in place only when the first hop (the original publisher) is unchanged; otherwise the broadcast is replaced outright, matching the draft's TRACK_INFO invalidation rules - Lite01/02 cancel the upstream subscription when the last subscriber leaves (no SUBSCRIBE_UPDATE to pause with) - lite-05 no longer sends a duplicate ANNOUNCE_END after a route-filter retract; the announce loop degrades a missing announce id to a skipped restart instead of panicking The draft now defines the first entry of the reconstructed path as the original publisher's identity and conditions TRACK_INFO invalidation on it changing; a same-publisher restart keeps the cache and resumes subscriptions. An explicit epoch (#2330) would make same-origin republishes detectable too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MoqRouteWatch::next returns Ok(None) once the broadcast ends, matching every other FFI stream (it errored with Dropped); Kotlin/Swift/Python/Go wrappers end their streams cleanly - broadcast::Consumer's manual Clone resets the route cursor so a clone observes the current route first, as the field contract promises - Python caches Announcement.broadcast so the route_changed cursor survives property access (a watch loop busy-looped at 100% CPU) - libmoq CMake configs link ScreenCaptureKit (moq-video needs it; the generated .pc already did), and the Go cgo flags drop the media frameworks (moq-ffi does not bundle moq-video) - doc fixes: route_updates references, Kotlin/Swift typealias docs, a parity.md route row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… latency_max
Notable resolutions on top of the route/resume architecture:
- adopted dev's flattened announce::Update { path, broadcast } (the
Announced event enum this branch trimmed was removed entirely on dev)
- broadcast state moved to dev's alive + kio::Shared + Requests model;
the route watch pairs a state poll with alive.poll_closed and the
spliced queue polls the Shared directly
- track fetches use dev's coalescing FetchState, wrapped in this
branch's Plain/Spliced dispatch
- route serve tasks run on the connection driver's TaskSet per dev's
caller-driven sessions instead of free-floating spawns
- libmoq link flags come from dev's native-libs/*.txt single source
(which already carries ScreenCaptureKit), superseding the ad-hoc list
- Subscription.stale renamed to latency_max throughout
- the route reannounce py test keeps its first hop stable across the
update, matching the first-hop publisher-identity rule
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Notable resolutions: - dev's stats module renames (stats::Handle/Subscriber/SubscriberTrack) applied across the route serve path - kio::Future renamed to kio::Pollable in the spliced fetch plumbing - Subscriber::update keeps the Plain/Spliced dispatch but stores the subscription verbatim per dev (the latency window now clamps at the aggregate); the spliced subscribe no longer clamps at resolution - adopted dev's finish_at warning for a boundary at or below the live edge in the serve loop - python keeps both sides' additions (Route + MediaFrame), moves the route tests to announce() (publish() now warns), and retracts the announce before asserting the watch ends: announce lifetime is guard-driven now, so a closed broadcast alone stays announced Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev merge switched the cache tests to the peek_group probe, leaving this branch's older helper unused; CI's -D warnings rejects it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the route machinery to the kio::wait idiom from #2377: the announce loop's route watch, the serve loop's event race, the TRACK_INFO and SUBSCRIBE_OK decodes, the assignment drive, and the origin linger timer all poll under one waiter instead of tokio::select!. Assignments::next becomes test-only now that the serve loop polls directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kixelated
added a commit
that referenced
this pull request
Jul 18, 2026
Separate a broadcast's *existence* (registered in the origin tree, reachable
by a FETCH) from its *liveness* (advertised to `announced()` subscribers).
Publishing now registers a broadcast **offline**: `publish_broadcast` /
`create_broadcast` make it FETCH-routable but do not announce it until a
producer flips it live. This keeps an offline broadcast routable without
advertising it, the foundation for routing FETCH requests to offline
broadcasts.
Announcement is driven off the flag: the origin gates its announce stream on
liveness via a per-broadcast reconciler, announcing a live route and
unannouncing one that goes offline or closes, while existence stays in the
tree. Route selection (active vs backup at a path) is unchanged.
New surface (additive):
- rust: `broadcast::Producer::{set_live, is_live}`,
`broadcast::Dynamic::{set_live, is_live}`, and on `broadcast::Consumer`
`is_live` plus the async observer `live_changed` / `poll_live` (mirrors
`closed`/`poll_closed`, resolves `None` on close) so a consumer can react to
a broadcast going offline, matching the js `Consumer.live` signal
- ffi: `MoqBroadcastProducer::set_live`; libmoq: `moq_publish_set_live`
- py `set_live`, swift `setLive`, go `SetLive`, js `Broadcast.live` +
`setLive`
The FFI-based bindings' high-level `announce` verb marks the broadcast live
internally, so binding behavior is preserved (announce = discoverable now).
BREAKING CHANGE: in rust and js, publishing a broadcast no longer announces
it on its own; callers must call `set_live(true)` (or, in bindings, keep using
`announce`, which now implies liveness). No wire-format change: announce
already is the wire liveness signal, so no IETF draft update.
Interim API: `set_live` is a standalone flag for now. Once #2241 lands the
mutable `broadcast::Route { hops, cost }`, fold `live` in as a third field and
reconcile it through the same route observer, dropping the separate channel
and reconciler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Subscriptions migrate transparently across connections: the model is no longer tied to a single session. N connections feed the same logical tracks, and applications never observe a route change or resubscribe.
Two-layer design.
track::Producer/track::Consumerstay tied to a single session. A new crate-internalresumemodel splices per-session tracks into one logical track at group boundaries:resume::Producer::switch(track, N)appends a segment serving groups fromNand caps the previous segment atN - 1, so segments always partition the sequence space.takeover(track)computes the boundary (one past the newest spliced group) for callers reacting to a route change.resume::Subscriberreads across segments as one track. Bounds are enforced on the read side: a route delivering past its cap is silently filtered, never an error, so wire compliance is an optimization rather than a correctness requirement. A dead segment stalls the subscriber (no error) until the next switch resumes it.group_start, riding the normal SUBSCRIBE with zero special-casing.The
resumemodule ispub(crate): applications only ever seetrack::Consumer/Subscriber(that's the transparency), so nothing new is exposed for the consuming side. Exposing manual splicing later is additive.Origin routes.
attach_route(path, route)returns aRoute, the lifecycle owner: dropping it detaches (failover semantics),unannounce(self)detaches gracefully. The first route creates and announces the (spliced) broadcast; later routes join silently as standbys. A serving task drainsRoute::assignments()(anorigin::Assignmentsqueue that outlives the handle); only the best route (shortest hops) is dispatchedorigin::Assignments.Assignment::serve(self, info)splices in the session's own track and returns anorigin::Servingthat derefs to thetrack::Producer;complete()/retire()end it deliberately (absorbing the track finish), while dropping an assignment or serving hands the track back (re-queued for the next route, bounded retries). When a route detaches, its tracks re-queue and the replacement resumes at the boundary; when the last route detaches the broadcast lingers 5s for a reconnect, and a deliberateRoute::unannouncecloses immediately so clean shutdowns propagate promptly and a re-announce is a fresh broadcast.Live handover: a strictly better route attaching mid-subscription takes live tracks over at an explicit group boundary. The old route's demand is capped via SUBSCRIBE_UPDATE and it winds down; the subscriber reads a seamless sequence.
Dynamic routes.
broadcast::Infois now purely static identity; the path a broadcast takes lives in a newbroadcast::Route { hops, cost }that changes over the broadcast's lifetime.broadcast::Producer::set_routepublishes a change;broadcast::Consumer::route()/route_changed()observe it (current value first, then every update). Route selection orders by(cost, hop length, deterministic hash): a publisher can advertise how expensive it is to serve (a standby transcoder at cost 10, dropping to 0 once warm) and change its mind as capacity shifts. Cost is local-only until the wire carries it (with the lite announce-id work). Internally, a session updates its attached route's metadata in place; the origin re-picks the winner and hands live tracks over at a boundary exactly like an attach. Updates propagate end to end: the lite publisher watches announced broadcasts and forwards hop changes as a restart (duplicate ANNOUNCE on lite-05, ANNOUNCE_RESTART by id on lite-06), including announces that cross the forwarding filter in either direction; the lite subscriber applies a received restart as an in-place route update rather than a route swap. A front promoting a standby route now re-advertises downstream automatically, closing the stale-hops gap.The first hop identifies the publisher. A received restart is applied as an in-place route update only when the incoming chain's first hop (the publisher's origin) matches the currently attached route; a different first hop is a different publisher and resolves as a separate route (attach/detach), not a splice. Versions without wire hop ids get a per-session random origin so distinct upstream sessions still resolve as distinct routes. The rule is now normative in
draft-lcurley-moq-relay-hops, updated in this PR.Announced::Restartis removed. Route churn is invisible toannounced(); a direct publish replaced by a better one is delivered asEnded+Active. Wire format unchanged; ANNOUNCE_RESTART decodes as an internal route swap.Public API changes (
rs/moq-net)broadcast::Route(builder:new/with_hop/with_hops/with_cost) +broadcast::Producer::set_route+broadcast::Consumer::{route, route_changed, poll_route_changed};track::Consumer::latest.Announced::Restart/announce::Event::Restart;broadcast::Info.hops(moved to the dynamicbroadcast::Route);track::{Consumer,Subscriber}::get_group(see below). Hence thedevtarget.Subscriber::closedis untouched:devremoved it in feat(moq-net): let finish_at declare a future exclusive end group (#2219) #2234, and this branch now merges that.)get_groupremoved, both halves.Consumer::get_grouphad no callers outsidemoq-net, and inside the crate only itself (its spliced arm delegated toresume::Consumer::get_group, which walked back to each segment's plainget_group-- a cycle with no entry point).Subscriber::get_groupwas a footgun: waiting on a sequence that may never arrive (gaps are legal, an evicted group waits for the track to close) parks forever on a live track with no error to act on.fetch_groupcovers both: cached resolves immediately, uncached asks aDynamic(wire FETCH), unservable fails withNotFound. The relay's?group=latestnow fetches the sequencelatest()reports, matching how?group=<n>already worked. This also squares the Rust surface withjs/net, which never hadgetGroup.FFI / wrappers
moq-ffidropsMoqAnnouncement.hops(a static snapshot of now-dynamic state) and mirrors theRouteAPI: aMoqRouterecord (hops+cost),MoqBroadcastProducer.set_route, and on the consumer aroute()snapshot plusroute_updates(), a cancellable watch (current route first, then every change) following theMoqAnnouncedstream pattern so Go can cancel via context. Wrappers updated per cross-package sync: Python (moq.Route,route/route_changed,set_route), Swift (Route,route,routeUpdates()AsyncSequence,setRoute), Kotlin (Route/RouteWatchtypealiases,routes()Flow), Go (Routealias,Route()/RouteUpdates()/SetRoute), plus the Python docs.libmoqnever exposed hops, so the C surface is untouched; a C route watch is deferred until a consumer needs one.track::Consumer/Subscriberinternally dispatch between plain and spliced backings; no new public types intrack.Testing
resumemodel: splice, boundary filtering, demand slicing and re-slicing, takeover boundaries, dead-segment stall, finish semantics, read_frame/get_group/fetch across segments, fetch waiting for the first segment.route_changedwatch semantics (initial value, coalescing, dropped); a cost update flipping the winner hands live tracks over at a boundary and re-advertises, with zero announce events.broadcast_route_migrationkills the serving session mid-subscription and the sametrack::Subscriberkeeps reading from the standby with zero announce events;broadcast_route_reannounce(lite-05 and lite-06 variants) updates the publisher's route mid-subscription and the subscriber observes the new hop chain on the same broadcast handle, no announce churn, subscription uninterrupted; full lite/ietf version matrix green; clippy clean.just test smoke-full(cross-language interop, built from this checkout): all 21 cells pass — rust/python/browser-js publishers x rust/python/browser-js/js-native-node/js-native-bun/c/gst subscribers. Two pre-existing harness breaks were fixed along the way (see below).Kept current with dev
The branch absorbed three dev landings mid-review, re-porting the route machinery onto each: the flattened announce updates +
kio::Sharedbroadcasts, the stats module +kioPollable + guard-driven announce lifetimes (announce guards now own the advert; the route watch plugs into the guard), and #2377's tokio removal (every formertokio::select!in the announce loop, serve loop, decode paths, and origin linger now polls under a singlekio::waitwaiter). Each merge was re-verified with strict clippy and the full moq-net suite.Unrelated fixes bundled (pre-existing, found by smoke-full)
libmoq.a/moq-ffibundle moq-video, so static consumers needFoundation/CoreMedia/CoreVideo/AVFoundation/VideoToolbox+-lc++(openh264).devhas since fixed the smoke harness itself (feat(capture): macOS window/app/system-audio sources and device enumeration #2293 adds ScreenCaptureKit too, so its list wins on merge), but the libmoq CMake config (build + the installedmoq-config.cmake.inthat CMake consumers like the OBS plugin inherit) and the Go cgo flags still carried the pre-moq-video list; both are fixed here. Linux-only smoke CI can't catch macOS link rot, same as the earlier CoreServices gap.Deliberately deferred
broadcast::Route.costdrives local selection but only hops ride ANNOUNCE, so a received route always has default cost. An explicit cost field (and its aggregation rule across relays) belongs with the lite announce-id work.set_routedoesn't re-run that compare. Fronts (the normal network path) re-select on every update.🤖 Generated with Claude Code
(Written by Claude Fable 5)