Skip to content

refactor!: build on moq main and split generic parts into moq-ext crates - #87

Open
Frando wants to merge 346 commits into
mainfrom
Frando/modularize
Open

Frando wants to merge 346 commits into
mainfrom
Frando/modularize

Conversation

@Frando

@Frando Frando commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

This PR ports iroh-live to moq main and gives it a new public API in every library crate. The node now takes any path, and the live/ layout, calls and rooms sit on top of it as optional parts. Generic media and access code moves into moq-ext-* crates without an iroh dependency, so it can potentially be upstreamed. #75 was closed in favor of this PR, so its redesign is part of this diff.

Why

moq main now does much of what iroh-live built by hand. Its origin picks routes and fails over between them. moq-mux has rendition helpers and encoder rate control, moq-audio has an adaptive jitter buffer, and moq-room is a room layer over announcements. Main also dropped moq-tokio's iroh backend (moq-dev/moq#7345), so iroh-live is now the only place where moq runs over iroh.

That splits the work in two. What is specific to iroh stays here: the transport, the node with its route table and per-peer access, tickets, gossip rooms and the relay. Everything generic moves into moq-ext-* crates that know nothing about iroh.

On main, the API was built around one convention. Live decided the path layout, the grants, the endpoint and the router, and calls and rooms were methods on it. An application with its own path scheme, its own access rules or its own router had to work around the facade. This PR turns that around:

  • The node is the application's handle, and it knows only paths. iroh_live::Node publishes and subscribes at any path. Nothing in the node mentions live/.
  • Conventions are values you opt into. layout::LiveLayout is the <root>/<peer>/<name> scheme, and every use of it names it. An application with another scheme calls the node with its own paths.
  • Services hold a node. Calls::new(&node, &layout) and Rooms::new(&node) stand side by side. Neither knows the other, and the node knows neither.
  • Least privilege, and viewing a ticket still works. NodeConfig::default() lets peers read and publish nothing. Subscribing to a ticket grants exactly that ticket's path, to exactly its publisher, for as long as the subscription lives.

moq main is not released, so the workspace depends on it by git rev (moq-dev/moq@5ac2db31d, 2026-11-08). The README says so up front and gives the git dependency line, since iroh-live is not on crates.io either.

How it fits together

 apps        irl (cli) · Android demo · pi-zero demo · iroh-live-relay
 facade      iroh-live: layout::LiveLayout · calls · EndpointOptions · media glue
 services    iroh-live-rooms        moq-ext-player · moq-ext-publisher · moq-ext-sources
 node        iroh-live-node: Node, BroadcastTicket, grants and claims, relay links
 transport   moq-transport-iroh     moq-ext-access (grants, audiences, token convention)
 upstream    iroh · iroh-gossip · moq-net · moq-video · moq-audio · moq-json
Crate On main What it holds
iroh-live same The facade: layout::LiveLayout, calls, EndpointOptions, media::remote_broadcast, and re-exports of the rest
iroh-live-node iroh-moq Node: one route table fed by direct sessions and relay links, publications, grants and ticket claims, admission, BroadcastTicket
moq-transport-iroh part of iroh-moq moq-net's poll transport on iroh connections, dial, accept, ALPNs, MoqPreset, link statistics
iroh-live-rooms iroh-rooms Gossip membership and members-only broadcasts. No media and no chat
iroh-live-relay same Relay for browsers. Pulls broadcasts from iroh publishers into moq-relay's cluster
iroh-live-cli same The irl binary
moq-ext-access new Grant, SessionRequest, Audience<P>, visible_paths and the token wire convention, generic over the peer id
moq-ext-adaptive part of moq-media Rendition choice and decoder handover as I/O-free state machines, tested on a fake clock
moq-ext-player part of moq-media RemoteBroadcast and Player: rendition switches, adaptation, A/V sync, stats, recording
moq-ext-publisher part of moq-media LocalBroadcast: one source encoded into a rendition ladder
moq-ext-sources part of moq-media Test pattern, audio files, capture, rpicam and push senders
moq-ext-media-core new The error, slot state and stats helpers the player and the publisher share
moq-ext-egui moq-media-egui egui video views and the debug overlay

The Android EGL renderer and JNI helpers moved into demos/android/rust, their only user. The thin wrappers in moq-media are gone: the crates use moq_audio::playback::Engine, moq_audio::encode::Control, hang's Catalog, moq_net::bandwidth::Rate and moq_video::FrameRate directly.

Using it

Publish and play

