fix(webrtc): decode data-channel framing as a byte stream; SCTP stream-id parity by DTLS role - #1460
Conversation
…rity by DTLS role go-libp2p (go-msgio pbio fallback) writes the uvarint prefix and the protobuf body as two SCTP messages, so every py<->go WebRTC-Direct connection died at Noise msg#1 with "malformed handshake frame length". New stream._decode_frames pops complete frames off an accumulating buffer (frames may be split or batched across SCTP messages, bounded by MAX_MESSAGE_SIZE); DataChannelReadWriter and WebRTCStream.on_data both use it. A malformed frame now resets the stream instead of dropping one message and desynchronising. With the handshake passing, a py listener then hit aiortc's `assert stream_id not in self._data_channels`: aiortc picks DCEP stream id parity from the ICE role, which is inverted for a WebRTC-Direct listener (ICE-controlled, DTLS server), so it used even ids like go's dialer. _create_channel now passes an explicit id by DTLS role (RFC 8832: client even, server odd). Verified live against go-libp2p v0.49.0: go->py and py->go, v1 and v2. Refs libp2p#1437
- keep aiortc allocating (and recycling) SCTP stream ids; only re-seed the parity from the DTLS role (explicit ids tied the 16-bit space to our never-recycled counter) - on_data: deliver frames decoded before a malformed one, reset the peer (RESET + cleanup) instead of a local-only reset, ignore bytes after reset, and apply a whole SCTP message in one trio hop - _decode_frames: derive the max prefix length from MAX_MESSAGE_SIZE Refs libp2p#1437
The test performs two full dials and two bounded peer-connection closes; on Windows each close can take ~5 s when a datagram write is in flight, so 30 s was flaky (seen once on CI, no socket errors logged). Refs libp2p#1437
… collides Harness mode binds TCP on the same port number as the OS-chosen UDP mux port; Windows reserves port ranges per protocol, so that TCP bind can fail with WinError 10013 (seen on CI in the harness loopback test). Retry up to five times with a new UDP port before giving up; explicit ports still fail loudly. Refs libp2p#1437
|
@seetadev @acul71 This one's ready for review (thanks for the main syncs!). The two CI reds are main-side flakes, not this diff: |
acul71
left a comment
There was a problem hiding this comment.
Good work overall — the root cause analysis is correct and the fix is sound. Two things I'd like addressed before merging, plus a few smaller notes.
✅ What's good
- Root cause correctly identified. go-msgio's pbio fallback writes the varint prefix and body as two separate SCTP messages; the byte-stream accumulation approach (
_decode_frames) matches how all mature WebRTC stacks handle this. _decode_frames()is a well-designed shared primitive. Generator mutates the buffer in place, yields complete frames, leaves partial tail — clean semantics used consistently by bothDataChannelReadWriterandWebRTCStream.on_data.- Bounds checking is correct.
MAX_MESSAGE_SIZE(16 384) enforced before allocating; prefix window capped at_MAX_PREFIX = 3bytes. - Atomic Trio-thread application. All frames from one SCTP message are applied in a single
_run_on_trio_threadhop — no interleaving. - SCTP parity fix is minimal. Re-seeding only
_data_channel_id(which aiortc bumps by 2) lets aiortc keep allocating and recycling IDs normally. - Tests are thorough: split frame across 3 SCTP messages, batched frames, partial-delivery-then-reset, DTLS role parity (client even, server odd), ID recycling after channel close, harness TCP bind retry.
- CI results (run locally on this branch):
make lint✅,make typecheck✅,make linux-docs✅.make test: 3449 passed / 3 failed — all 3 failures arekad_dhtassertion flakes unrelated to this PR (none of the touched files are inkad_dht). WebRTC suite: 231/231 passed. - Newsfragment
newsfragments/1437.bugfix.rstpresent, correct format, ends with newline ✅.
🔴 M1 — Hard assert in production channel-open code (_aiortc_helpers.py ~line 358)
assert hasattr(dtls, "_role"), "RTCDtlsTransport has no attribute '_role'"_role is a private aiortc attribute. If a future aiortc upgrade renames or removes it, every open_stream() call will crash with AssertionError (not a clean WebRTCConnectionError). The "auto" fallback path already exists right below — please use it:
if not hasattr(dtls, "_role"):
logger.warning(
"RTCDtlsTransport._role missing (aiortc API changed?); "
"using default SCTP stream-id parity"
)
else:
role = dtls._roleThis is the same defensive pattern as the rest of the function. The assert style is fine inside set_private_attr (a dev-time guard), but not in a live connection path.
🟡 M2 — Latent head[-1] access when consumed == 0 in _decode_frames (stream.py ~line 534)
length, consumed = decode_varint_with_size(bytes(head))
if head[consumed - 1] & 0x80: # ← head[-1] if consumed == 0In practice consumed >= 1 for any non-empty input with the current varint implementation, and while buf: guards the outer loop. But if decode_varint_with_size ever returns (0, 0) for a non-empty byte (implementation contract not documented), head[-1] silently checks the wrong byte. One-line fix:
if consumed == 0:
return # wait for more bytesimmediately after the decode_varint_with_size call.
🟡 M3 — _reset_locally() not idempotent (stream.py)
A batch containing a peer-sent RESET flag followed by a malformed frame causes _reset_locally() to be called twice (once from _apply_batch_on_trio_thread, once from _reset_on_trio_thread). The second call is benign today but _enqueue_eof_sentinel_locked is not guarded. Easy fix:
def _reset_locally(self) -> None:
if self._state == StreamState.RESET:
return
self._state = StreamState.RESET
self._enqueue_eof_sentinel_locked()🔵 Minor / Nice-to-have
- m1 —
newsfragments/1437.bugfix.rstis very long (500+ word single paragraph). Towncrier reflows it, but 2–3 concise user-facing bullets would read better in the changelog. - m2 —
test_ids_follow_dtls_role_and_are_recycledusesasyncio.run()in a plaindeftest. Inconsistent with the@pytest.mark.trioconvention. Consider restructuring as a@pytest.mark.trio async deftest. - m3 — Redundant
assert hasattrpattern (the manualasserton_role+ the one insideset_private_attrmix two styles — just use one).
❓ Questions for the author
- [M1] Was
RTCDtlsTransport._rolepresent since aiortc 1.15 (the floor inpyproject.toml)? Is there a CI check that would catch a private rename? - [M2] Is there a test that exercises
_decode_frameswithconsumed == 0from a non-empty buffer? - [
DataChannelReadWriter._fill] When a FIN is received,_closed = Trueis set and the loop breaks, leaving any further frames inself._rawunprocessed. Is the invariant "Noise handshake FIN frames carry no payload" documented anywhere? - [Loopback timeout] The 30 s → 60 s widening: was this observed to be flaky on Linux CI too, or only Windows?
- [Stacked PRs] Is #1460 safe to merge independently and #1459 rebased on top, or should both land together?
Summary
| Quality | Good |
| Security impact | Low |
| Merge readiness | Needs fixes — please address M1 and M2 (both are one-liners), and optionally M3 |
| Confidence | High |
The framing and SCTP-parity fixes are correct and critical for go↔py interop. M1 is the main blocker (a hard assert in a live connection path). M2 and M3 are easy defensive fixes worth adding. Everything else is polish.
Requesting changes on M1 + M2; the rest is up to you.
… RESET guards - _create_channel: a missing RTCDtlsTransport._role now degrades to aiortc's default stream-id parity with a warning instead of asserting (an assert in a live connection path, also stripped under -O) - _decode_frames: reject consumed == 0 from the varint decoder rather than silently indexing head[-1] - _reset_locally is idempotent; _apply_batch_on_trio_thread stops delivering a batch's remaining frames once the stream is RESET - note in DataChannelReadWriter._fill that frames after FIN/RESET are dropped by design; wrap the newsfragment Refs libp2p#1437
|
@acul71 All addressed in
Your questions: (1) |
|
Superseded by #1459, which already includes this stack (framing as a byte stream + SCTP stream-id parity by DTLS role). |
What
Two pre-existing bugs that made every py↔go WebRTC-Direct connection fail, found by running the v1 code live against go-libp2p v0.49.0:
uvarint + pb.Messageper SCTP message, but go-msgio writes the varint prefix and the body as two separate writes → two SCTP messages. py failed at Noise msg#1 withmalformed handshake frame length(andWebRTCStream.on_datawould have dropped every split frame after that). Now both the Noise channel and streams decode frames from an accumulating byte buffer: split or batched frames are fine, the buffer is bounded byMAX_MESSAGE_SIZE, frames decoded before a malformed one are still delivered, a malformed frame resets the stream (RESET sent to the peer, further bytes ignored), and a whole SCTP message is applied in one trio hop.assert stream_id not in self._data_channels). We now re-seed the allocator's parity from the DTLS role (RFC 8832: client even, server odd) and let aiortc keep allocating and recycling ids.Also widens one Windows-sensitive loopback test's budget (two dials + two bounded closes).
Verified
Live against go-libp2p v0.49.0, both directions: go→py and py→go connect and complete Noise. Unit tests for split/batched frames, partial-delivery-then-reset, id parity and id reuse.
tests/core/transport/webrtc: 230 passed; mypy/ruff/pyrefly clean.Refs #1437 — first of two stacked PRs; the v2 flow (#1459) builds on this.