Skip to content

feat(webrtc): run aiortc RTCPeerConnection over UdpMux (attach_muxed_connection) - #1448

Merged
acul71 merged 11 commits into
libp2p:mainfrom
yashksaini-coder:feat/webrtc-udpmux-aiortc-bridge
Aug 28, 2026
Merged

acul71 merged 11 commits into
libp2p:mainfrom
yashksaini-coder:feat/webrtc-udpmux-aiortc-bridge

Conversation

@yashksaini-coder

Copy link
Copy Markdown
Contributor

What

Second step for #1437: lets a full aiortc RTCPeerConnection (DTLS + SCTP) run over a UdpMux-backed ICE connection, so a listener can demux concurrent inbound dials on one UDP port.

  • attach_muxed_connection(pc, mux, conn) (_aiortc_helpers.py): call after createDataChannel, before any set*Description. Swaps iceGatherer._connection / iceTransport._connection for the mux-backed aioice.Connection and rebinds iceTransport._recv/_send (aiortc captures those at construction — without this DTLS keeps talking to the orphaned connection). Registers/unregisters the peer address on ICE state changes.
  • UdpMux fixes surfaced by driving it with a real peer:
    • add_ice_connection also sets _local_candidates_start, so aiortc's gather() (issued from setLocalDescription) is a no-op instead of binding extra sockets and appending their candidates to the answer.
    • STUN responses carry no USERNAME — they were dropped. Now routed by address; addresses are learned from inbound checks and from outbound sends (pion-style), so DTLS racing ahead of our ICE completed still routes.
    • unregister(ufrag) drops the learned addresses for that protocol.
    • STUN-shaped-but-malformed datagrams go straight to the connection's data path (StunProtocol.datagram_received only catches ValueError, so struct.error escaped the loop callback).
  • webrtc extra: aiortc>=1.15,<2.0 (what this was validated against; 1.15 also fixed the credential-forwarding bug from spike: aiortc ICE mux / STUN USERNAME exposure for webrtc-direct v2 listener #1352).

Tests

test_udp_mux.py: mux-backed server PC vs plain aiortc client PC on loopback — answer advertises only the shared port + our creds + a=setup:passive, ICE/DTLS/SCTP connect, data flows both ways, mux tables empty after close. Plus addr-learning / unregister / malformed-STUN cases. tests/core/transport/webrtc: 187 passed; mypy/pyrefly clean.

Stacked on #1447 → #1446.

Refs #1437

@yashksaini-coder

Copy link
Copy Markdown
Contributor Author

@seetadev @acul71 Rebased on latest main. This PR runs a full aiortc RTCPeerConnection over the #1397 UdpMux via a new attach_muxed_connection helper, plus mux hardening (address learning/caps, no extra sockets from gather()). Stacked on #1447; unblocks the STUN listener in #1449; refs #1437 / #1352. One known flake left: a Windows-only hang in the client-side aioice close (upstream proactor issue) we're isolating on a fork branch.

yashksaini-coder added a commit to yashksaini-coder/py-libp2p that referenced this pull request Aug 23, 2026
…_attr

Dialer v1 ICE-credential fields on the aioice Connection and the
listener's inbound _validate_peer_identity replacement now assert the
slot exists before writing, matching the pattern introduced in libp2p#1448.

Refs libp2p#1437
@yashksaini-coder
yashksaini-coder force-pushed the feat/webrtc-udpmux-aiortc-bridge branch from 07ab523 to 8596cdf Compare August 27, 2026 04:35
yashksaini-coder added a commit to yashksaini-coder/py-libp2p that referenced this pull request Aug 27, 2026
…_attr

Dialer v1 ICE-credential fields on the aioice Connection and the
listener's inbound _validate_peer_identity replacement now assert the
slot exists before writing, matching the pattern introduced in libp2p#1448.

Refs libp2p#1437
yashksaini-coder added a commit to yashksaini-coder/py-libp2p that referenced this pull request Aug 27, 2026
…_attr

Dialer v1 ICE-credential fields on the aioice Connection and the
listener's inbound _validate_peer_identity replacement now assert the
slot exists before writing, matching the pattern introduced in libp2p#1448.

Refs libp2p#1437
@yashksaini-coder
yashksaini-coder force-pushed the feat/webrtc-udpmux-aiortc-bridge branch from 7963c3e to f2f656e Compare August 27, 2026 11:43
tox commands_pre installed no extras, so aiortc was absent in CI and every
test under tests/core/transport/webrtc was skipped on GitHub. aiortc 1.15 is
pure Python and its native deps (av, pylibsrtp) ship manylinux/win wheels, so
no system packages are needed.

Refs libp2p#1437
…oles

The listener's inbound completion was a stub: after ICE it logged and
returned, so no inbound connection ever reached the handler. dial() was
broken too - get_remote_fingerprint read attributes aiortc does not have.

- listener: own a trio nursery (system task, like TCP), hop asyncio->trio
  without blocking the loop, run Noise XX as *initiator* (spec: server
  initiates, dialer responds), then hand the authenticated connection to
  the handler; bound in-flight unauthenticated inbounds.
- transport.dial(): Noise responder; verify authenticated peer ID against
  /p2p/ after the handshake.
- noise: role-ordered prologue (dialer fingerprint, then server);
  PatternXX.handshake_outbound(remote_peer=None) skips only the ID
  equality check; DataChannelReadWriter.read(n) honours n (the Noise
  packet reader asks for the 2-byte prefix alone).
- helpers: get_remote_fingerprint reads pc.sctp.transport._ssl peer cert;
  noise send waits for data channel 0 to open.
- tests: transport-level dial->listen->stream echo loopback, wrong /p2p/
  rejection, e2e Noise role test, buffered read test, fingerprint test.

Refs libp2p#1437
- Frame Noise handshake bytes as uvarint-prefixed webrtc.pb.Message
  stream frames on channel 0 (spec 'Multiplexing'; what go/js do) instead
  of raw bytes - required for interop; py<->py was symmetric so tests did
  not notice.
- Listener: guard the handler call - the nursery lives in a trio system
  task, so an escaping exception aborted the whole trio run.
- dial(): bound the Noise phase (responder's first step is a read) with
  handshake_timeout; close the PC on every failure path, not just the two
  explicit mismatches; do not pass config.ice_servers (previously no STUN
  servers were used; the default Google STUN added ~5s to offline dials).
- Tests: framing round-trip/chunking/FIN/malformed, handler-exception
  regression.

Refs libp2p#1437
…string

Review follow-up: the module docstring still described the prologue as
local_fp || remote_fp; it is dialer_fp || server_fp (spec role order),
matching build_noise_prologue. Also bound the dialer's wait_for_connected
with config.handshake_timeout like the listener does.

Refs libp2p#1437
…connection)

aiortc has no injection point for an external ICE connection, so a
listener that demuxes on one UDP port could not use RTCPeerConnection on
top of UdpMux. Add attach_muxed_connection(pc, mux, conn): swaps the
gatherer/transport aioice.Connection for the mux-backed one, rebinds the
DTLS _recv/_send that RTCIceTransport captured at construction, and keeps
the mux tables in sync on ICE state changes.

UdpMux fixes found while validating that path with a real peer:
- mark _local_candidates_start so aiortc's gather() from
  setLocalDescription is a no-op (it bound extra sockets and appended
  their candidates to the answer);
- STUN responses/indications carry no USERNAME - route them by address,
  and learn peer addresses from inbound checks and outbound sends;
- unregister(ufrag) also drops the addresses learned for that protocol;
- STUN-shaped-but-malformed datagrams go straight to the connection's data
  path (StunProtocol only catches ValueError, so struct.error escaped).

Test: mux-backed server PC vs plain aiortc client PC on loopback -
answer advertises only the shared port, ICE/DTLS/SCTP connect, data flows
both ways, tables empty after close. webrtc extra now aiortc>=1.15.

Refs libp2p#1437
… mux test

- _learn_addr: latest connection wins for a reused (ip, port) (pion
  behaviour) so a redial from the same socket reaches its new connection;
  cap learned addresses per protocol (unauthenticated STUN with a live
  ufrag from many source ports must not grow the table without bound).
- test_pc_over_mux: bind the mux on 0.0.0.0 and advertise a real host
  address - a 127.0.0.1-bound socket cannot reply to a LAN-bound peer
  socket on Windows (WinError 1231), which Linux's weak-host model hid.

Refs libp2p#1437
A hung close() after a primary failure would outlive asyncio.wait_for and
be killed by pytest-timeout, reported as an xdist 'worker crashed' that
hides the real error (seen on Windows CI).

Refs libp2p#1437
…ection)