On main, Live::publish returned a broadcast with one video and one audio slot, and a subscriber got one decoder per track:

// main
let live = Live::from_env().await?.with_router().spawn();
let broadcast = live.publish("hello")?;
broadcast.video().set(video::capture::Config::default())?;

let sub = live.subscribe(ticket.endpoint, &ticket.broadcast_name).await?;
if let Some(video) = sub.media().await.video {
    while let Some(frame) = video.recv().await { /* render */ }
}

Now the application binds the endpoint, owns the router, and names the layout it publishes under:

// this PR
let layout = LiveLayout::default();                          // "live/<peer>/<name>"
let endpoint = EndpointOptions::from_env()?.bind().await?;
let node = Node::new(endpoint.clone(), layout.config(&[]));  // peers publish only under their own id
let router = node.mount(Router::builder(endpoint)).spawn(); // add your own protocols here

let cam: LocalBroadcast = layout.publish(&node, "hello")?;
cam.set_video(camera, VideoEncoding::ladder([VideoRendition::p360(), VideoRendition::p720()]))?;
println!("{}", layout.ticket(node.id(), "hello")?);

let subscription = node.subscribe_ticket(&ticket).await?;
let player = remote_broadcast(&subscription).play(PlayerConfig::default())?;
let mut frames = player.video();
while let Some(frame) = frames.next().await { /* render */ }

router.shutdown().await?; // shuts down the node and every mounted service, then closes the endpoint

The broadcast is created in the node's route table, and peers read it without a copy. layout.publish_with(&node, name, audience, fill) runs fill before the announcement, so a reader never finds the broadcast without its catalog, and returns the Publication with what fill built.

On main, the adaptation state machine, the playout clock and reopen_decoder were public, and the caller had to coordinate them. Now PlayerConfig sets each behaviour with one value: RenditionMode (auto, pinned or off), Latency, the decoder kind and an optional AudioTarget. Player::status() reports the rendition on screen, a pending switch and errors. A switch opens a second decoder that takes over once it catches up, so the picture does not go blank.

An application with a path scheme of its own skips the layout and gives the node a grant:

let node = Node::new(endpoint, NodeConfig { grant: Some(Arc::new(my_grant)), ..Default::default() });
let (publication, cam) = node.publish_with("fleet/robot-7/cam", Audience::peers([edge]), fill)?;
let subscription = node.subscribe("fleet/robot-7/cam", Reach::Direct(robot)).await?;

iroh-live/examples/moq_only.rs does exactly this.

Tickets

A BroadcastTicket is a publisher's endpoint id and the full path of one of its broadcasts. It says nothing about layouts, so it works with any scheme. Publication::ticket() and LiveLayout::ticket(peer, name) build one.

The string form is ilv1:<peer>/<path>. The peer is the endpoint id in lowercase base32. A path segment equal to the peer's own id is written ~, and other bytes outside [A-Za-z0-9._-] are percent-encoded:

ilv1:xq2zp5bk3o4rbi5kx7ncymbkjrmdlbm3lqkv3wuiixu3vo5mbfqq/live/~/studio

That is about 72 characters, short enough for a QR code on a small screen. A ticket holds no socket addresses: the subscriber finds the publisher by its id over pkarr, DNS and mDNS.

Ticket grants

Node::subscribe_ticket works on a node with the default config, which lets no peer publish anything. It does that by claiming the ticket's path for the ticket's publisher:

  1. If the node's grant already lets the publisher publish the path, it claims nothing.
  2. Otherwise it calls Node::claim_path(peer, path). The claim widens what that peer may publish into this node by that one path, on every session with the peer, including ones that start later.
  3. It resolves the path like subscribe(path, Reach::Both(peer)).
  4. The subscription holds the claim. When the last subscription of the path drops, the grant narrows again, and moq ends what local readers read there.

A claim is only allowed if the peer owns the path. By default that means exactly one segment of the path is the peer's endpoint id in its canonical spelling, and no other segment parses as an endpoint id. So a claimed path names its publisher and no one else, and it can never overlap another peer's paths. NodeConfig::owner replaces the rule for a node that knows better, as the relay does. Claims are capped at 64 paths and 16 KiB per peer.

Each session's grant is now two layers: a base set by NodeConfig::grant, manual admission, ConnectOptions::grant or Session::set_grant, and the peer's claimed paths on top. Session::grant() returns the effective grant, Session::base_grant() the base. A set_grant cannot remove a claimed path, and a claim cannot undo a narrowing of any other path.

