Skip to content

feat(tokio): WebRTC data channel transport over str0m - #5702

Merged
kixelated merged 23 commits into
mainfrom
quest/m1/p2p/webrtc
Oct 17, 2026
Merged

kixelated merged 23 commits into
mainfrom
quest/m1/p2p/webrtc

Conversation

@kixelated

@kixelated kixelated commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Finishes quest/m2/p2p/webrtc: native peers can now hold a moq-net session with a browser @moq/p2p peer over a WebRTC data channel.

  • moq-tokio webrtc feature (off by default, on in moq-cli): str0m with a full ICE agent.
    • Host candidates on every non-loopback, non-link-local interface, all on one dual-stack socket per session.
    • A STUN Binding client (RFC 8489 retransmits) per configured stun: server; mapped addresses become server-reflexive candidates and trickle to the caller.
    • Remote .local candidates are resolved with mdns-sd before they reach str0m.
    • qmux-01 runs over one reliable ordered channel labelled moq: one record per message, max_record_size 16 KiB clamped to the peer's max-message-size, no datagrams. This matches js/p2p.
    • The driver never stops reading the socket for a slow session. Unread records are bounded by qmux's advertised max_data plus 256 KiB of slack; a peer that ignores flow control past that has the channel closed with a flow control error. The answerer ignores channels not labelled moq.
  • Signaling stays with the caller, through plain async methods on two typed handles. No callbacks.
    • webrtc::Offerer::new returns the offer; accept applies the answer. The offerer opens the channel and connects with Client::connect_webrtc(&Offerer).
    • webrtc::Answerer::new takes the offer and returns the answer. It adopts the channel through Listener::accept_webrtc(&Answerer), which returns a normal Request so auth and admission work as usual.
    • Both constructors take the MoQ ALPN the peers agreed through signaling, as js/p2p's connect and accept do. The offerer's channel carries it, and accept_webrtc refuses a channel carrying any other, even one the server speaks.
    • Both have candidate (trickled local candidates) and add_candidate (remote ones).
  • New moq-ice crate: the ICE agent shared with moq-rtc (its old run loop, socket reader, and candidate pick), plus the STUN client and the per-peer wire counters from feat(rtc)!: expose per-peer UDP wire sources #5632. moq-tokio uses it privately and moq-rtc directly, so str0m types stay out of moq-tokio's API. moq-rtc's WHIP/WHEP sessions run on it, and its net.rs and private reader are gone.
    • Agent::new(rtc, socket) takes a moq_ice::Socket: the UDP socket it sends from, the host candidates it advertises, and the datagrams routed to it (from moq_ice::reader for a 1:1 socket, or moq-rtc's server mux).
    • The agent owns str0m's single-mutation invariant: Agent::rtc is read only, and Agent::rtc_mut returns an RtcMut guard that derefs to &mut Rtc and drains to a timeout when dropped. The changes that build a peer (host candidates, then the channel or the answer) happen before the agent exists, as in str0m's own examples; construction_queues_no_output pins that none of them queues output, so draining between them would change nothing.
    • Transmits the socket can't take yet wait in an outbox bounded at 256 datagrams; past it they drop, as a full socket buffer would.
    • The 30 s establishment backstop counts from the later of two points: gathering's end (every STUN server answered or gave up), and, for an offerer built with awaiting_answer, its first rtc_mut (applying the answer). Signaling time and an unreachable STUN server's ~39.5 s retransmit schedule are not charged against ICE, and since gathering always ends, nothing can wait on it forever.
  • moq-relay's auth::request_for maps a WebRTC request to moq_auth::Transport::WebRtc instead of panicking, so accept_webrtc requests go through relay admission.
  • Server::accept on a build without a listener transport (e.g. webrtc alone) now returns None instead of panicking. sh/rs/tokio-features.sh --all adds webrtc.
  • doc/concept/transport.md covers the native side, and doc/lib/rs lists moq-ice. The finished quest (quest/m2/p2p/webrtc.md) is deleted, and its links in the m2 p2p epic, cli.md, and native-quic.md are removed; native-quic.md now points at moq-ice for the shared gathering.

Public API

New crate moq-ice (0.1.0): Agent (new(rtc, socket), with_stun, awaiting_answer, rtc, rtc_mut, wire, next), RtcMut, Event, Error, Socket (udp, locals, inbound), Inbound, Wire, Stats, Packet, INBOX, reader, canonical. It exposes str0m 0.24 types (Rtc, Candidate, Event) by design.

New in moq-tokio (feature webrtc), with no str0m or moq-ice types:

  • webrtc::{Offerer, Answerer, Config, Stun, Offer, Answer, Candidate, Error}
  • Client::connect_webrtc, Listener::accept_webrtc, Transport::WebRtc, Error::WebRtc

Changed in moq-auth: only the doc of Transport::WebRtc, which now covers data channel sessions as well as WHIP/WHEP.

Changed in moq-tokio: Error::Stopped now covers a listener too, and its message is "stopped locally" instead of "connection stopped".

Breaking in moq-rtc:

  • Error::RtcInput is removed. A datagram str0m can't parse is now dropped with a debug log instead of ending the session, because a STUN-exposed socket receives noise.
  • wire::Stats is now a re-export of moq_ice::Stats (same fields).

Wire: nothing new. The data channel framing is the one @moq/p2p already speaks (qmux-01.<alpn>).

Checks

  • cargo clippy -D warnings: moq-tokio with --features webrtc alone and with --all-features --all-targets; moq-ice, moq-rtc, moq-tokio (+webrtc), and moq-cli with --all-targets. cargo fmt --check and quest check pass.
  • cargo test -p moq-tokio --features webrtc --lib webrtc (14), cargo test -p moq-ice (6), cargo test -p moq-rtc (50).
  • After merging main (Oct 17): clippy on moq-ice, moq-rtc, moq-tokio (+webrtc), and moq-cli with --all-targets, the three test suites, and just check.
  • Regression tests: unread_records_do_not_stall_the_writer (fails with the old freeze: the writer stalls once four records sit unread), ignoring_flow_control_closes_the_channel, queue_closes_past_its_budget, queue_ends_on_empty_or_error_records, outbox_is_bounded, awaiting_answer_holds_the_deadline, gathering_holds_the_deadline, every_transport_has_an_auth_name (moq-relay; panics without the WebRTC arm), unsignaled_protocol_is_refused (fails without the exact-match check), construction_queues_no_output.
  • Not run: browser interop, which belongs to the p2p harness quest.

Decisions

Decisions 1 to 9 were settled by the maintainer through /quest-iterate. ✅ marks the chosen option.

  1. Which side is the MoQ client?
    • ✅ The offerer. str0m can't add an application m-line without a channel, so the offerer creates it, and js/p2p already has the channel creator connect.
    • The answerer, which would need a negotiated (pre-agreed id) channel.
  2. How are the two roles typed?
    • ✅ Two types, Offerer and Answerer, sharing a private handle, so calling connect_webrtc with an answerer doesn't compile.
    • One Peer with a runtime Error::Role.
  3. Where does the shared ICE agent live?
    • ✅ A new moq-ice crate holding the agent, STUN client, and wire counters. moq-tokio uses it privately and moq-rtc directly; Agent::mutate(closure) becomes a guard that derefs to &mut Rtc and drains on drop.
    • #[doc(hidden)] pub mod ice in moq-tokio.
    • Keep the public moq_tokio::webrtc::ice.
    • Move the data channel transport into moq-rtc.
  4. How are .local candidates resolved?
    • ✅ One process-wide lazy mdns-sd daemon.
    • A daemon per peer.
  5. What happens when a STUN host does not resolve?
    • ✅ Warn and skip it, as a browser does.
    • Fail the peer.
  6. How is a slow data channel reader bounded?
    • ✅ Keep driving the agent, and close the channel when unread records exceed qmux's advertised receive window (max_data) plus slack for control frames. Freezing stalls our own sends (sctp only releases buffer on a SACK) and ICE consent.
    • Freeze the agent while a four-record queue is full.
    • Close on a small byte budget, as js/p2p does with 64 KiB.
  7. What does accept_webrtc return on a closing listener?
    • ✅ Error::Stopped, with its doc widened to cover a listener.
    • Error::NoBackend.
    • A new Error::Closing variant.
  8. Agent::new(rtc, socket, locals, inbound) takes four arguments. What changes?
    • ✅ Bundle the I/O pieces into one struct named by role: moq_ice::Socket { udp, locals, inbound }, so it's Agent::new(rtc, socket). The same struct drops moq-rtc's Session::ingest / egress from five arguments to three, and both crates' client bind helpers return it.
    • Keep four arguments.
  9. Rename rtc_mut() / RtcMut or awaiting_answer()?
    • ✅ Keep all three names.
    • Rename them.
  10. When does an answerer's 30 s ICE deadline start? (Codex on 4b96283)
    • ✅ Once gathering ends, for both roles; an offerer also waits for its answer. Gathering is bounded by the STUN schedule, so a peer that never shows up still times out, at worst ~70 s after creation with an unreachable STUN server.
    • At creation, as before. A caller that sends its answer only after the last candidate fails whenever a STUN server is unreachable.
    • At the answerer's first remote input. A non-trickle offer brings none, so accept_webrtc would wait forever.
  11. How does the answerer learn the agreed ALPN? (Codex on 39e9ab3)
    • ✅ Answerer::new(config, offer, protocol), mirroring Offerer::new(config, protocol) and js/p2p's Transport.accept({ protocol }); accept_webrtc refuses a channel carrying any other ALPN.
    • Keep Answerer::new(config, offer) and accept any ALPN the server speaks. The peer could then run a different version than signaling agreed.

Decisions 10 and 11 were picked by an unattended /quest-iterate agent on its recommendation.

Every Codex finding through 39e9ab3 is fixed or answered inline. Declined: draining between the construction-time mutations, since none queues output (pinned by construction_queues_no_output) and str0m's examples build a peer the same way.

Suggested follow-ups

  • Native pairs over ICE and QUIC (quest/m2/p2p/native-quic.md): move host gathering and mDNS resolution into moq-ice when that quest needs them. Its plan says so.
  • An mDNS resolution test against a real responder. It's skipped here because it depends on multicast.
  • Expose a peer's selected candidate pair and wire stats on Offerer / Answerer, for p2p route policy.
  • Let the moq-rtc WHIP/WHEP client gather candidates the same way, replacing its static ice_candidates.
  • js/p2p closes the channel when its 64 KiB receive queue overflows; align it with decision 6 (max_data plus slack).
  • Resolve STUN hosts on the driver task, so Offerer::new / Answerer::new don't wait on DNS before returning the description.
  • moq-ice reads std::time::Instant, so the transport tests run on wall-clock time over loopback. Moving it to moq-clock would let them run under simulated time.
  • Offerer::candidate / Answerer::candidate return None both when gathering finishes and when the connection fails; a caller can't tell them apart.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 6 commits October 15, 2026 23:47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a `webrtc` feature to moq-tokio: a full ICE agent (host candidates on
every interface, a dual-stack socket, STUN server-reflexive gathering, mDNS
resolution of `.local` candidates) and qmux-01 over one ordered data channel,
matching @moq/p2p. Signaling stays with the caller through async methods on
`webrtc::Peer`; `Client::connect_webrtc` and `Listener::accept_webrtc` run
the MoQ session over it.