pc.close() deadlocks on Windows CPython 3.12/3.13: DTLS queues its
close_notify datagram, transport.close() runs with that write in flight
and defers connection_lost to the write callback, but
_ProactorDatagramTransport._loop_writing early-returns on _conn_lost -
the deferred connection_lost is never delivered and aioice's
StunProtocol.close() awaits its closed-future forever (confirmed via
task/transport dumps on CI: closing=True, write_fut finished,
closed.done=False).

close_peer_connection(pc) bounds close() and, on timeout, abort()s the
lingering aioice transports - _force_close delivers connection_lost
unconditionally - then lets close() finish. Used in the PC-over-mux test;
production call sites switch in the listener PR.

Refs libp2p#1437
Writing a wrong - or upgraded-away - private/mangled attribute name is a
silent no-op: setattr creates a new attribute the library never reads and
the failure only surfaces far downstream (the SDP-fingerprint invariant
test, an ICE that binds extra sockets). Assert hasattr at the write so it
fails at the line that caused it, and so an aiortc/aioice upgrade that
renames or drops a slot breaks loudly at construction.

Applied to the DTLS cert pin (_RTCPeerConnection__certificates, ca8331f),
the attach_muxed_connection injection points, and add_ice_connection's
aioice fields.

Refs libp2p#1437
…Windows

