Once a WebRTC peer-connection is established, all communication flows over RTCDataChannels. This document covers the wire-level details every SpawnDev.RTC consumer should know — message framing, binary encoding, backpressure (the most error-prone area), and SCTP cause-code interpretation.
- Channel creation and labels
- Message framing
- Binary vs text
- Backpressure —
BufferedAmountandOnBufferedAmountLow - SCTP cause codes — production debug
- Max message size
WireDataChanneladapter — SpawnDev.RTC + WebTorrent integration
A peer-connection can have multiple data channels. Each is identified by a label (UTF-8 string, no length cap) and an id (uint16, auto-assigned).
var dc = await pc.CreateDataChannelAsync("messages", new RTCDataChannelInit
{
Ordered = true, // SCTP ordered delivery
MaxRetransmits = null, // null = reliable (no cap on retransmits)
Negotiated = false, // false = announce via DCEP; true = pre-negotiated id
Id = null, // ignored when Negotiated=false
Protocol = "", // optional sub-protocol identifier (string, often empty)
});A common pitfall: assuming the label assigned at the offerer's CreateDataChannel call is the same as the label observed at the answerer's OnDataChannel event. Browsers preserve the label faithfully; SipSorcery's fork also preserves it. However, SpawnDev.RTC's RtcPeer had a 2026-04-27 bug where label-vs-peerId comparison was used to dedup peer-connections — and the labels turned out to be NOT cross-side stable in some race scenarios with simultaneous CreateDataChannel calls from both sides. The fix in Torrent.OnHandshake skips the destroy-on-duplicate path when labels are not directly comparable. See D:\users\tj\Projects\SpawnDev.WebTorrent\SpawnDev.WebTorrent\SpawnDev.WebTorrent\Torrent.cs for the labelsComparable guard.
The takeaway: do not use channel labels as cross-side dedup keys. Use the BitTorrent peer_id from the BEP-10 handshake (or whatever stable identifier the application establishes via a handshake message on the channel itself).
When a non-negotiated channel is created, the offerer sends a DCEP OPEN message in-band to the answerer, who replies with a DCEP ACK. This happens transparently — neither browser nor SipSorcery exposes DCEP to consumers. Application data sent before the ACK is buffered until the ACK arrives.
RTCDataChannel.Send(data) produces exactly one message on the wire, regardless of how SCTP fragments it under the hood. The receiving side's OnMessage event fires once per message with the complete data buffer.
dc.Send(new byte[] { 1, 2, 3 }); // Receiver gets one OnMessage with [1, 2, 3]
dc.Send(new byte[] { 4, 5, 6 }); // Receiver gets one OnMessage with [4, 5, 6]
// Receiver does NOT get [1, 2, 3, 4, 5, 6] in a single event.This is different from a TCP socket (where reads can deliver any prefix of the bytes sent). Data channels are message-oriented like UDP, but reliable (when ordered=true, retransmits=null).
The wire frame contains the application message bytes plus SCTP/DTLS overhead — no application-level framing is added. Consumers that need their own message ids, headers, or types build them into the application payload.
SCTP delivers messages within a single channel in send-order (when ordered=true). It does NOT preserve cross-channel ordering. If your app needs cross-channel ordering, either use one channel for everything or build sequence numbers into payloads.
dc.BinaryType = RTCDataChannelBinaryType.ArrayBuffer; // delivers byte[] / Uint8Array
// or
dc.BinaryType = RTCDataChannelBinaryType.Blob; // browser-only, delivers BlobSpawnDev.RTC defaults to ArrayBuffer on both platforms. The C# event OnMessage always delivers a byte[] regardless of binary type.
dc.Send(string text); // sends as text frame, UTF-8 encoded by SCTP
dc.Send(byte[] data); // sends as binary frameThe receiver sees one OnMessage event per send. Text vs binary is preserved through the channel — OnMessage carries either a string or a byte[] depending on what the sender used.
In production SpawnDev.RTC + SpawnDev.WebTorrent code, all peer-wire and signaling traffic is binary. Text frames are reserved for the human-readable signaling layer (the WebSocket tracker — not the data channel).
This is the single most important thing to get right when writing a data-channel application that pushes more than ~tens of KB at a time.
RTCDataChannel.Send(data) is synchronous and non-blocking. It does not throw on a full SCTP send buffer — it queues the message on the local heap and returns immediately. There is no built-in mechanism to make it backpressure-aware.
If your application keeps calling Send while the network is slower than the sender:
BufferedAmount(the queue size in bytes) grows without bound- Local memory pressure increases
- Eventually one of three things happens:
a. Browser reaches its internal cap (Chromium: ~1 GiB queued before discarding) and tears down the connection with
sctpCauseCode=12(User-Initiated Abort) b. The local process runs out of heap and crashes c. The remote peer's incoming buffer fills, RWND drops to zero, and the sender stalls — but only AFTER local memory is committed
const int MaxBufferedAmount = 64 * 1024; // 64 KB — fits comfortably under MTU * cwnd
// At channel creation:
dc.BufferedAmountLowThreshold = MaxBufferedAmount;
// On every send:
async Task SendAsync(byte[] payload)
{
while (dc.BufferedAmount > MaxBufferedAmount && !destroyed && dc.ReadyState == "open")
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
// Capture the latest tcs so OnBufferedAmountLow signals it
Interlocked.Exchange(ref _bufferedAmountLowTcs, tcs);
// Recheck in case the buffer drained between the while condition and here
if (dc.BufferedAmount <= MaxBufferedAmount) break;
await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30));
}
if (dc.ReadyState == "open") dc.Send(payload);
}
dc.OnBufferedAmountLow += (_, _) =>
{
var tcs = Interlocked.Exchange(ref _bufferedAmountLowTcs, null);
tcs?.TrySetResult(true);
};This is exactly what SpawnDev.RTC.RtcPeer.Send (and WireDataChannel.Send) implement.
OnBufferedAmountLow fires once when BufferedAmount transitions from above the threshold to at-or-below. To get repeated firings, the buffer must rise back above the threshold first.
The threshold of 64 KB is a SpawnDev.RTC convention chosen to:
- Stay comfortably under the typical SCTP cwnd at steady state (avoiding frequent stalls)
- Stay well below browser caps (avoid OOM-style aborts)
- Provide a useful breathing room for high-throughput tensor / piece data
Configurable via RtcPeer.MaxBufferedAmount.
The await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)) ceiling exists because if the remote stops draining (network outage, browser tab suspended), OnBufferedAmountLow never fires. The 30s ceiling lets the sender abandon the wait, return control, and let the next-layer error handling (heartbeat timeout, peer destroy) close the connection cleanly.
When a data-channel close is observed, the close event carries a numeric cause (the SCTP cause code). Common values:
| Cause | Name | What you actually saw | What to do |
|---|---|---|---|
| 0 | Normal | Other side called close() cleanly |
Nothing; cleanup |
| 1 | Invalid Stream Identifier | Send to closed/never-existed stream | Bug — should not reach here |
| 2 | Missing Mandatory Parameter | Malformed init | Bug at peer |
| 5 | Unresolvable Address | Endpoint address gone | Network issue, not your bug |
| 8 | Restart of Association | Endpoint restarted | Reconnect |
| 12 | User-Initiated Abort | One side called close() OR browser hit internal cap | See below |
| 13 | Protocol Violation | Unrecoverable protocol error | Bug |
In April 2026, SpawnDev.RTC.WebTorrent's deployed P2P compute demo on GitHub Pages started seeing sctpCauseCode=12 events when workers pushed back multi-MB tensor result buffers. Diagnosis:
- Worker computes tensor, pushes back to coordinator via
dc.Send(resultBuffer) dc.BufferedAmountclimbed past Chrome's internal cap (~1 GiB queued)- Chrome tore down the connection unilaterally, emitting
sctpCauseCode=12 - Coordinator saw
OnClosewith cause=12, application logic interpreted as "remote left the swarm" - Worker stayed alive but coordinator pruned it; chain reaction
The fix shipped in SpawnDev.WebTorrent 3.2.2-rc.1 and SpawnDev.ILGPU.P2P 4.9.2-rc.26:
RtcPeer.Sendbecomesasyncand awaitsOnBufferedAmountLowwhenBufferedAmount > MaxBufferedAmount(64 KB)WireDataChannel.BufferedAmountLowThreshold = MaxBufferedAmountP2PWorker.HandleDispatchAsyncawaits buffer push-back BEFORE sending theKernelResult, so the worker can abort cleanly withresult.Success = falseif any push fails
After the fix: zero observed cause-12 events across 3 weeks of production. The Mandelbrot demo's blank-canvas symptom (caused by the worker timing out before its KernelResult arrived) disappeared simultaneously.
Occasionally a peer's WebRTC stack will emit cause 12 on its own when the local browser is shutting down (tab close, navigate away). This is normal cleanup and indistinguishable on the wire from the abort case. SpawnDev.RTC's higher-level OnPeerLeft event collapses both into a single "peer is gone" signal so consumer code doesn't need to differentiate.
The a=max-message-size SDP attribute tells the peer how big a single application message it can send. Defaults:
| Implementation | Default | Notes |
|---|---|---|
| Chrome | 262144 (256 KB) | Hard cap on unmodified Chrome |
| Firefox | 1073741823 (~1 GiB) | Effectively unlimited |
| Safari | 65535 (64 KB) | Lowest of the major browsers |
| SipSorcery (fork) | 262144 (256 KB) | Configurable up to 1 GB |
| SpawnDev.RTC default | 262144 | Chrome-compatible |
To send messages larger than 256 KB safely across all browsers, the application must chunk the payload itself. SpawnDev.WebTorrent's piece exchange (BEP 3) chunks pieces at 16 KB boundaries by default, well under any browser's cap.
For high-throughput SpawnDev.ILGPU.P2P workloads, the application chunks tensor buffers into 64 KB strips before pushing — and applies the backpressure pattern (§4) so the chunked sequence doesn't accumulate in BufferedAmount.
WireDataChannel is the SpawnDev.WebTorrent adapter that wraps an RTCDataChannel to look like a BitTorrent peer-wire transport. It plugs into Wire.cs (the BEP-3/BEP-10 framing layer) and lets BitTorrent peer logic operate over a WebRTC data channel as if it were a TCP socket.
Key responsibilities:
- Backpressure —
BufferedAmountLowThreshold = RtcPeer.MaxBufferedAmount; sends awaitOnBufferedAmountLowwhen over. - Binary framing — Wire treats sends as opaque byte arrays; the application layer (Wire.cs) handles BEP-3 length-prefixed framing on top.
- Close cascade — on
OnClose,WireDataChannelemits a single event thatTorrent.OnWire'sWire.OnClosehandler turns into a_transport.UnregisterPeer(peerId)call. This was the 2026-04-26 P2P bridge-dropout bug; seeSpawnDev.ILGPU.P2P 4.9.2-rc.25changelog. - No application logic —
WireDataChanneldoes NOT understand the BitTorrent wire protocol. It just routes bytes between the data channel and the upper Wire layer.
The adapter lives in SpawnDev.WebTorrent and is the only point where SpawnDev.RTC's RtcPeer connects to BitTorrent semantics. SpawnDev.RTC alone (without WebTorrent) doesn't need it — apps using RTC for non-BitTorrent purposes (multiplayer game state, agent swarms) consume RtcPeer directly.
| Concept | SpawnDev.RTC type | File |
|---|---|---|
| Data channel | RtcDataChannel |
SpawnDev.RTC/RtcDataChannel.cs |
| Peer + send + backpressure | RtcPeer |
SpawnDev.WebTorrent/RtcPeer.cs (since WebTorrent owns the BitTorrent integration) |
| WebTorrent wire adapter | WireDataChannel |
SpawnDev.WebTorrent/WireDataChannel.cs |
Default MaxBufferedAmount |
64 KB | RtcPeer.cs |
| Backpressure 30 s ceiling | TimeSpan.FromSeconds(30) |
RtcPeer.cs Send method |