fix(webrtc): buffer early data channel messages until muxer adoption#3576
Merged
Conversation
…s the channel Both peers in a private-to-private WebRTC connection create their DataChannelMuxerFactory at the start of SDP signaling but only create the muxer - which attaches stream/protocol handlers to incoming data channels - when the connection upgrade completes. Data channels that arrive in between are parked in earlyDataChannels without a message listener and node-datachannel does not buffer messages dispatched without a listener, so a stream opened by the remote as soon as the direct connection is available loses its initial multistream-select bytes and stalls until the dialer's timeout aborts it. Buffer messages that arrive on early data channels and replay them synchronously once the muxer adopts the channel and the stream has attached its message handler, preserving ordering with later messages. Adds an integration test that opens an echo stream from the dialer's connection:open event while the recipient is held at the pre-muxer upgrade checkpoint via a connection gater (failed 10/10 with a timeout before the fix, passes 10/10 after), plus a control test that only differs in when newStream is called (passes 10/10 both before and after). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Author
|
would love to get some eyes on this @tabcat whenever you get the chance 🙏 |
Member
|
Hey @Faolain thank you for reporting this bug. I've been looking at the pr for the last few days. Currently looking at whether this needs DoS mitigations. This will likely be merged by the end of week. In the future, if you'd like me to look at something I'd prefer you message me on Discord. You can find me in the libp2p server (there should be a link in the root readme) in the js-libp2p channel. |
Cap early data channels (16 KB and 16 messages per channel, 64 channels) and close over-limit or non-ArrayBuffer channels without aborting the connection, so a peer cannot buffer unbounded data before the connection is admitted.
Wire drop logging/metrics and factory.close() on upgrade failure into the private-to-public path, and add splice-out, metric-key and binaryType tests.
…ption Buffered early channels become the muxer's early streams on adoption, so feed both caps from one webRTC()/webRTCDirect() maxEarlyStreams option (default 10). Drops the separate MAX_EARLY_DATA_CHANNELS constant.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In a private-to-private WebRTC connection, each peer creates its
DataChannelMuxerFactoryat the start of SDP signaling but only creates the muxer - which attaches stream/protocol handlers to incoming data channels - when the connection upgrade completes, some time after the underlyingRTCPeerConnectionconnects. Data channels that arrive in between are parked inearlyDataChannelswithout amessagelistener, andnode-datachannel(like browserRTCDataChannels) does not buffer messages dispatched without a listener - so if the remote opens a stream as soon as the direct connection is available, its initial multistream-select bytes are silently dropped, protocol negotiation stalls, and the stream dies on the dialer's timeout even though the connection itself is healthy.Environment
main@ 5459e13, which islibp2p@3.3.5,@libp2p/webrtc@6.0.26,@libp2p/circuit-relay-v2@4.2.8,@libp2p/websockets@10.1.16,@libp2p/identify@4.1.9,node-datachannel@0.32.3,@chainsafe/libp2p-yamux@8.0.1,@chainsafe/libp2p-noise@17.0.0Reproduction
In-repo:
packages/integration-tests/test/webrtc-early-stream-race.node.ts(wired intotest/node.ts). Relay (the aegir fixture relay) + two Node peers; the dialer upgrades the relayed connection to a direct WebRTC connection and opens an echo stream from itsconnection:openevent, i.e. in the same event loop turn that publishes the connection. The recipient is held at the pre-muxer upgrade checkpoint via an asyncdenyInboundEncryptedConnectionconnection gater for 500ms to make the race deterministic (see below). Without the fix in this PR the test failed 10/10 consecutive runs withTimeoutError: The operation was aborted due to timeout; a sibling control test that only differs in whennewStreamis called (it waits for the recipient to surface the connection) passed 10/10.Why the artificial delay: the window is inherent but only a few milliseconds wide between two Node processes on one idle machine - debug traces on this branch show the recipient adopting the early channel ~2-4ms after the dialer's first bytes would arrive. Over real networks (or with a busy event loop) the recipient's upgrade completion is delayed arbitrarily relative to the dialer's first stream data, which is how this was hit downstream. The gater hook runs immediately before muxer creation in the upgrader, so it widens exactly the window in question using only public API. Crucially, if early channels buffered their data the delay would only add latency - the echo would still complete well inside the 5s timeout. That is what happens with the fix applied: the same test passes in ~1.6s.
Trace of a failing run (
DEBUG='libp2p:webrtc*'):Standalone reproduction using published packages (no repo checkout needed)
npm i libp2p @libp2p/webrtc @libp2p/circuit-relay-v2 @libp2p/websockets @libp2p/identify @chainsafe/libp2p-yamux @chainsafe/libp2p-noise @multiformats/multiaddr-matcher, thennode webrtc-early-stream-race.mjs. Exits 1 with a timeout (race reproduced); setRECIPIENT_UPGRADE_DELAYto0(or open the stream later) and it exits 0.Diagnosis
packages/transport-webrtc/src/muxer.ts(atmain):DataChannelMuxerFactoryis constructed at the start of signaling (private-to-private/initiate-connection.tsfor the dialer,private-to-private/transport.ts_onProtocolfor the recipient). Itsdatachannellistener only doesthis.earlyDataChannels.push(evt.channel)- nomessagelistener is attached to the parked channel.createStreamMuxer()is called later, from the upgrader, after the signaling stream has been closed (a relayed round-trip) and the rest of the upgrade has run. Only then doesDataChannelMuxeradopt the parked channels (in aqueueMicrotask) viaonDataChannel()->createStream(), which is wherechannel.onmessageis first assigned (src/stream.ts).node-datachannel'sRTCDataChannelpolyfill dispatchesmessageevents as they arrive; events dispatched while no listener is attached are lost (verified in isolation: 3/3 messages sent into a captured-but-unadopted channel were dropped). Browsers behave the same way, which is whysrc/stream.tsalready carries a comment that message listeners must be attached before events occur.muxer ... closed while reading 0/1 bytes. Downstream (ts-drp, macOS/Node 22) the trace signature ismuxer error closed while reading 0/1 bytes->TimeoutError: The operation was aborted due to timeout->libdatachannel error ... DataChannel is closed(node-datachannelRTCDataChannel.ts:209) as the abandoned channel is torn down.Relationship to existing issues
TimeoutError: signal timed outwhen sending on a/webrtcaddress that both sides report as connected. Same failure family (first application stream on a freshly-upgraded private-to-private WebRTC connection stalls); this PR contributes a deterministic Node-side reproduction and a mechanism.main.What this PR contains
packages/integration-tests/test/webrtc-early-stream-race.node.ts- integration test that opens an echo stream from the dialer'sconnection:openevent (fails 10/10 onmainwithTimeoutError), plus a control that only differs in whennewStreamis called (passes 10/10 onmain).packages/transport-webrtc/src/muxer.ts- fix:onEarlyDataChannelnow attaches a bufferingonmessagehandler to parked channels; when the muxer adopts a channel the buffered events are replayed synchronously right aftercreateStream()assigns the real handler, preserving ordering with later messages. With the fix the race test passes 10/10 andnpm run --workspace @libp2p/webrtc test:nodeis green.Notes for maintainers
muxer.ts. An alternative would be to create theWebRTCStreameagerly in the factory; that is more invasive, so this PR keeps the parked-channel design and just makes it lossless. Happy to iterate if you would prefer that direction..skipon landing and can split the PR accordingly.