moq-rtc now drives its sessions with the shared `webrtc::ice::Agent` instead
of its own run loop, socket reader, and candidate pick. moq-cli enables the
feature by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve against the moq-quic switch: the `webrtc` feature joins the `quic`
defaults, and the server's pending-handshake gates name `quic` instead of
`noq`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A runtime Error::Role guarded calls that only one side may make. Each role
now has its own type, so connect_webrtc takes an Offerer and accept_webrtc
an Answerer, and the wrong pairing does not compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review October 16, 2026 19:17
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 23 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 05740537-e672-4510-b216-9d989b8d7ca4
📥 Commits

Reviewing files that changed from the base of the PR and between b7d7269 and fb5c4bf.

⛔ Files ignored due to path filters: 1
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing: 39
  • .github/workflows/ts.yml
  • Cargo.toml
  • doc/concept/transport.md
  • doc/lib/rs/index.md
  • quest/m2/p2p/README.md
  • quest/m2/p2p/cli.md
  • quest/m2/p2p/native-quic.md
  • quest/m2/p2p/webrtc.md
  • rs/moq-auth/src/request.rs
  • rs/moq-cli/Cargo.toml
  • rs/moq-ice/CHANGELOG.md
  • rs/moq-ice/Cargo.toml
  • rs/moq-ice/README.md
  • rs/moq-ice/src/agent.rs
  • rs/moq-ice/src/lib.rs
  • rs/moq-ice/src/wire.rs
  • rs/moq-relay/Cargo.toml
  • rs/moq-relay/src/auth.rs
  • rs/moq-rtc/Cargo.toml
  • rs/moq-rtc/src/client/whep.rs
  • rs/moq-rtc/src/client/whip.rs
  • rs/moq-rtc/src/error.rs
  • rs/moq-rtc/src/lib.rs
  • rs/moq-rtc/src/net.rs
  • rs/moq-rtc/src/server/mux.rs
  • rs/moq-rtc/src/server/whep.rs
  • rs/moq-rtc/src/server/whip.rs
  • rs/moq-rtc/src/session.rs
  • rs/moq-rtc/src/wire.rs
  • rs/moq-tokio/Cargo.toml
  • rs/moq-tokio/src/client.rs
  • rs/moq-tokio/src/error.rs
  • rs/moq-tokio/src/lib.rs
  • rs/moq-tokio/src/server.rs
  • rs/moq-tokio/src/transport.rs
  • rs/moq-tokio/src/webrtc.rs
  • rs/moq-tokio/src/webrtc/channel.rs
  • rs/moq-tokio/src/webrtc/tests.rs
  • sh/rs/tokio-features.sh
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T04:35:52.619041Z 313b214 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ace5625315

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-tokio/src/webrtc/ice.rs Outdated
}
self.connected |= state.is_connected();
}
return Ok(Event::Rtc(event));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drain str0m output before permitting another mutation