Calls

On main, each side published at calls/<own id>, which every session of the node could see, and Call::close closed the whole session. iroh_live::calls is now a service over a layout's call path:

let (calls, side): (Calls, LocalBroadcast) = Calls::new(&node, &layout)?;  // "live/<me>/call", offered to nobody
side.set_video(camera, VideoEncoding::single(VideoRendition::p360()))?;

// Place a call: ring the peer, wait for it to answer, subscribe to its side.
let call = tokio::time::timeout(Duration::from_secs(30), calls.call(peer)).await??;

// Take calls.
let mut events = calls.events();
while let Some(event) = events.next().await {
    if let CallEvent::Ringing(peer) = event {
        let call = calls.answer(peer).await?;
        let player = remote_broadcast(call.subscription()).play(PlayerConfig::default())?;
    }
}

Ringing a peer adds it to the audience of this node's side, so the side appears in that peer's route table, and that is the ring. Answering rings back and subscribes. Two peers that call each other at once end up in one call. Dropping a Call hangs up: the peer loses the side, and the session stays. calls.ringing() is the level-triggered set of peers that ring now. events() reports the edges, best-effort, and replays the current ringing set first. One side serves every call in turn, so a camera and a preview keep running between calls. The wire protocol is the one irl call and the Android demo already speak.

Rooms

On main, a room subscribed to every broadcast of every member and sent events, chat included. Now the membership is a watched RoomState, and the application subscribes to the broadcasts it wants:

let node = Node::new(endpoint.clone(), layout.config(&[Arc::new(rooms::publish_scope)]));
let rooms = Rooms::new(&node);
let router = rooms.mount(node.mount(Router::builder(endpoint))).spawn(); // moq ALPNs and gossip
let room = rooms.join(&ticket, Some("ada".into())).await?;
let cam: LocalBroadcast = room.publish("cam")?;
let members = room.state().get();
let subscription = room.subscribe(peer, "cam").await?;

Room broadcasts live at rooms/<topic>/<member>/<name>, and only members see them. Chat is not part of the library: irl room sends it on moq-room's chat track, which @moq/room also reads.

The node: routing and access control

On main, iroh-moq had no shared route table: each session had its own origin, and a broadcast came only from the session with its publisher. That prevented forgery, but a broadcast could not come through a relay. The node now follows moq main's model and keeps no routing logic of its own:

  • One route table. Direct sessions announce into it as Link::Peer with the peer's hop. Relay links announce as Link::Upstream with a higher cost. moq moves a subscription between a direct and a relay route of one stream without ending it, and RemoteBroadcast follows a publisher that restarts as a new stream.
  • No transit. Every link serves the table's local view, which hides routes learned from peers and relays. A node serves only its own publications.
  • Per-peer visibility through per-session authorization. Each session and relay link is authorized for the publications whose audience admits the peer, before its handshake and again whenever a publication, an audience, a grant or a claim changes. A peer taken out of an audience loses the path, and its reads there end with Unauthorized.
  • Grants decide what peers may put into the table. The default lets peers publish nothing. LiveLayout::config lets each peer publish only under <root>/<its id>/, so no peer can publish in the name of another, and pinned subscribes can tell peers apart by the routes' hops. Admission::Manual hands incoming sessions to Node::accept, for example after a token check.
  • Relays. attach_relay takes an iroh:// or https:// URL and redials with backoff. Everyone publications go to the relay, Peers publications never do. A relay is trusted with every path it forwards.

The relay

iroh-live-relay serves browsers through moq-relay and pulls broadcasts from iroh publishers into its cluster without copying them. Its pull node uses the cluster origin as its route table (NodeConfig::table) and a base grant of nothing. Each pull holds a claim_path guard for the ticket's path, so the publisher's session announces exactly that path into the cluster and nothing else. The pull node's NodeConfig::owner accepts only a peer's canonical live/<peer>/<name> or rooms/<topic>/<peer>/<name> path, so a ticket that names another peer's path is refused before anything is dialed. A browser reads the pull under the ticket string it asked for, which the relay parses from the raw URL path before percent-decoding.

Transport

moq main owns the transport interface (moq_net::transport::poll), and moq-tokio implements it on its own QUIC stack. moq-transport-iroh does the same over iroh's Connection and streams, with no web-transport crates. Priorities, datagrams, close and reset codes, metrics and wire counters are backed. send_group, probe and receive_window return None, since iroh has nothing to back them with. A send stream dropped before finish is reset, so the peer never takes a truncated stream for a complete one.

