Repository navigation
feat(tokio): WebRTC data channel transport over str0m - #5702
Conversation
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>
|
Warning Review limit reachedYou'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. View limit details
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. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| } | ||
| self.connected |= state.is_connected(); | ||
| } | ||
| return Ok(Event::Rtc(event)); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
| let protocol = config.protocol.clone(); | ||
| channel.set_buffered_amount_low_threshold(SEND_WINDOW / 2); | ||
|
|
||
| let (reads, rx) = mpsc::unbounded_channel(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
| let config = channel.config().ok_or(Error::Unreliable)?; | ||
| if !config.ordered || config.reliability != Reliability::Reliable { | ||
| return Err(Error::Unreliable); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
…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
There was a problem hiding this comment.
💡 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".
| 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)), |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
| failed: None, | ||
| timeout: now, | ||
| outbox: VecDeque::new(), | ||
| started: now, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
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>
`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>
There was a problem hiding this comment.
💡 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".
| let len = record.as_ref().map_or(0, |data| data.len() as u64); | ||
| if self.unread.fetch_add(len, Ordering::Relaxed) + len > self.budget { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
|
|
||
| let locals: Vec<SocketAddr> = hosts | ||
| .iter() | ||
| .filter(|ip| local.is_ipv6() || ip.is_ipv4()) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
# 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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
| Some((host, port)) => (host, Some(port)), | ||
| None => (rest, None), | ||
| }; | ||
| if host.is_empty() || host.contains(['/', '?', '@', '[', ']']) { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
| if let Role::Offerer { .. } = role { | ||
| // The answer applies through `rtc_mut`, which starts the deadline. | ||
| agent = agent.awaiting_answer(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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)
|
Merge summary for 4b96283:
No public API change beyond the PR description. CI is green on this head and (Written by Claude Opus 5.5) |
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>
|
@codex review |
There was a problem hiding this comment.
💡 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".
| /// A WebRTC data channel using qmux framing. | ||
| WebRtc, |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
|
@codex review |
There was a problem hiding this comment.
💡 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 { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
| pub async fn new(config: &Config, offer: &Offer) -> Result<(Self, Answer), Error> { | ||
| Self::with_hosts(config, offer, hosts()?).await |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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)
`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>
|
@codex review |
|
Codex Review: Didn't find any major issues. Delightful! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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>
|
Merge summary for fb5c4bf:
(Written by Claude Opus 5.5) |
Summary
Finishes
quest/m2/p2p/webrtc: native peers can now hold a moq-net session with a browser@moq/p2ppeer over a WebRTC data channel.moq-tokiowebrtcfeature (off by default, on inmoq-cli): str0m with a full ICE agent.stun:server; mapped addresses become server-reflexive candidates and trickle to the caller..localcandidates are resolved with mdns-sd before they reach str0m.moq: one record per message,max_record_size16 KiB clamped to the peer'smax-message-size, no datagrams. This matchesjs/p2p.max_dataplus 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 labelledmoq.webrtc::Offerer::newreturns the offer;acceptapplies the answer. The offerer opens the channel and connects withClient::connect_webrtc(&Offerer).webrtc::Answerer::newtakes the offer and returns the answer. It adopts the channel throughListener::accept_webrtc(&Answerer), which returns a normalRequestso auth and admission work as usual.js/p2p'sconnectandacceptdo. The offerer's channel carries it, andaccept_webrtcrefuses a channel carrying any other, even one the server speaks.candidate(trickled local candidates) andadd_candidate(remote ones).moq-icecrate: 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-tokiouses it privately andmoq-rtcdirectly, so str0m types stay out ofmoq-tokio's API. moq-rtc's WHIP/WHEP sessions run on it, and itsnet.rsand private reader are gone.Agent::new(rtc, socket)takes amoq_ice::Socket: the UDP socket it sends from, the host candidates it advertises, and the datagrams routed to it (frommoq_ice::readerfor a 1:1 socket, or moq-rtc's server mux).Agent::rtcis read only, andAgent::rtc_mutreturns anRtcMutguard that derefs to&mut Rtcand 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_outputpins that none of them queues output, so draining between them would change nothing.awaiting_answer, its firstrtc_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'sauth::request_formaps a WebRTC request tomoq_auth::Transport::WebRtcinstead of panicking, soaccept_webrtcrequests go through relay admission.Server::accepton a build without a listener transport (e.g.webrtcalone) now returnsNoneinstead of panicking.sh/rs/tokio-features.sh --alladdswebrtc.doc/concept/transport.mdcovers the native side, anddoc/lib/rslistsmoq-ice. The finished quest (quest/m2/p2p/webrtc.md) is deleted, and its links in the m2 p2p epic,cli.md, andnative-quic.mdare removed;native-quic.mdnow points atmoq-icefor 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(featurewebrtc), 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::WebRtcChanged in
moq-auth: only the doc ofTransport::WebRtc, which now covers data channel sessions as well as WHIP/WHEP.Changed in
moq-tokio:Error::Stoppednow covers a listener too, and its message is "stopped locally" instead of "connection stopped".Breaking in
moq-rtc:Error::RtcInputis 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::Statsis now a re-export ofmoq_ice::Stats(same fields).Wire: nothing new. The data channel framing is the one
@moq/p2palready speaks (qmux-01.<alpn>).Checks
cargo clippy -D warnings:moq-tokiowith--features webrtcalone and with--all-features --all-targets;moq-ice,moq-rtc,moq-tokio(+webrtc), andmoq-cliwith--all-targets.cargo fmt --checkandquest checkpass.cargo test -p moq-tokio --features webrtc --lib webrtc(14),cargo test -p moq-ice(6),cargo test -p moq-rtc(50).moq-ice,moq-rtc,moq-tokio(+webrtc), andmoq-cliwith--all-targets, the three test suites, andjust check.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.Decisions
Decisions 1 to 9 were settled by the maintainer through
/quest-iterate. ✅ marks the chosen option.js/p2palready has the channel creator connect.OffererandAnswerer, sharing a private handle, so callingconnect_webrtcwith an answerer doesn't compile.Peerwith a runtimeError::Role.moq-icecrate holding the agent, STUN client, and wire counters.moq-tokiouses it privately andmoq-rtcdirectly;Agent::mutate(closure)becomes a guard that derefs to&mut Rtcand drains on drop.#[doc(hidden)] pub mod iceinmoq-tokio.moq_tokio::webrtc::ice.moq-rtc..localcandidates resolved?max_data) plus slack for control frames. Freezing stalls our own sends (sctp only releases buffer on a SACK) and ICE consent.js/p2pdoes with 64 KiB.accept_webrtcreturn on a closing listener?Error::Stopped, with its doc widened to cover a listener.Error::NoBackend.Error::Closingvariant.Agent::new(rtc, socket, locals, inbound)takes four arguments. What changes?moq_ice::Socket { udp, locals, inbound }, so it'sAgent::new(rtc, socket). The same struct drops moq-rtc'sSession::ingest/egressfrom five arguments to three, and both crates' client bind helpers return it.rtc_mut()/RtcMutorawaiting_answer()?accept_webrtcwould wait forever.Answerer::new(config, offer, protocol), mirroringOfferer::new(config, protocol)andjs/p2p'sTransport.accept({ protocol });accept_webrtcrefuses a channel carrying any other ALPN.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-iterateagent 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
quest/m2/p2p/native-quic.md): move host gathering and mDNS resolution intomoq-icewhen that quest needs them. Its plan says so.Offerer/Answerer, for p2p route policy.ice_candidates.js/p2pcloses the channel when its 64 KiB receive queue overflows; align it with decision 6 (max_dataplus slack).Offerer::new/Answerer::newdon't wait on DNS before returning the description.moq-icereadsstd::time::Instant, so the transport tests run on wall-clock time over loopback. Moving it tomoq-clockwould let them run under simulated time.Offerer::candidate/Answerer::candidatereturnNoneboth 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