When egress writes or trickled candidates are queued, returning on the first Output::Event lets the consumer loop mutate the Rtc again before poll_output reaches Output::Timeout; for example, moq-rtc's biased write branch can dispatch several writes consecutively, and the WebRTC driver can process consecutive candidate commands. str0m 0.24.1 requires every mutation to be followed by a complete output drain, so these normal bursts can leave ICE, media, or SCTP state inconsistent. Keep ownership of the drain cycle inside the agent before allowing another external mutation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, and it regressed moq-rtc too: the old loop only took a write after draining to a timeout. Fixed in the latest push. ice::Agent now owns the drain cycle: rtc() returns &Rtc, every change goes through Agent::mutate, which drains to Output::Timeout and queues events, and next drains after each datagram or timeout.

(Written by Claude Opus 5.5)

Comment thread rs/moq-tokio/src/webrtc.rs Outdated
let protocol = config.protocol.clone();
channel.set_buffered_amount_low_threshold(SEND_WINDOW / 2);

let (reads, rx) = mpsc::unbounded_channel();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound the incoming data-channel queue

When a peer sends records faster than qmux consumes them, every ChannelData event is moved into this unbounded channel while the driver continues draining str0m, so an untrusted peer can grow process memory without limit by ignoring qmux flow control. The browser counterpart bounds queued bytes to SEND_WINDOW and closes on overflow; this path likewise needs a bounded byte queue or must close the channel when its receive budget is exhausted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed: qmux's reader can park on accept backpressure while the peer keeps sending control frames. Fixed in the latest push with backpressure instead of a close-on-overflow cap. The read queue holds four records (the send window), and while it is full the driver stops pulling events off the socket, so SCTP's congestion control slows the peer the way TCP does under WebSocket. A new 1 MiB transfer test covers it.