WebTransport over HTTP/3 on iroh is gone. Its only job was carrying the relay token in the URL. The token now travels in the MoQ SETUP, and for moq-lite-06, whose SETUP has no token field, also as ?jwt= in the setup path; moq_ext_access::token writes both and SessionRequest::token() reads them. A dial offers only the newest moq ALPN, because iroh's router sorts its ALPNs and TLS would otherwise settle on the oldest version.

Breaking changes

  • No compatibility with main's wire formats, room format or path layout, nor with peers that dial WebTransport over HTTP/3 on iroh. Broadcasts move from bare names to live/<id>/<name>, and calls from calls/<id> to live/<id>/call.
  • Tickets are BroadcastTicket { peer, path } in the node, written ilv1:.... LiveTicket, the iroh-live: form and the older EndpointAddr form no longer parse, so printed QR codes need re-issuing.
  • Removed: Live, LiveBuilder, CallOffer, Call, the facade's free config functions, Subscription::media and the per-track VideoTrack (use Player), MoqSession and IncomingSessionStream (use Session and Node::accept), RoomEvent and room chat, the Android Camera2 bridge (use VideoSource::push), and the moq-ext-media-android crate.
  • Renamed: Moq to Node, Live::remote_broadcast to media::remote_broadcast, and the relay's RelayConfig to iroh_live_relay::Config. The node's RelayConfig for attach_relay keeps its name.
  • Lifecycle: nothing owns the endpoint for you. Router::shutdown shuts down the node and every service mounted on the router, then closes the endpoint.
  • SessionRequest lost its HTTP/3 headers; SessionRequest::token() returns the SETUP token.
  • Config types are struct literals with Default; #[non_exhaustive] and the builders it forced are gone.
  • Features: iroh-live has no default features (main had capture, render and sound-server). full turns on media, capture, render, sound-server and record. media, rooms, file and record are new.
  • irl record --latency is now --max-age.
  • LICENSE-MIT held the Apache text since the first commit; it now holds the MIT license.

Known limits

  • One ALPN on dial. A future build that offers only a newer moq version will not connect to this one. The accepting side still takes every version.
  • A widened grant lands a moment later. A live session applies a claim asynchronously, so a read that starts right after claim_path on an existing session can still be refused. subscribe_ticket on a fresh session is not affected.
  • Calls. moq's announcements are a listing now, so a peer that publishes its side again without withdrawing it first does not ring anew, and a withdrawal and a new offer in quick succession can merge into no change. ringing() stays correct.
  • Pinned subscribes depend on the grants. A peer can declare another peer's hop on the wire, so Session::subscribe attributes a route correctly only while grants do not overlap. The layout's grants and the claim ownership rule make sure of that.
  • Grant size. A grant lists every visible publication, and moq refuses an AUTH_OK over 64 KiB, which a few hundred room paths reach.
  • HTTPS relay links are authorized just after moq-tokio's connect spawns its dial loop. Closing that gap needs a moq-tokio API change.
  • A microphone that fails no longer reopens on its own with moq main's shared audio capture. The audio slot stays Failed until the next set_audio.
  • The Android crate only builds for Android, so CI on Linux and macOS does not compile it. It needs cargo ndk -t arm64-v8a check -p iroh-live-android.
  • moq main is not released. A release cut from main will be one large breaking version, and we will move the git rev to it.

Testing

CI runs cargo check with default, all and no default features, plus the relay alone and the facade without media. It runs clippy with -D warnings, nextest and cargo fmt --check on Linux and macOS, the Playwright suite (browser to iroh, iroh to browser through a relay link, pull mode), and release builds for Android, Windows and aarch64.

At the head of this branch, every crate's test suite passes locally with all features, including the patchbay tests for adaptation on impaired links (cargo make test-patchbay). New tests cover ticket grants and claims (forged paths, narrowing on drop, set_grant precedence, limits, concurrent claims), the ticket format, the relay's pull ownership, and calls, including interop with a peer that speaks the raw path protocol as today's irl call does.

Suggested reading order

  1. moq-ext-access: the grant, audience and token types.
  2. moq-transport-iroh/src/transport.rs and stream.rs: the poll transport on iroh.
  3. iroh-live-node: node.rs, publish.rs, state.rs (authorization and the two grant layers), claim.rs, ticket.rs, session.rs, route.rs, then relay.rs.
  4. iroh-live/src/layout.rs and calls.rs.
  5. iroh-live-relay/src/pull.rs.
  6. iroh-live-rooms/src/room.rs.
  7. Media: moq-ext-adaptive, then moq-ext-player and moq-ext-publisher.