Windows surfaces an ICMP port-unreachable for a datagram we sent (a
keepalive to a peer that just closed) as WSAECONNRESET on the shared
socket's next recv, and the proactor loop does not re-arm reading after
error_received - the mux went deaf for every other peer (seen in CI as
'[WinError 10054]' followed by the next dial's ICE timing out). Disable
that behaviour with SIO_UDP_CONNRESET on the mux socket; no-op elsewhere.

Refs libp2p#1437
@yashksaini-coder
yashksaini-coder force-pushed the feat/webrtc-udpmux-aiortc-bridge branch from f2f656e to 59369a8 Compare August 27, 2026 11:48
yashksaini-coder added a commit to yashksaini-coder/py-libp2p that referenced this pull request Aug 27, 2026
…_attr

Dialer v1 ICE-credential fields on the aioice Connection and the
listener's inbound _validate_peer_identity replacement now assert the
slot exists before writing, matching the pattern introduced in libp2p#1448.

Refs libp2p#1437
@acul71
acul71 merged commit 59369a8 into libp2p:main Aug 28, 2026
37 of 38 checks passed
acul71 added a commit that referenced this pull request Aug 28, 2026
…1449)

Lands the full WebRTC-Direct stack for #1437:

- CI webrtc extra, inbound wiring + spec Noise roles, UdpMux bridge
- STUN-dispatch v1 listener + v1 dialer (spec path default)
- HTTP POST /sdp harness opt-in via enable_sdp_http_harness
- Review follow-ups: rate limit, ice_servers=[], actpass + DTLS server role,
  0.0.0.0 concrete advertising, README note

Supersedes #1446, #1447, #1448.

Refs #1437
@yashksaini-coder
yashksaini-coder deleted the feat/webrtc-udpmux-aiortc-bridge branch August 28, 2026 05:41
@acul71 acul71 mentioned this pull request Sep 3, 2026
4 of 7 tasks
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.

2 participants