(Written by Claude Opus 5.5)

Comment thread rs/moq-tokio/src/webrtc.rs Outdated
Comment on lines +690 to +692
let config = channel.config().ok_or(Error::Unreliable)?;
if !config.ordered || config.reliability != Reliability::Reliable {
return Err(Error::Unreliable);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore unrelated channels before claiming the session slot

If the peer connection opens any other data channel before the moq channel, open adopts it as the one session channel because this validation checks reliability but never config.label. A channel with another protocol then fails admission, while the real moq channel is subsequently closed as an extra; a reliable channel sharing the MoQ protocol is incorrectly consumed outright. Filter or close channels whose label is not LABEL without setting self.channel, so the driver can still wait for the intended channel.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in the latest push: the answerer closes any channel not labelled moq without claiming the session slot, so the real one can still arrive. The offerer already matched on its own channel id.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 16, 2026 12:35
…eads

str0m requires a full poll_output drain after each mutation of an Rtc. The
ICE agent returned on the first event, so a caller could mutate again
mid-drain (an egress write burst in moq-rtc, or back-to-back candidates).
The agent now drains on every change, hands out only a shared Rtc, and
routes mutations through Agent::mutate.

The data channel read queue was unbounded, so a peer could grow memory
while qmux's reader waited on the application. It now holds four records,
and a full queue stops the driver reading the socket, as TCP would.

The answerer now ignores channels not labelled moq instead of taking the
first one to open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	quest/m2/p2p/README.md
#	quest/m2/p2p/cli.md
#	quest/m2/p2p/native-quic.md
#	quest/m2/p2p/webrtc.md
@kixelated
kixelated marked this pull request as draft October 16, 2026 23:27

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0a315c93f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-ice/src/agent.rs Outdated
let dst = to_family(dst, self.socket_v6);
match self.socket.try_send_to(data, dst) {
Ok(len) => self.inbound.wire.sent(len),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => self.outbox.push_back((data.to_vec(), dst)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound pending UDP transmits

When the UDP send buffer returns WouldBlock, every str0m datagram is appended to this unbounded queue. In the checked moq-rtc egress loop, the biased write branch can remain continuously ready and repeatedly call Agent::mutate, while only Agent::next drains outbox; under sustained socket backpressure, media packets can therefore accumulate without limit and exhaust process memory. Preserve backpressure or impose a bounded drop policy instead of queueing every transmit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in a244d57: the outbox is bounded at the inbound queue's size (256 datagrams), and past it a datagram is dropped as a full socket buffer would, which ICE, SCTP, and RTP already recover from. The outbox stays for the transient case where tokio hasn't seen the socket writable yet. outbox_is_bounded covers it.

(Written by Claude Opus 5.5)

Comment thread rs/moq-ice/src/agent.rs Outdated
failed: None,
timeout: now,
outbox: VecDeque::new(),
started: now,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Start the ICE deadline after applying the remote description

For an Offerer, Agent::new runs before the caller has sent the offer or applied an answer, so starting the 30-second establishment clock here charges arbitrary application signaling time against ICE. It also expires before an unreachable STUN server's configured retry schedule completes, meaning a caller collecting candidates until candidate() returns None before signaling can have the driver time out, mistake the dropped candidate sender for completed gathering, and then receive Closed from accept. Arm this deadline only once establishment can actually begin.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in a244d57: Agent::awaiting_answer holds the establishment deadline until the first external Agent::rtc_mut, and the offerer's agent uses it, so the clock starts when the answer (or a trickled remote candidate) applies. The agent's own timeouts and STUN replies don't start it, so gathering can run its full RFC 8489 schedule before signaling. awaiting_answer_holds_the_deadline covers it.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 16, 2026 16:45
The agent, its STUN client, and the wire counters move out of a public
`moq_tokio::webrtc::ice` into a new `moq-ice` crate, which `moq-tokio`
uses privately and `moq-rtc` uses directly, so str0m types stay out of
`moq-tokio`'s API. `Agent::mutate(closure)` becomes `Agent::rtc_mut()`,
a guard that derefs to `&mut Rtc` and drains when dropped.

A slow data channel reader no longer freezes the agent, which stalled
our own sends and ICE consent. The driver keeps reading and closes the
channel once unread records pass qmux's `max_data` plus slack.

`accept_webrtc` on a closing listener returns `Error::Stopped`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A socket that stays full while changes keep coming no longer grows the
outbox without limit: past the inbound queue's size, datagrams drop as a
full socket buffer would. An offerer's agent holds its establishment
deadline until the answer applies, so signaling time is not charged
against ICE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review October 16, 2026 23:50
`moq_ice::Agent::new` took four arguments. The UDP socket, its advertised
host candidates, and the datagrams routed to the agent now travel together
as `moq_ice::Socket`, so `Agent::new(rtc, socket)` and moq-rtc's
`Session::ingest` / `Session::egress` take one value instead of three.
The client `bind_udp` helpers in moq-tokio and moq-rtc return it directly.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6eb1649a80

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-tokio/src/webrtc/channel.rs Outdated
Comment on lines +83 to +84
let len = record.as_ref().map_or(0, |data| data.len() as u64);
if self.unread.fetch_add(len, Ordering::Relaxed) + len > self.budget {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Charge every queued record against the receive budget

The latest byte-budget fix still assigns a cost of zero to empty binary records and all error records, so a peer can flood zero-length SCTP messages while the session is absent or slow and enqueue them indefinitely in the unbounded channel without ever crossing budget. Each message still allocates queue/Bytes bookkeeping, preserving the remote memory-exhaustion path; enforce a per-record charge or a separate record-count bound.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in c0111e2: Inbox::push now treats an empty binary record as a protocol violation (a qmux record always carries its type, so empty is malformed), and any error record ends the session's reads and closes the channel. Every queued record therefore costs at least one byte, so the byte budget also bounds the record count. queue_ends_on_empty_or_error_records covers it.

(Written by Claude Opus 5.5)

Comment thread rs/moq-tokio/src/webrtc.rs Outdated

let locals: Vec<SocketAddr> = hosts
.iter()
.filter(|ip| local.is_ipv6() || ip.is_ipv4())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify the IPv6 socket is actually dual-stack

On systems where binding [::] succeeds but clearing IPV6_V6ONLY does not, this condition treats the socket as dual-stack solely because its local address is IPv6 and advertises IPv4 candidates. Agent then maps IPv4 destinations onto that v6-only socket, every send fails, and an IPv4-only peer cannot connect even though the fallback IPv4 bind was never attempted. Use the existing udp_is_dual_stack check to decide whether IPv4 candidates are reachable or reopen an IPv4 socket.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in c0111e2: bind reads back udp_is_dual_stack and only offers IPv4 host candidates when the socket is IPv4 or actually dual-stack. A v6-only socket with only IPv4 hosts now fails with NoHost instead of advertising candidates it can't reach.

(Written by Claude Opus 5.5)

kixelated and others added 3 commits October 16, 2026 17:25
# Conflicts:
#	rs/moq-tokio/Cargo.toml
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An empty or error record cost nothing against the inbound byte budget, so a
peer could queue them without bound. Empty records are malformed qmux, and
both now end the session's reads and close the channel.

A [::] socket that stayed v6-only no longer advertises IPv4 host candidates.

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

Copy link
Copy Markdown
Collaborator Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0111e2fd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rs/moq-tokio/src/webrtc.rs Outdated
Some((host, port)) => (host, Some(port)),
None => (rest, None),
};
if host.is_empty() || host.contains(['/', '?', '@', '[', ']']) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject malformed STUN host characters

When a caller parses a URL such as stun:server.example#fragment or one containing whitespace, this validation accepts the malformed host even though the documented format is stun:host[:port]. The subsequent lookup in resolve_stun only warns and skips the server, silently leaving the peer with host candidates; connections that require a server-reflexive candidate then fail despite the configuration having parsed successfully. Reject fragments, whitespace, and control characters during Stun::from_str.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 4b96283: a bracketless host must now be ASCII letters, digits, -, ., or _, so fragments, whitespace, and control characters fail in Stun::from_str. stun_urls covers the three cases.

(Written by Claude Opus 5.5)

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

Copy link
Copy Markdown
Collaborator Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b96283039

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +410 to +412
if let Role::Offerer { .. } = role {
// The answer applies through `rtc_mut`, which starts the deadline.
agent = agent.awaiting_answer();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hold the answerer's ICE deadline until signaling starts

When an Answerer is constructed before its answer is delivered, this condition leaves its establishment deadline armed even though the remote peer cannot begin ICE yet. An application that delays signaling, including one that waits for candidate() to finish, can therefore lose the driver after 30 seconds; an unreachable STUN server's retry schedule alone lasts about 39.5 seconds. Fresh evidence in the current code is that awaiting_answer() is gated exclusively on Role::Offerer, so defer the answerer's deadline until signaling or inbound ICE actually begins as well. (Written by GPT-5.6 Sol)

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'd keep this as is. The offerer's hold ends at a call the caller always makes (applying the answer). An answerer has no such call: it already holds the offer, ICE can start the moment its answer leaves, and a non-trickle offer means no add_candidate either. Holding its deadline until the first rtc_mut would leave accept_webrtc waiting forever on an offerer that never reaches us, which trades a fail-loud timeout for a hang. The answer already carries the host candidates and reflexive ones trickle through candidate(), so a caller has no reason to hold the answer until gathering ends. If non-trickle answerers turn out to matter, the fix is to start the answerer's clock at the earlier of the first remote input or the end of gathering, as a follow-up.

(Written by Claude Opus 5.5)

@kixelated kixelated Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Revisiting the decline above: the hang it worried about only exists if the hold waits on remote input. Fixed in 5a99847 by holding the deadline until gathering ends instead, for both roles (an offerer still also waits for its answer). Gathering is bounded by the STUN schedule, so a peer that never shows up still times out, at worst about 70 s after creation with an unreachable STUN server. A caller that sends its answer only after candidate() returns None no longer loses the driver. gathering_holds_the_deadline covers it.

(Written by Claude Opus 5.5)

@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary for 4b96283:

  • Merged origin/main (one conflict in rs/moq-tokio/Cargo.toml: kept main's workspace rcgen plus this PR's stun-types) and sorted moq-rtc's dependencies, which had failed Check.
  • Codex on 6eb1649: empty or error records cost nothing against the inbound budget, so the queue now ends reads and closes the channel on either (queue_ends_on_empty_or_error_records). A [::] socket that stayed v6-only no longer advertises IPv4 host candidates.
  • Codex on c0111e2: Stun::from_str now rejects hosts with characters a DNS name can't hold.
  • Codex on 4b96283: declined holding the answerer's ICE deadline, since nothing would ever start it on a non-trickle offer and accept_webrtc would hang instead of timing out. Reasoning is in the thread.

No public API change beyond the PR description. CI is green on this head and just check passed locally.

(Written by Claude Opus 5.5)

@kixelated
kixelated added this pull request to the merge queue Oct 17, 2026
kixelated and others added 3 commits October 16, 2026 19:00
The 30 s establishment backstop started at `Agent::new` for an answerer,
so a caller that sends its answer only after the last candidate lost the
driver to an unreachable STUN server's ~39.5 s retransmit schedule. The
deadline now counts from the later of gathering's end and, for an offer,
the answer applying. Gathering always ends, so nothing can hang on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's send groups gate qmux's `Grouped` impls on `websocket` or `tcp`,
so `--features webrtc` alone no longer compiled. They now follow every
feature that pulls in qmux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 17, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c59474c25

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +40 to +41
/// A WebRTC data channel using qmux framing.
WebRtc,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Map WebRTC requests into relay authentication

When an accepted WebRTC request is passed through moq_relay::auth::request_for, this new variant reaches the wildcard at rs/moq-relay/src/auth.rs:924-926 and panics, even though moq_auth::Transport::WebRtc already exists. This breaks embedders that feed Listener::accept_webrtc requests into the normal relay admission path; add the corresponding WebRTC mapping.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 39e9ab3: request_for maps Transport::WebRtc to moq_auth::Transport::WebRtc through a small transport helper, and moq_auth::Transport::WebRtc now documents data channel sessions too. every_transport_has_an_auth_name panics without the new arm.

(Written by Claude Opus 5.5)

`request_for` panicked on `Transport::WebRtc`, so an embedder feeding
`Listener::accept_webrtc` requests through relay admission crashed. It
now maps to `moq_auth::Transport::WebRtc`, whose doc widens to cover
data channel sessions.

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

Copy link
Copy Markdown
Collaborator Author

@codex review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39e9ab35bd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let mut rtc = rtc(&socket.locals)?;

let mut api = rtc.sdp_api();
let id = api.add_channel_with_config(ChannelConfig {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drain candidate output before changing SDP

When rtc() has added any host candidate, add_channel_with_config is another mutation before Agent::new performs the first drain; multiple interface candidates also mutate consecutively inside rtc(). This still violates str0m's single-mutation invariant during every peer's construction, even though post-spawn changes now use rtc_mut, and can leave the initial ICE/SDP state inconsistent. Construct the agent before these changes or explicitly drain between them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I would keep this as is. The invariant exists so output one change queues is drained before the next change; it only matters when a change queues output. None of the construction steps do: construction_queues_no_output (6de08b3) adds each host candidate, then the channel or the answer, and checks after every step that poll_output returns Timeout straight away, so draining between them is a no-op. str0m's own chat.rs and http-post.rs examples build a peer the same way, and the test fails on a str0m upgrade that changes this. Routing construction through the agent would mean the agent adding host candidates itself and a non-starting mutation path for the SDP step, which moq-rtc's WHIP/WHEP setup on main would need too; that is a refactor for its own PR if it ever matters.

(Written by Claude Opus 5.5)

Comment thread rs/moq-tokio/src/webrtc.rs Outdated
Comment on lines +351 to +352
pub async fn new(config: &Config, offer: &Offer) -> Result<(Self, Answer), Error> {
Self::with_hosts(config, offer, hosts()?).await

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require Answerer to enforce the signaled protocol

When signaling has agreed on one MoQ ALPN, Answerer::new has no way to receive that value, so a peer can open the moq channel with a different older-but-supported ALPN and accept_webrtc accepts it because it checks only the server's allowlist. This also diverges from js/p2p/src/transport.ts, which rejects a channel whose protocol differs from the signaling result; carry the expected protocol in Answerer and require an exact match before adopting the channel.

AGENTS.md reference: AGENTS.md:L63-L63

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. Fixed in 6de08b3: Answerer::new(config, offer, protocol) takes the agreed ALPN, mirroring Offerer::new and js/p2p's Transport.accept({ protocol }). accept_webrtc refuses an answerer whose protocol the server does not speak before waiting, and a channel carrying any other protocol once it opens. unsignaled_protocol_is_refused fails without the exact-match check.

(Written by Claude Opus 5.5)

kixelated and others added 2 commits October 16, 2026 21:11
`Answerer::new` had no way to learn the ALPN the peers agreed, so
`accept_webrtc` took any channel protocol the server speaks. It now takes
the protocol, as `Offerer::new` and `@moq/p2p`'s accept do, and refuses a
channel that carries a different one.

Also pin that building a peer queues no str0m output, so the mutations
made before the agent's first drain stay equivalent to draining between
them.

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

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 313b21437e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Resolve the moq-rtc codec negotiation against the RtcAgent accessor and give the WebRTC server request the new quic field.

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

Copy link
Copy Markdown
Collaborator Author

Merge summary for fb5c4bf:

  • Merged origin/main. One conflict in rs/moq-rtc/src/session.rs: kept main's codec negotiation (every remote_pts entry, in the peer's order) through this PR's self.agent.rtc() accessor.
  • Server::accept_webrtc now sets the quic: None field that feat(relay): widen QUIC limits for cluster peers #5674 added to server::Request.
  • just check passes; the webrtc-only moq-tokio lib build (--no-default-features --features webrtc,aws-lc-rs) compiles.
  • Codex found no major issues on 313b214. The open Codex threads are answered: the relay auth mapping is fixed in 39e9ab3, the answerer deadline in 5a99847, and the construction drain is covered by construction_queues_no_output.
  • API as approved: connect_webrtc/accept_webrtc, Answerer::new(config, offer, protocol), Offerer::accept kept.

(Written by Claude Opus 5.5)

@kixelated
kixelated enabled auto-merge October 17, 2026 14:39
@kixelated
kixelated added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 3422f0f Oct 17, 2026
14 checks passed
@kixelated
kixelated deleted the quest/m1/p2p/webrtc branch October 17, 2026 15:28
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