DEV Community

Cover image for Adding WebRTC to py-libp2p: Two Runtimes, Three Specs, and One Unforgiving Private Slot
Yash Kumar Saini
Yash Kumar Saini Subscriber

Posted on

Adding WebRTC to py-libp2p: Two Runtimes, Three Specs, and One Unforgiving Private Slot

TL;DR — py-libp2p PR #1309 adds an experimental WebRTC transport (/webrtc-direct and /webrtc) as a v1 node-to-node foundation. It's ~6,700 lines across 41 files, gated by libp2p[webrtc] + enable_webrtc, and merged after four rounds of maintainer review. This is the story of what's inside, why the seams are where they are, and the three bugs that would have shipped silently if the loopback test hadn't been written.


A quick honest note before we start

I want to be upfront about how this post came together. This is a reflective engineering write-up on a PR I authored, with the goal of turning the review conversation, spec cross-references, and multi-round fixes into something a future reader can learn from without having to scroll 400 GitHub comments.

Where I quote maintainers, those are their words. Where I explain "why we did it this way," I've tried to preserve the trade-offs as they actually came up in review — not the polished, past-tense version. This isn't a marketing post. WebRTC in py-libp2p is experimental v1; browser interop is not supported; there are follow-ups filed. I'll say that plainly throughout.

If you're new to libp2p, WebRTC, or trio, there's enough background woven in to keep you oriented. If you're deep in this ecosystem already, the interesting sections are the framing, the certificate pin, and the two-runtime bridge — feel free to jump.


The problem: why a Python libp2p wants WebRTC at all

libp2p is a modular network stack: identity, transports, security, multiplexing, routing, and pub/sub, each swappable. Its Go and JS implementations have had WebRTC for a while. Python did not — issue #546 tracks the gap.

Why care? Three reasons the ecosystem keeps coming back to:

  1. NAT traversal for real users. Most consumer machines sit behind at least one NAT, often more. WebRTC's ICE + STUN + TURN dance is the only widely-deployed answer that browsers speak natively. If your Python peer wants to talk to a browser peer — or two NATed Python peers want to talk directly with only a light-touch relay — WebRTC is the door.
  2. Native multiplexing. WebRTC data channels give you real per-stream framing over SCTP. That means the transport itself carries a "muxer," so you can skip the Yamux / Mplex upgrade step that a TCP-based libp2p connection needs.
  3. Server-to-browser without an HTTP server. With /webrtc-direct, a Python node can accept an inbound connection from a browser peer purely via a UDP socket + a certificate hash embedded in the multiaddr. No signaling server, no HTTPS termination, no TURN unless traversal forces you there.

That's the "why." Now for the "how" — and specifically, why "how" turned into 6,700 lines of Python that took four review rounds to get right.


What actually landed

The PR body says it, but here it is in one paragraph so you know the surface area:

  • WebRTCDirectTransport (/webrtc-direct) and WebRTCPrivateTransport (/webrtc, relay-signaled).
  • Spec-compliant data-channel framing — uvarint length-prefixed protobuf, with the full FIN / FIN_ACK / STOP_SENDING / RESET state machine.
  • In-band data channels for application streams; the Noise handshake channel (id=0) stays pre-negotiated per spec.
  • Signaling with the bilateral ICE_DONE fix from libp2p/specs#585.
  • Noise XX with a prologue that binds the handshake to the DTLS certificate fingerprints.
  • ECDSA P-256 cert generation with multihash / multibase fingerprint encoding.
  • A DTLS cert pin that actually pins the cert. More on that shortly.
  • A trio ↔ asyncio bridge, because py-libp2p is trio and aiortc is asyncio.

The final tests count: 153 / 153 passing, including a real RTCPeerConnectionRTCPeerConnection loopback test that exercises framing, chunked writes over the 16 KiB SCTP ceiling, and the SDP-fingerprint canary.