You can skim iroh-live-cli, the demos and docs/. Using iroh-live has complete programs for every case above.

🤖 Generated with Claude Code

Frando and others added 30 commits September 25, 2026 00:51
…point

`LiveBuilder::with_moq` panicked when the node ran on another endpoint
than the builder's. The doc now says which endpoint to use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The microphone publication and the MicrophoneConfig export were not
gated on capture, so the crate failed to compile on its own without
it. Workspace builds hid this through feature unification, but a
standalone build of iroh-live-relay or iroh-live-media-android, which
take the crate without default features, failed. The helpers only the
device paths use are gated with them, so every single feature builds
without warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Explanations of history and of the obvious go, and the rest says what
a caller needs in fewer words. The README matches the current API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comments that restated GL calls and the decorative banners go, the
crate docs list the renderer, and the letterbox comments no longer
swap pillarbox and letterbox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Six status updates in the video task each reset the rendition, the
decoder and the switch in their own way; PlayerStatus::clear_video
does it once. Latency validates itself instead of going through a free
function and a one-line PlayerConfig method.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The link monitor read QUIC path statistics for direct sessions and
moq-tokio's session statistics for relay links, in two monitors with
two reading types, and kept per-path history for a sliding one-second
goodput, a sliding two-second loss rate, a two-bucket windowed round
trip minimum, a count of distinct round trip readings, and counters
nothing read. moq-net's `Session::stats` gives both kinds of link the
same figures, the peer's delivery estimate included, so one monitor now
reads them and one sampler keeps fifteen seconds of readings: the
minimum over all of them, loss and goodput against the newest one two
seconds old.

