Skip to content

net: migrate subscriptions transparently across connections#2241

Merged
kixelated merged 17 commits into
devfrom
claude/subscription-connection-migration-80faad
Jul 18, 2026
Merged

net: migrate subscriptions transparently across connections#2241
kixelated merged 17 commits into
devfrom
claude/subscription-connection-migration-80faad

Conversation

@kixelated

@kixelated kixelated commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

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::Consumer stay tied to a single session. A new crate-internal resume model splices per-session tracks into one logical track at group boundaries:

  • resume::Producer::switch(track, N) appends a segment serving groups from N and caps the previous segment at N - 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::Subscriber reads 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.
  • Demand forwards to each session's track intersected with its segment bounds, so a serving session just sees an ordinary subscription that happens to start or end at a boundary. The resume point is the slice's group_start, riding the normal SUBSCRIBE with zero special-casing.

The resume module is pub(crate): applications only ever see track::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 a Route, 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 drains Route::assignments() (an origin::Assignments queue that outlives the handle); only the best route (shortest hops) is dispatched origin::Assignments. Assignment::serve(self, info) splices in the session's own track and returns an origin::Serving that derefs to the track::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 deliberate Route::unannounce closes 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::Info is now purely static identity; the path a broadcast takes lives in a new broadcast::Route { hops, cost } that changes over the broadcast's lifetime. broadcast::Producer::set_route publishes 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::Restart is removed. Route churn is invisible to announced(); a direct publish replaced by a better one is delivered as Ended + Active. Wire format unchanged; ANNOUNCE_RESTART decodes as an internal route swap.

Public API changes (rs/moq-net)

  • Added: 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.
  • Removed (breaking): Announced::Restart / announce::Event::Restart; broadcast::Info.hops (moved to the dynamic broadcast::Route); track::{Consumer,Subscriber}::get_group (see below). Hence the dev target.
  • Changed (breaking): none beyond the removals. (Subscriber::closed is untouched: dev removed it in feat(moq-net): let finish_at declare a future exclusive end group (#2219) #2234, and this branch now merges that.)
  • get_group removed, both halves. Consumer::get_group had no callers outside moq-net, and inside the crate only itself (its spliced arm delegated to resume::Consumer::get_group, which walked back to each segment's plain get_group -- a cycle with no entry point). Subscriber::get_group was 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_group covers both: cached resolves immediately, uncached asks a Dynamic (wire FETCH), unservable fails with NotFound. The relay's ?group=latest now fetches the sequence latest() reports, matching how ?group=<n> already worked. This also squares the Rust surface with js/net, which never had getGroup.

FFI / wrappers

moq-ffi drops MoqAnnouncement.hops (a static snapshot of now-dynamic state) and mirrors the Route API: a MoqRoute record (hops + cost), MoqBroadcastProducer.set_route, and on the consumer a route() snapshot plus route_updates(), a cancellable watch (current route first, then every change) following the MoqAnnounced stream 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/RouteWatch typealiases, routes() Flow), Go (Route alias, Route()/RouteUpdates()/SetRoute), plus the Python docs. libmoq never exposed hops, so the C surface is untouched; a C route watch is deferred until a consumer needs one.

  • track::Consumer/Subscriber internally dispatch between plain and spliced backings; no new public types in track.

Testing

  • resume model: 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.
  • Origin: failover with boundary resume, live handover, linger + re-attach, linger expiry, immediate graceful unannounce, direct-publish replacement as Ended+Active.
  • Dynamic routes: route_changed watch 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.
  • e2e (real QUIC + WebTransport): broadcast_route_migration kills the serving session mid-subscription and the same track::Subscriber keeps 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::Shared broadcasts, the stats module + kio Pollable + guard-driven announce lifetimes (announce guards now own the advert; the route watch plugs into the guard), and #2377's tokio removal (every former tokio::select! in the announce loop, serve loop, decode paths, and origin linger now polls under a single kio::wait waiter). Each merge was re-verified with strict clippy and the full moq-net suite.

Unrelated fixes bundled (pre-existing, found by smoke-full)

  • macOS static-link flags: libmoq.a/moq-ffi bundle moq-video, so static consumers need Foundation/CoreMedia/CoreVideo/AVFoundation/VideoToolbox + -lc++ (openh264). dev has 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 installed moq-config.cmake.in that 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 epoch/id on the wire: same-path announces are assumed to be alternate routes of the same content (matching the existing backup-route assumption). An epoch in ANNOUNCE would make the check exact; natural fit for the lite-06 announce-id work.
  • Segment/track reaping: a front's logical tracks and old segments are kept until the broadcast closes, so idle session tracks pause (near-zero cost) rather than tear down. Reaping and demand-triggered re-assignment are follow-up.
  • Cost on the wire: broadcast::Route.cost drives 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.
  • Node-level re-selection on route update: selection among same-path direct publishes samples routes at (un)publish time; a later set_route doesn't re-run that compare. Fronts (the normal network path) re-select on every update.
  • IETF sessions still direct-publish (no route attach); moq-transport ingress keeps today's churn semantics.
  • js/net mirror owed per Cross-Package Sync.
  • A group mid-transfer when its route dies is not re-delivered live (subscribers may already hold it); it stays reachable via FETCH like any other loss.

🤖 Generated with Claude Code

(Written by Claude Fable 5)

kixelated and others added 11 commits July 13, 2026 15:01
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
kixelated marked this pull request as ready for review July 16, 2026 19:03

@sourcery-ai sourcery-ai 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.

Sorry @kixelated, your pull request is larger than the review limit of 150000 diff characters

kixelated and others added 5 commits July 16, 2026 19:00
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>
@kixelated
kixelated merged commit 261c204 into dev Jul 18, 2026
9 checks passed
@kixelated
kixelated deleted the claude/subscription-connection-migration-80faad branch July 18, 2026 03:12
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