What's out of scope, and this matters: browser interop is not supported here; go / js /webrtc-direct interop is not wired (they reconstruct the SDP from STUN USERNAME, we don't have that hook yet); the /webrtc dial path is a listener + signaling skeleton only. All of that is deliberate. This is v1. v2 (specs#715) is where browser dial actually happens, and we wanted the v1 skeleton in the tree so it stops being vaporware.


Architecture in one picture

Two flavors of WebRTC show up in py-libp2p, and it took me a while to keep them straight. Here they are, side by side:

flowchart LR
  subgraph Direct["/webrtc-direct (server-reachable)"]
    A[Client] -- SDP offer built<br/>from server multiaddr --> B[Server<br/>publishes cert hash]
    A <-. DTLS + data channels .-> B
  end

  subgraph Private["/webrtc (both peers NATed)"]
    C[Peer A] --- R[Relay v2]
    D[Peer B] --- R
    C -. signaling over relay:<br/>SDP + ICE + ICE_DONE .-> D
    C <-. direct DTLS + data channels .-> D
  end
Enter fullscreen mode Exit fullscreen mode

Two transports, one shared substrate: certs, SDP, Noise binding, stream framing, and the trio↔asyncio bridge. Only signaling differs.

Inside a single peer, the runtime layout looks like this:

flowchart TB
  subgraph Trio["Trio world (py-libp2p)"]
    T1[Host / Swarm]
    T2[WebRTCConnection]
    T3[WebRTCStream × N]
    T4[Application handlers]
  end

  subgraph Bridge["AsyncioBridge<br/>(background daemon thread)"]
    BR[asyncio event loop]
  end

  subgraph Asyncio["Asyncio world (aiortc)"]
    A1[RTCPeerConnection]
    A2[RTCDataChannel × N]
    A3[DTLS / ICE / SCTP]
  end

  T1 --> T2 --> T3
  T2 -- run_coro --> BR
  BR -- runs on --> A1
  A1 --> A2 --> A3
  A2 -. on_data via trio_token .-> T3
Enter fullscreen mode Exit fullscreen mode

The interesting seam is the arrow labelled trio_token: aiortc calls into us from an asyncio thread, and we need those callbacks to hand data to trio memory channels safely. If we skip the token, we get RunFinishedError and worse. That code lives in stream.py:382-410 — I'll come back to it.


Deep dive #1 — framing, or "why bare protobuf was the wrong wire"

If you read the first draft of this PR, application-level writes were serialized as bare protobuf Message bytes and shoved straight into data_channel.send(...). That "works" on the happy path for small payloads. It's also wrong for two independent reasons — and once you understand both, you understand why the spec is written the way it is.

Reason 1: SCTP has a 16 KiB message ceiling

The libp2p WebRTC spec caps each data-channel message at 16 384 bytes. Below that, you're fine. Above, aiortc will happily hand SCTP an oversized message that gets fragmented in ways your peer can't reassemble, or your peer will drop it, or SCTP itself will complain — the failure modes vary by implementation, which is worse than a clean rejection.

The fix is to chunk on the sender's side and let the read side see one wire message per SCTP message.

Reason 2: bare protobuf isn't self-delimiting inside a stream frame

Even inside the 16 KiB limit, a Go or JS peer decoding your message needs a length prefix. The spec is explicit: each SCTP message is one uvarint length prefix + one protobuf Message. Not "raw protobuf." Not "concatenated protobufs." Exactly one framed pair per SCTP datagram.

Here's the on-wire shape:

┌───────────────────┬──────────────────────────────────────────┐
│  uvarint length   │  protobuf Message (flag? + message? bytes)│
└───────────────────┴──────────────────────────────────────────┘
                    ← up to 16 KiB total, hard ceiling ──────→
Enter fullscreen mode Exit fullscreen mode

The uvarint itself is straightforward — 7 bits of payload per byte, top bit for continuation, capped at 10 bytes so it fits uint64:

# libp2p/transport/webrtc/_varint.py
def encode_uvarint(value: int) -> bytes:
    if value < 0:
        raise ValueError("uvarint cannot encode negative values")
    buf = bytearray()
    while value > 0x7F:
        buf.append((value & 0x7F) | 0x80)
        value >>= 7
    buf.append(value & 0x7F)
    return bytes(buf)
Enter fullscreen mode Exit fullscreen mode

The sender side of a stream then becomes:

# libp2p/transport/webrtc/stream.py
async def write(self, data: bytes) -> None:
    if self._state == StreamState.RESET:
        raise WebRTCStreamError("Stream was reset")
    if self._write_closed:
        raise WebRTCStreamError("Write side is closed")

    offset = 0
    while offset < len(data):
        chunk = data[offset : offset + MAX_PAYLOAD_SIZE]
        msg = Message(message=chunk)
        await self._send_message(msg)
        offset += len(chunk)
Enter fullscreen mode Exit fullscreen mode

MAX_PAYLOAD_SIZE is chosen so that even with the largest reasonable varint length prefix plus protobuf tag bytes, the full framed wire message stays under 16 KiB. The chunk boundary is on the sender; the reader just decodes one frame per SCTP message.

And this is worth calling out — there's one spec detail that's easy to miss:

The spec allows a message to carry both data and FIN, and the data must be delivered to the reader before the read channel is closed.

So the receive path enqueues payload first, then processes the flag. Getting that order wrong gives you a data loss bug that only shows up on the last chunk of a stream close. From stream.py:352-380:

# libp2p/transport/webrtc/stream.py
def _apply_on_trio_thread() -> None:
    # Enqueue payload BEFORE processing flags — the spec allows a
    # message to carry both data and FIN, and the data must be
    # delivered to the reader before the read channel is closed.
    if has_payload:
        try:
            self._read_send.send_nowait(payload)
        except trio.WouldBlock:
            logger.warning(...)
    if has_flag:
        if flag == Message.FIN:
            self._read_closed = True
            self._enqueue_eof_sentinel_locked()
            self._schedule_send(Message(flag=Message.FIN_ACK))
        elif flag == Message.FIN_ACK:
            self._fin_ack_received.set()
        ...
Enter fullscreen mode Exit fullscreen mode

The comment isn't decoration; it's a spec citation for a future reader who wonders why the ordering matters.


Deep dive #2 — the certificate pin that actually pins

This is my favourite bug in the PR. It's the kind of bug that survives green tests because the failure is silent, the API looks correct, and the wrong value is only observable if you cross-check two places that most people cross-check exactly once — at spec-reading time, then never again.

Setting the scene

/webrtc-direct works by embedding the server's DTLS certificate hash in the multiaddr:

/ip4/127.0.0.1/udp/9090/webrtc-direct/certhash/uEiA…/p2p/12D3K…
Enter fullscreen mode Exit fullscreen mode

The client dials that address, constructs an SDP offer locally (there's no signaling server), and expects the server's DTLS handshake to present a cert whose SHA-256 matches the certhash in the multiaddr. If they don't match, the client bails — that's the whole security story of /webrtc-direct.

Which means: whatever cert the server advertises, it must actually use in the DTLS handshake. If those two paths diverge, you either get a broken connection or, worse, a working connection that isn't actually pinned to anything.

The naïve pin

In aiortc, you'd expect to set the cert on the RTCPeerConnection like this:

pc = RTCPeerConnection()
pc._certificates = [my_cert]   # ← looks fine, right?
Enter fullscreen mode Exit fullscreen mode

That's a silent no-op. RTCPeerConnection reads only its own name-mangled private attribute — self.__certificates inside the class, which mangles to self._RTCPeerConnection__certificates from the outside. The pc._certificates attribute lives on the object, sure, but aiortc never looks at it. It happily generates its own certificate at construction time and uses that in DTLS. Your advertised /certhash/ is a completely different digest.

The maintainer review flagged this indirectly — the loopback test caught it directly. Every test that ran without an actual peer connection passed. The moment we ran an SDP round-trip and grepped the a=fingerprint line, the pinned cert was nowhere.

The fix

# libp2p/transport/webrtc/_aiortc_helpers.py
async def create_peer_connection(
    rtc_cert: RTCCertificate,
    ice_servers: list[str] | None = None,
) -> RTCPeerConnection:
    """
    Create an RTCPeerConnection pinned to the given certificate.

    aiortc >= 1.5 no longer accepts certificates= in RTCConfiguration —
    passing it raises TypeError.  We build the peer connection with ICE
    servers only, then replace the auto-generated DTLS certificate before
    any SDP operation triggers the DTLS handshake.  The fingerprint and
    the actual handshake cert are read from a name-mangled private
    attribute (self.__certificates inside RTCPeerConnection →
    self._RTCPeerConnection__certificates from the outside).  Setting
    the un-mangled pc._certificates is a no-op that aiortc never reads.
    """
    config = RTCConfiguration(iceServers=list(ice_servers) if ice_servers else [])
    pc = RTCPeerConnection(configuration=config)
    # Must use the mangled name — aiortc reads only self.__certificates.
    pc._RTCPeerConnection__certificates = [rtc_cert]  # type: ignore[attr-defined]
    return pc
Enter fullscreen mode Exit fullscreen mode

Two characters (__) that would look like a typo in a code review, guarding a subtle security property.

The canary

Fixing the bug is one thing; making sure it stays fixed is another. The loopback test now includes a canary — after the SDP exchange, it parses the a=fingerprint:sha-256 line out of the offer and asserts it equals the SHA-256 of the pinned cert:

# tests/core/transport/webrtc/test_multiplexing_loopback.py
def _sdp_fingerprint_string(cert: WebRTCCertificate) -> str:
    """
    Render *cert*'s SHA-256 fingerprint in the colon-separated upper-hex
    form aiortc writes into SDP a=fingerprint:sha-256 ... lines.
    """
    return ":".join(f"{b:02X}" for b in cert.fingerprint)
Enter fullscreen mode Exit fullscreen mode

If somebody in the future refactors the pin — or aiortc renames __certificates — this test fails immediately, with a message that points straight at the divergence. That's the whole point of a canary: it wouldn't have been added if the bug hadn't happened, and it stays there so the bug can't come back.

The subplot here isn't "aiortc is buggy" — it's that libraries with public APIs sometimes leak private-attribute contracts into their integrators, and when they do, the only defense is a test that observes the on-wire outcome, not the API you think you called.


Deep dive #3 — bridging two runtimes without blowing up

py-libp2p is trio. aiortc is asyncio. Both are event loops. Neither will await the other's coroutines. This isn't a limitation of Python; it's a design decision by trio (structured concurrency, no thread-safety-by-default) that leans hard against interop.

The options are: pick one runtime, run both loops in one thread with a bridge library, or run the second loop in its own thread. Option one wasn't available — swapping either dependency was a non-starter. Option two (anyio, trio-asyncio) has enough corner cases with aiortc's assumptions that we didn't want to chase them under a review deadline. Option three is boring and explicit, which is what we wanted.

The bridge is a small class:

# libp2p/transport/webrtc/_asyncio_bridge.py
class AsyncioBridge:
    """
    Manages a background asyncio event loop for running aiortc coroutines.
    """
    def __init__(self) -> None:
        self._loop: asyncio.AbstractEventLoop | None = None
        self._thread: threading.Thread | None = None
        # Protects start/stop against concurrent trio calls
        self._lock = trio.Lock()
        # Protects _loop/_started/_stopped reads from run_coro against
        # concurrent stop() — needed because run_coro_sync is called
        # from non-trio threads (asyncio callbacks).
        self._state_lock = threading.Lock()
Enter fullscreen mode Exit fullscreen mode

Two locks — a trio.Lock for the trio side of start/stop, and a threading.Lock because asyncio callbacks can hit the bridge from a foreign thread. If you only have the trio lock, you race with those callbacks and it manifests as intermittent AsyncioBridgeError: not running right at shutdown.

The heart of it is run_coro:

# libp2p/transport/webrtc/_asyncio_bridge.py
async def run_coro(self, coro: Coroutine[Any, Any, T]) -> T:
    with self._state_lock:
        loop = self._loop
        running = self._started and not self._stopped
    if loop is None or not running:
        coro.close()
        raise AsyncioBridgeError("AsyncioBridge is not running")

    future = asyncio.run_coroutine_threadsafe(coro, loop)

    def _wait_for_result() -> T:
        return future.result()

    try:
        # abandon_on_cancel=True lets trio cancel the scope immediately.
        # The background thread is abandoned but we cancel the asyncio
        # future below so it doesn't leak.
        return await trio.to_thread.run_sync(
            _wait_for_result, abandon_on_cancel=True
        )
    except trio.Cancelled:
        future.cancel()
        raise
Enter fullscreen mode Exit fullscreen mode

Two subtle things worth pausing on:

  1. abandon_on_cancel=True — without it, trio's cancellation waits for the thread to unwind before actually cancelling. With aiortc's coroutines that can mean seconds, or forever. With abandon_on_cancel=True, trio releases the scope immediately, we forward the cancellation to the asyncio future, and the background loop cleans up on its own timeline.
  2. The _state_lock snapshot — we read loop and running under the lock, then use the local loop variable. If stop() runs concurrently, we either dispatched to a valid loop (which will handle the cancel) or we raise cleanly. Never a race where we scheduled on a partially-torn-down loop.

The reverse direction — aiortc calling us — needs a different trick. When a data channel fires an on_message event, we're on the asyncio thread, and we need to push bytes into a trio.MemorySendChannel. Direct calls into trio primitives from a foreign thread are undefined behavior. The trick is trio.lowlevel.current_trio_token(), captured at construction time:

# libp2p/transport/webrtc/stream.py
try:
    self._trio_token = trio.lowlevel.current_trio_token()
except RuntimeError:
    self._trio_token = None

def _run_on_trio_thread(self, fn: Callable[[], None]) -> None:
    token = self._trio_token
    try:
        trio.lowlevel.current_task()
        in_trio = True
    except RuntimeError:
        in_trio = False

    if in_trio or token is None:
        fn()
    else:
        try:
            trio.from_thread.run_sync(fn, trio_token=token)
        except trio.RunFinishedError:
            logger.debug(...)
Enter fullscreen mode Exit fullscreen mode

If we're already on a trio task (tests), we call inline. If we're on an asyncio callback thread, we go through trio.from_thread.run_sync with the token. If trio has already shut down, we log and drop — not raise, because the callback is fire-and-forget from aiortc's point of view.

This is the code that makes the whole transport not-flaky. It's ~30 lines. It took two review cycles to converge on.


Deep dive #4 — Noise XX, but bound to DTLS

WebRTC already gives you an encrypted DTLS channel between two peers. Why do we still run Noise on top of it?

Because DTLS authenticates the certificate, not the libp2p peer. Anyone with a valid ECDSA P-256 cert can complete the DTLS handshake. But libp2p peers are identified by their libp2p identity key, which is a completely different keypair. So we need a step where each side proves control of a libp2p identity — and the natural way to do that is Noise XX, which is already how libp2p peers authenticate on TCP.

But there's a subtle attack here: what if an attacker sits between the two peers, has their own valid DTLS cert, and forwards both DTLS sessions? Both peers see a valid DTLS handshake, then run Noise on top — and if Noise doesn't know anything about DTLS, it authenticates fine end-to-end through the attacker.

The fix is the Noise prologue — a byte string that's mixed into the Noise handshake state at the start. The libp2p spec binds Noise to DTLS by putting both certificate fingerprints into the prologue:

# libp2p/transport/webrtc/noise_handshake.py
def build_noise_prologue(
    local_fingerprint: bytes,
    remote_fingerprint: bytes,
) -> bytes:
    """
    Build the Noise prologue that binds the handshake to the DTLS session.

    Prologue format:
        b"libp2p-webrtc-noise:" + encode(local_fp) + encode(remote_fp)
    """
    local_mh = _MH_SHA256_HEADER + local_fingerprint
    remote_mh = _MH_SHA256_HEADER + remote_fingerprint
    return NOISE_PROLOGUE_PREFIX + local_mh + remote_mh
Enter fullscreen mode Exit fullscreen mode

If an attacker sits in the middle, each side sees a different remote fingerprint (the attacker's), so the prologues mismatch, and Noise fails. Clean. Nothing needs to change in the Noise state machine itself — the prologue is a parameter it already accepts.

The rest of the handshake reuses py-libp2p's existing PatternXX implementation. That's what "in-band data channels" is buying us — the Noise handshake rides over data channel 0, which is pre-negotiated (negotiated=True, id=0) so both peers agree on its identity without SDP renegotiation. Application streams get their own in-band channels, so the on('datachannel') event fires on the peer.

Why this matters: in the first draft of the PR, application streams used negotiated=True as well. The consequence was that on the remote peer, the datachannel event never fired, because pre-negotiated channels bypass it entirely. accept_stream() blocked forever. It was a classic "the spec is clear, we read it too quickly" fix — Noise uses negotiated=True because it's channel 0 and both sides know it's there; application streams use negotiated=False because they need to be announced.


Deep dive #5 — signaling, ICE_DONE, and a spec fix in flight

For the /webrtc (relay-signaled) case, two NATed peers need to exchange SDP over some other channel to get started. That channel is a Circuit Relay v2 stream, and the protocol is /webrtc-signaling/0.0.1.

The interesting part isn't the SDP exchange — it's how you know it's done. ICE candidates trickle over the signaling stream as each peer's ICE agent discovers them. At some point, both sides are done trickling, and the stream can close. But if one side closes prematurely, the other side loses candidates it hadn't received yet.

The old spec left this timing ambiguous. libp2p/specs#585 fixed it by adding a bilateral ICE_DONE message — each side signals it's done, and neither closes until both ICE_DONEs are seen:

Initiator                              Responder (via relay)
  ──── SDP_OFFER ─────────────────────────>
  <─── SDP_ANSWER ─────────────────────────
  <──> ICE_CANDIDATE (trickle, both ways) <>
  ──── ICE_DONE ───────────────────────────>
  <─── ICE_DONE ───────────────────────────
  (both sides close signaling stream)
Enter fullscreen mode Exit fullscreen mode

That's what signaling.py implements. It's not the most complicated code in the PR, but it's the piece that cannot be handwaved with "we'll add ICE_DONE later" — it changes the state machine of when both peers agree the signaling phase has ended.


The seam that will save us a rewrite: _apply_ice_credentials()

There's one more piece of "spec is in flight" that we chose to handle architecturally rather than by writing a TODO.

The libp2p/specs#672 discussion is about Chrome removing SDP munging for ICE credentials — a change that will eventually break the v1 way of injecting the peer's ICE ufrag into the SDP. When that lands, code that has ICE credential handling scattered across the SDP builder is in for a bad time. Code that has all of it in one function just changes that function.

So we put all of it in one function:

# libp2p/transport/webrtc/sdp.py
def _apply_ice_credentials(
    sdp: str,
    ufrag: str,
    pwd: str,
    fingerprint_hex: str,
    remote_ufrag: str | None = None,
    remote_pwd: str | None = None,
) -> str:
    """
    Apply ICE credentials to the SDP.

    **This is the single seam for libp2p/specs#672.**  When Chrome drops
    ICE credential munging support, only this function needs to change.
    Currently a no-op passthrough — local credentials are already in the
    SDP template.  The remote credentials are accepted but unused until
    the spec changes require injecting them via a separate mechanism.
    """
    return sdp
Enter fullscreen mode Exit fullscreen mode

Today it's a passthrough. Tomorrow, when v2 dispatches by libp2p+webrtc+v1/ / libp2p+webrtc+v2/ ufrag prefixes, this function grows a small branch on the ufrag prefix and everything else in the builder stays intact.

The maintainer's response, verbatim, was:

The v1 foundation here (framing, Noise prologue, certhash, _apply_ice_credentials() as a seam) is not throwaway — it feeds v2, but _apply_ice_credentials() will likely evolve into a version-dispatch layer rather than a single swap.

That's the value of a seam. It reserves the space for future evolution without pre-committing to which evolution.


Testing: 153 tests, but only one of them mattered

Unit tests are cheap; they run in ~10 seconds and give you fast feedback on individual modules. Integration tests are expensive; the two-RTCPeerConnection loopback test spins up an ICE agent, waits for connectivity, and pushes data through the SCTP stack. It takes a few seconds and is annoyingly flaky under heavy CI load.

But that expensive test caught three bugs the cheap ones missed:

Bug Symptom in unit tests Symptom in loopback Caught by
pc._certificates = [...] no-ops ✅ Green Advertised certhash ≠ SDP fingerprint SDP-fingerprint canary
Application channels used negotiated=True ✅ Green accept_stream() hangs forever Real accept-stream round trip
Bare protobuf, no length prefix ✅ Green 16 KiB payload gets fragmented, reader dumps garbage > 16 KiB echo round-trip

Each of these was reachable by unit tests only if you knew to look for them, which no one did. That's the lesson of integration testing WebRTC: unit tests are a floor, not a ceiling, and the ceiling has real hardware in it.

The loopback test's docstring calls this out explicitly:

"""
Integration test: two real RTCPeerConnections, end-to-end loopback.

Validates the WebRTC data-channel layer at the wire level — the three
ecosystem-compatibility fixes documented in PR #1309 review notes:

  - In-band data channels (negotiated=False): the responder's
    'datachannel' event must fire so WebRTCConnection.accept_stream()
    unblocks.
  - uvarint length-prefixed framing: every send must stay within the
    16 KiB spec ceiling; > 16 KiB payload round-trips through chunking
    and reassembly.
  - DTLS certificate pinning: aiortc >= 1.5 dropped 'certificates=' in
    RTCConfiguration; the cert override must take effect before
    createOffer/createAnswer so the local DTLS fingerprint in the SDP
    matches the one our peer expects.
"""
Enter fullscreen mode Exit fullscreen mode

If somebody reads this file six months from now, they know exactly why those three assertions exist. That's the value of leaving the review context in the code, not just in the PR comments.


What the review looked like from the inside

Four review pushes on top of the initial scaffolding, roughly:

  1. Correctness pass. Copilot flagged 22 inline issues, mostly F401 unused imports and one substantial one — that dial() was returning connections without actually running ICE/DTLS/Noise. We raised NotImplementedError on the paths that weren't wired yet, so a caller would fail loudly instead of getting a phantom connection.
  2. Wire the transport into aiortc. This is where the cert-pin bug lived, silent, until we started running real handshakes.
  3. Framing + in-band channels + cert pin. The three bugs above, all in one series of commits. The commit messages are worth reading — each one names exactly what was wrong and why the fix is right.
  4. Two-PC loopback test + newsfragment reframe. Landing the integration test that guards the three fixes, and rewriting the newsfragment so "experimental v1 node-to-node scaffolding" is the first thing a changelog reader sees.

The maintainer's approving comment, in full:

Merging this as experimental WebRTC scaffolding behind libp2p[webrtc] + enable_webrtc. The critical-path fixes from the re-review (uvarint framing, in-band channels + DCEP wait, cert pinning, loopback tests, docstring/newsfragment accuracy) are in place, and CI is green.

#546 stays open (Refs #546 — do not auto-close on merge). This PR advances the parent issue but does not satisfy it.

That last line matters. Issue #546 is "WebRTC support in py-libp2p." This PR is not that. This PR is scaffolding for that. The follow-ups — browser interop, go/js interop, full private dial — are separate issues that get their own PRs on this foundation.


Where it goes next

The concrete follow-ups, in the order they'll probably ship:

  • STUN USERNAME exposure spike in aiortc. Go and JS /webrtc-direct listeners reconstruct the SDP offer from the STUN username on the inbound packet. Our listener uses an HTTP POST /sdp exchange as a py-to-py harness. If aiortc exposes the inbound STUN username via a hook, our listener can switch and interop lights up.
  • Private /webrtc dial. The listener is here, the signaling protocol is here, the WebRTCPrivateTransport is here. The dial path currently constructs and returns a connection without running relay signaling — that's the next PR.
  • Version dispatch in _apply_ice_credentials(). When libp2p+webrtc+v1/ and libp2p+webrtc+v2/ ufrag prefixes are standardized, that function grows a branch and WebRTCDirectListener learns to look at the prefix. v2 is the browser-compatible path.
  • Interop tests against go-libp2p and js-libp2p. These will fail today, deliberately. When the STUN username hook lands, they start passing, and we know we're on the ecosystem.

None of these are architecturally scary. The scary architecture is what this PR did: two runtimes, one wire format, one lifecycle, three specs. That's done.


The one takeaway I want you to leave with

If you're building anything that crosses two runtimes, two libraries, or two specs, the seams are the API. Not the surface API — the internal seams. Where does ICE credential handling live? One function. Where does the DTLS cert pin land? One helper. Where does the trio ↔ asyncio boundary get crossed? One class.

The maintainer review didn't just catch bugs. It repeatedly pushed us toward fewer places where the interesting decisions were expressed. Every review round pulled scattered logic into a named function, so that when the next spec revision lands (v1 → v2, specs#672, specs#715), the change is localized.

Scaffolding isn't just "the parts that don't work yet." Scaffolding is the shape of the seams, so the parts you fill in later fit.


Try it, and honest expectations

You can install and inspect it today:

pip install 'libp2p[webrtc]'
Enter fullscreen mode Exit fullscreen mode

Then in code:

from libp2p import new_host

host = new_host(enable_webrtc=True)
# ...multiaddr: /ip4/…/udp/…/webrtc-direct/certhash/u…/p2p/…
Enter fullscreen mode Exit fullscreen mode

Realistic expectations:

  • ✅ Two Python peers talking /webrtc-direct on a loopback interface will work.
  • ⚠️ Python peer ↔ Go peer or Python peer ↔ JS peer over /webrtc-direct will not work yet (the STUN username reconstruction gap).
  • ❌ Browser peer dialing a Python peer will not work. That's v2's job.

If you want to help, the follow-up sub-issues under #546 are the map. The seams are ready. The wires need connecting.


References


Written by Yash Kumar Saini — reflections on a merged py-libp2p PR. Corrections and pushback welcome; I'll update this post in place if I got anything wrong. The PR itself is the source of truth.

Top comments (0)