`LinkSample` keeps what adaptation and the overlay read: round trip,
its minimum, loss, goodput, delivery, path generation, and whether the
path is relayed. The facade's signals count changes of the serving link
and its path instead of offsetting generations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
VideoSource::spawn and AudioSource::push assembled their inner state by
hand, and AudioSource had a second constructor only to pass a demand
counter in; both now take the source's own counter for the sender. The
microphone config's feature check and its resolve step no longer carry
the same error twice. The rpicam configs lose getters and a constructor
that only restated their fields inside the module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
iroh-moq carries MoQ for anything, and what the preset tunes (BBR3, so
moq-net's send-rate estimate tracks the link) is about MoQ, not media.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Probing, track creation and producer setup each wrapped the error,
reported it on the rendition and kept it as the last failure in their
own copy of the same five lines. The raw and pre-encoded paths also
kept their own copy of the frame, byte and rate counting, which is now
EncodeStats::record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Eight tests spelled out the same timeout-and-watch loop, two pushed
frames with the same loop, and six turned off hardware encoding with
the same struct update. Each is now one helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pacing was a struct around one rate meter with one method; it is a
function taking the meter. The pacing future uses n0-future's
BoxFuture. CatalogProducer only renamed moq-mux's catalog producer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A failed dial that a relay could stand in for awaited the relay route
inside the select branch that saw the failure. The branches now only
pick the outcome, and the one wait on the relay route follows them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sessions and relay links spawned their two origin drivers the same way,
and now call `Origins::run`. `Subscription::session` and
`Subscription::link` share one lookup of the serving link, `hop_for`
and the relayed first hop share the masking in `hop_from`, and the
transport's dial and accept wrap errors with one function.
`AudienceKind` converts from `Audience` with `From`, the `PeerSet`
alias, `Incoming::close` and `Moq::tasks` go, and a publication's
closed task is no longer an `Option` that is always set.

The origin tests use the harness in `tests/common` instead of a copy
of it, and test origins come from moq-tokio's `origin::spawn_config`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comments that told the history of a bug, carried review ids, or said
what the next line does are gone. The rest says what a caller needs in
shorter sentences, and keeps the measured numbers and platform quirks
behind the defaults. Four docs that no longer matched the code are
corrected: AudioSource::file does not check the codec before it
returns, the pattern generator's bands are cut in sixteenths, the
playout clock wakes a wait when a frame becomes due sooner, and the
crate docs no longer link capture-only items from ungated docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The gossip announcement and the public `RoomPeer` carried the same two
fields, with a hand-written conversion from one to the other. The
announcement now wraps a `RoomPeer` next to its `left` flag, and the
actor keeps `RoomPeer`s, so the published state is a clone. This
changes the announcement's wire format, which has no earlier release to
meet. The one-use `KvEntry` alias, whose doc described a type that no
longer exists, is inlined.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`IrohSessions::alpns` rebuilt `iroh_moq::alpns()` by hand, the URL name
extraction repeated `requested_name`, and a pull's identity-checked
removal existed twice. `RelayServer`, a struct that held only the data
directory, is one `secret_key` function, and the HTTP handlers take the
certificates as their state directly. The pull module's ticket tests
repeated `iroh-live`'s own and go. The relay no longer depends on
`web-transport-iroh` or `http`, nor its tests on `rand`, and test
origins come from moq-tokio's `origin::spawn`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every doc comment, inline comment and the README are cut to what a
caller needs and worded plainly. The crate docs list the main types and
keep cancellation safety as a compact table. Gone: history notes, notes
on versioning of integration points, field docs that restate the field,
and a node test comment about the bare-name alias cut earlier. Every doc
summary is one line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The crate docs, constants and room methods say what a caller needs in
plain words, and the test docs fit their summaries on one line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The crate docs, comments, test docs and README are shorter and plain.
The `--bind` help no longer claims it binds iroh, which binds its own
socket. Gone: a pull comment about the old path layout, a test doc
about a Playwright failure, and box-drawing characters in test
comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`Moq`, `MoqConfig`, `Session`, `Subscription`, `Incoming` and the
relay's `PullState` had hand-written `Debug` impls; derive_more's
attributes say the same in a line or two each. `Shared::hop`, which only
the old `Moq` impl read, goes: the route table knows its hop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Stage 2 of v2-redux (plans/v2-redux). Six conflicts, resolved as follows:

- iroh-live/Cargo.toml and lib.rs docs: the rooms crate is iroh-live-rooms
  (transport side); the relay-links feature is gone (transport side) and so is
  test-util (media side).
- iroh-live/src/network.rs: the transport side's simplified signal closure;
  it only fills public NetworkSample fields, so it fits the media side's type.
- The LocalBroadcast doc in iroh-live-media, docs/architecture/publish.md and
  docs/guide/moq.md: the media side's wording, since the catalog metadata
  section is cut, with the rooms crate's new name.

Verified: cargo make check-all, the full suite (321 passed, 4 skipped), and
Android clippy with -D warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CLI's `LocalPreview` was `iroh_live_egui::VideoView` with an
optional stream: a `FrameView`, a repaint task per stream and
`set_frames`. `VideoView::new` and `set_frames` take
`impl Into<Option<VideoFrames>>`, so a view can wait without a stream,
and the CLI's copy and its second `wake_on_frame` go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CLI's `record` and `watch` each carried the same `check_rendition`,
and the media crate made the same check in `Recording::start` and the
selector. `Catalog::video_rendition` serves all four, and
`Error::UnknownRendition` now lists the names the catalog has, which is
what the CLI printed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CLI's recorder and the egui overlay each had a `format_bytes`, in
different units. `bytesize` is already in the tree through moq-relay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`LatencyArg::latency` returns the player's `Latency` instead of two
durations the caller put back together, and its three constant tests
become one. `OpenedVideo` derives `Debug`; `audio_encoding` loses a
parameter it ignored; the Android bridge uses n0-future's
`AbortOnDropHandle` instead of its own; the Pi's `print_stats` is gated
on the feature that calls it instead of allowing dead code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-video

moq-video publishes the names its backends answer to as `encode::NAMES`
and `decode::NAMES`. The CLI spelled them out again as two enums, with a
test to catch the copies drifting. One `Backend` type now parses a
strategy or one of upstream's names, so a new backend upstream is a
choice here without a change. `DecoderArg::from_kind` had no caller.
`WatchArgs` is boxed like the other commands' arguments, since a
`Backend` is larger than the enum it replaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`irl call` and the Android demo each carried the same `Call`: the `call`
broadcast name, dialing a peer, and subscribing to its side over the
session. The two have to agree for a phone to call a desktop, so the
convention now lives in `iroh_live::{CALL, Call}` and both use it. A
test in the e2e suite covers the dial.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five callers read `IROH_SECRET` by hand (the CLI, both demos, both
examples), and the relay and `irl run` each loaded or created a key file.
`EndpointOptions::from_env` and `secret_key_file` in iroh-moq replace
them. `irl run` now stores its keys like the relay does, as 32 bytes in a
file only its user can read, where it wrote hex with default permissions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three callers built a ticket by hand instead of calling `Live::ticket`.
Three set a Pi camera's keyframe interval to its frame rate, which
`RpicamConfig::new` already does. The balanced latency preset restated
`Latency::default`. Waiting for the first catalog goes through
n0-watcher's `initialized` instead of a hand-written loop, in the CLI
and the Pi demo. `irl devices` and the scan screen share one check for
`rpicam-vid` on `PATH`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`ScanView`, `FileImport` and `TicketQr` had hand-written `Debug` impls
that printed less than a derive does, or skipped fields a derive can
skip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frando and others added 7 commits November 4, 2026 18:27
…track

moq-net ends a track with `Error::Dropped` when its last producer goes
away without a finish or an abort. That is a publisher leaving, most
often one in the same process whose task stopped. The player took it
for a broken track: it marked the video slot `Failed` and backed off the
rendition, so a route that carried the same rendition name could not
take over at once.

`CleanClose` now counts `Dropped` with the cancel codes. The track ends
like a clean end, and the player waits for the next route or catalog.
`a_player_keeps_playing_when_its_route_goes` aborts the task that holds
the producers, so this is the likely cause of its macOS flake. Over the
wire a dropped producer arrives as an internal error, which stays a
failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…path

A URL carries the requested name percent-encoded. A name with a space or
a non-ASCII character reached the ticket parser and the mount as
`my%20cam`, so the relay pulled and mounted the wrong path and a browser
viewer never got the broadcast. `percent-encoding` was already in the
tree through `url`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The module docs said dropping `run`'s future stops everything it
started. The tasks that dial and hold a pull are detached spawns and run
until the pull retires. `resolve_again` also does not look at claims, so
a pull nobody reads redials for up to about 90 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without `--locked`, `cargo install` resolves the dependencies anew and
ignores `Cargo.lock`. With moq pinned by git rev, that can build a tree
nobody has tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A viewer must keep playing when the publisher switches its video
source, both when an application feeds another device into one pushed
source and when it calls `set_video` with a new source. The tests run
in-process and across two iroh nodes, with a camera-like source whose
timestamps start at zero and a test pattern on the process clock.

Two of them fail today. A pushed source fed from the camera after the
pattern fails the rendition with a timestamp rewind. A `set_video`
replacement shows the new source only after about 2.2 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An application that pushes another device into the same source breaks
its timeline: a camera stamps from zero after a test pattern on the
process clock. The publisher anchored the timeline once, so the camera
frames went back and moq-mux refused them as a rewind. That failed the
rendition, and nothing brought it back. `Timeline` now anchors a
timeline that goes back, or jumps far past the broadcast clock, at the
clock again, and the encoder cuts a group there. Audio uses the same
mapping.

A `set_video` replacement finished its tracks and created new ones of
the same names. The player saw the old track end after the catalog
changed and waited two seconds before it asked again. The replaced task
now hands each rendition's track to its successor, so a viewer keeps
its subscription and sees the new source from its first keyframe. A
size change that needs another decoder still moves the rendition to a
new track name, as moq-mux does, so the encoder follows the demand of
the renamed track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Frando
Frando marked this pull request as ready for review November 4, 2026 22:38
Frando and others added 22 commits November 5, 2026 15:20
…source frame

The broadcast moves every source timestamp onto its own clock, so a
subscriber that reports which frame it worked on cannot name the frame
the publisher knows. An application that matched results to frames
had to paint a frame id into the pixels to get around this.

The video timeline now records the source timestamp of the last 1024
frames it published, and `LocalBroadcast::video_source_timestamp`
returns it for a published timestamp. A subscriber echoes the decoded
frame's timestamp, and the publisher looks up its own frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A player plays audio to a device or decodes it into nothing, so an
application that analyses a broadcast's audio had to open its own
moq-audio decoder and follow the catalog itself.

`Player::audio_frames` returns an `AudioFrames` stream of the frames
the audio task decodes: interleaved f32 samples with their timestamp
and format. It runs beside the `AudioTarget`, so `AudioTarget::null`
decodes without playing. The stream follows reopened tracks and new
routes, and a reader more than a second behind skips the oldest
frames instead of holding the decoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt or per dial

iroh-live did not name its congestion controllers, so an application
that compared them depended on noq-proto and set the transport config
after `EndpointOptions::apply` installed BBR3. Switching controllers
also meant binding a new endpoint.

`Congestion` names BBR3, CUBIC and NewReno and builds the transport
config for one. `EndpointOptions::congestion` sets it for the whole
endpoint, BBR3 by default as before. iroh 1.2 takes a transport config
per connection, so `ConnectOptions::congestion` dials one session with
another controller through the new `dial_with`, and switching is a
close and a new dial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An application that publishes a control value or a stream of results
had to write those tracks by hand: one frame per group, a walk over
groups and frames to read them, and the value repeated every second so
a late reader converged.

moq-json's snapshot track already does this. Every update supersedes
the last, `Consumer::latest` skips to the newest value, and the track
keeps its newest group, so a late reader gets the current value at
once. iroh-live now re-exports it as `iroh_live::json`, with an example
of a value written on one node and read on another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view takes frames from its stream and only hands back an image, so
an application that labels the picture with its timestamp or size, or
matches it to a result, had to keep a second subscription to the same
frames and guess which one was on screen.

`VideoView::last_frame` returns the frame `render` last took. It stays
across `set_frames`, as the picture on screen does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The decoder reads the track and decodes it in one call, so the time
between a frame landing and its picture leaving the decoder was all
one number. An application that measures how long a picture waited
could not tell the network from the decoder.

moq-net stamps each frame when it lands, but the container layer
drops the stamp before the decoder sees it. Each decoder now gets a
watcher on the same track, with the same subscription so moq-net
serves both from one upstream request. It decodes only the container
and keeps the arrival of the last 256 frames by timestamp.

`FrameTiming` gains `arrived`, set for pictures, and
`Player::video_timing(pts)` returns the timing of one presented
picture, so a frame from `Player::video` can be looked up by its
timestamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er the player drops

A handle keeps up to a second of decoded frames. After the player drops
it still returns those, then ends; the test expected it to end at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to moq-ext-access

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Grants now have a base per link and ticket paths per peer. A claim widens
every session with the peer and narrows again when the last claim drops.
Also adds Node::{id, grant_for, closed}, NodeConfig::owner, and richer
NotGranted, NotOwner and TooManyGrants errors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…dd LiveLayout

BroadcastTicket moves into iroh-live-node as ilv1:<id>/<path> with the peer's
own segment written as ~. Node::subscribe_ticket claims the path for its
publisher while the subscription lives, so a default-config viewer works.
The facade gains layout::LiveLayout and media::remote_broadcast, and the old
free functions delegate to them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…path

The pull node starts from a base grant of nothing and each pull holds a
claim_path guard, checked by an owner function: only the canonical live
and rooms paths of the ticket's peer are pulled. Tickets are read from the
raw URL path before percent decoding.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The CLI call window now runs on iroh_live::calls, and the room command
mounts rooms next to the node on its own router.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e config functions

Callers hold a Node and name a LiveLayout. The Android demo and the
CLI place calls through iroh_live::calls, tickets use the ilv1 scheme, and
the relay's RelayConfig is now iroh_live_relay::Config. The pi-zero e-paper
QR uses error correction level L so an ilv1 ticket keeps its 33 modules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Hops are plain ids and publishing goes through origin::Route, announce
events are a Present/Absent listing, follow reports a path going offline,
encoders take a join time instead of a GOP, and moq-net, moq-json and
moq-room enable their async features. A live session now applies a
widened ticket grant a moment after the claim, which the claim_path docs
and the grants test say.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Documents undocumented items with one-line summaries, renames vague
tests, moves function-local imports to the top, uses LinkId for link ids
inside iroh-live-node, tightens READMEs and DEVELOPMENT.md, and drops
stale statements.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- iroh-live-node: hop ids are a crate-private Hop type instead of raw u64s.
- pi-zero: every unsafe block has a SAFETY comment, the GL helpers are
  unsafe fns with a documented contract, and dead helpers are gone. Each
  texture keeps its own allocated size: the U and V planes shared one, so
  the first V upload hit a texture that was never allocated, and so did a
  switch between RGBA and I420. The fb-demo renders on its own thread, as
  run_drm does, since its EGL context is current on one thread only.
  The console ioctls are named constants.
- moq-ext-sources: LocalTask::released exists only with the capture
  feature, its only user.
- calls: ring changes the audience under the state lock, as a hang-up
  does, so the two cannot lose each other's update. The docs say a hung-up
  peer rings again only after it withdraws and offers its side again.
- A line-by-line review of the facade, rooms, relay, player, media-core,
  adaptive, publisher, sources and egui crates: stale docs after the moq
  update (GOP, V4L2 timeouts, announcements), redundant comments, shared
  helpers in tests and in moq-ext-adaptive, and #[must_use] where dropping
  a value is a bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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