Skip to content

WebRTC: a native build gives up a browser's lost SCTP chunks itself - #351

Closed
FractumSeraph wants to merge 1 commit into
OpenCommunityEdition:mainfrom
FractumSeraph:webrtc-sctp-gaps
Closed

FractumSeraph wants to merge 1 commit into
OpenCommunityEdition:mainfrom
FractumSeraph:webrtc-sctp-gaps

Conversation

@FractumSeraph

Copy link
Copy Markdown

The problem

The CI step "Test in Chromium and Firefox" (node port/web/tests/webrtc_native.mjs chromium firefox, web job) fails now and then in its lossy run, the one with a tenth of the datagrams lost each way. The lossless runs always pass. In a failing run the data channel stays open, but the echoes stop early, for example:

firefox, a tenth of the datagrams lost: 74 of 960 messages back
FAILED: firefox with losses: {"sent":960,"echoed":74,"wrong":0,"lastEchoed":108,"open":true}

It isn't only a test problem: the native end of a real browser connection (port/linux/src/p2p_webrtc.c) stops in the same way on a path that loses packets, and the player's connection then times out.

On OpenCE main (b1ef7f7), in Docker (node:24-bookworm, Playwright 1.58: Chromium and Firefox 146.0.1), looping the lossy run 30 times per browser, 8 of the 60 runs failed (4 Chromium, 4 Firefox), with 49 to 241 of 960 messages back.

Why

I traced the native end's SCTP and the datagrams the test drops. Both browsers use dcsctp, WebRTC's SCTP (Firefox 146 does too), and both stop giving up their lost chunks (FORWARD-TSN, RFC 3758), so the native end's cumulative TSN stops at the first lost chunk. Two causes:

  1. The native end's SACK reported at most 16 gaps (MAXIMUM_GAP_BLOCKS). Chunks received past the 16th gap were never acknowledged. dcsctp counts a chunk missing only when a SACK newly acknowledges something after it, so it stopped counting the lost chunks missing, never gave them up, and fell back to its retransmission timer. A SACK now reports as many gaps as a packet carries.
  2. The association's first DATA chunk lost. In 30 runs of each browser, every run that lost the first chunk failed, and no other run did.
    • dcsctp sends a FORWARD-TSN at most once per smoothed round trip, or per 200 ms if that is shorter, and each one passes only the first gap.
    • It measures the round trip only from SACKs without gaps. When the first chunk is lost, every SACK has a gap, so it keeps its first guess (500 ms). It then gives up one gap every 200 ms, fewer than a 10% loss opens, and falls behind until the native end's TSN window is full. That is Chromium's failure.
    • Firefox's is worse. Before Firefox 158, Firefox's DataChannel code never gives dcsctp a clock: DcSctpSocketCallbacks::TimeMillis() keeps its default, which returns 0. dcsctp's time therefore never moves, and the 200 ms it waits after its first FORWARD-TSN never pass, so it sends no other for the rest of the connection. Mozilla fixed that on 2026-10-06 (bug 2077997, "Give dcsctp a real clock"), in Firefox 158; the current release, 157, still has it.

The fix

The native end no longer depends on the browser's FORWARD-TSN. If a gap in what the browser sent is still open 200 ms after a later chunk arrived, the native end gives it up itself, exactly as if a FORWARD-TSN had come (sctp_skip, sharing give_up_to with the FORWARD-TSN handler).

  • The channel is unordered with no retransmissions, so a chunk missing that long is lost either way, and KCP sends the tunnel's data again as on UDP.
  • 200 ms is RFC 3758's limit on delaying a FORWARD-TSN, so a browser that works has given the gap up by then. It is also under dcsctp's 400 ms minimum retransmission timeout, which would cut its congestion window to one packet.
  • The SACKs stay valid for any SCTP implementation: only TSNs below one actually received are acknowledged.

The log says once per connection, with the SCTP state, when the native end gives up a gap for the browser or when the browser acknowledges nothing. That should make the next such problem quicker to find.

Nothing the machines send each other changes, so there is no network version change. The README ("Browsers"), NETCODE.md (Transport) and the test's comment describe it.

Checked

Same Docker setup:

  • OpenCE main: 8 of 60 lossy runs failed (30 per browser).
  • This branch: none of 100 lossy runs failed (50 per browser), at least 685 of 960 messages back; the full webrtc_native.mjs chromium firefox passes.
  • Once, in about 700 runs in all, the channel didn't open within the test's 20 seconds. That is older and unrelated: with 40% loss and a single round of messages, OpenCE main and this branch both failed to open in 14 of 50 runs, with the same errors (not open: ICE connected, connected mostly, a few ICE failures).
  • The same change, on FractumSeraph's fork, passed 250 more lossy runs.
  • p2p_webrtc.c compiles as C89 (gnu89, -Wall -Wextra -Wdeclaration-after-statement) without new warnings.

The commit is cherry-picked from FractumSeraph/OpenCE (5ace29a), with one sentence of its message corrected.

🤖 Generated with Claude Code

The lossy run of webrtc_native.mjs failed about one time in ten (the channel
open, the echoes stopped). Tracing the native end's SCTP showed two causes,
both in how the browsers' SCTP (dcsctp, Chromium's and Firefox 146's) gives
up lost chunks with FORWARD-TSN:

- The native end's SACK reported only 16 gaps. Chunks received past the
  16th were never acknowledged, so the browser counted nothing newly
  acknowledged, never counted the lost chunks missing and never gave them
  up. A SACK now reports as many gaps as a packet carries.
- In 30 runs of each browser, every run that lost the association's first
  DATA chunk failed, and no other did. dcsctp sends a FORWARD-TSN at most
  once per smoothed round trip (200 ms when that is longer), each passing
  only the first gap, and measures the round trip only from SACKs without
  gaps; with the first chunk lost it keeps its first guess (500 ms), so it
  gives up a gap every 200 ms, fewer than 10% loss opens, and Chromium falls
  behind until the native end's TSN window is full. Firefox's dcsctp before
  158 has no clock (DataChannelDcSctp leaves TimeMillis returning 0,
  Mozilla's bug 2077997), so the 200 ms after its first FORWARD-TSN never
  pass: it sends no other for the rest of the connection.

So the native end no longer waits on the browser: a gap a later chunk came
past 200 ms ago (RFC 3758's limit on delaying a FORWARD-TSN, under dcsctp's
400 ms minimum retransmission timeout) is given up as if a FORWARD-TSN had
come. The channel's messages are never retransmitted, so such a chunk is
lost either way; the SACKs stay valid for any SCTP (only TSNs below one
received are acknowledged). The log says once per connection, with the
SCTP state, when a gap is given up here or the browser acknowledges
nothing.

Nothing the machines send each other changes: no network version.

Checked in Docker (node:24-bookworm, Playwright 1.58: Chromium and Firefox
146.0.1) with webrtc_native.mjs's lossy run looped: before, 4 of 32, 3 of
25 and 3 of 30 Firefox runs and 2 of 12 Chromium runs failed (2 of 30 with
the SACK fix alone); after, none of 250 (100 and 25 each browser, 15 more
Firefox), at least 677 of 960 messages back; the full test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 5ace29a)
@VerySmuggy VerySmuggy added bug Something isn't working priority: high Should be handled soon labels Oct 19, 2026
@cybersecurity

Copy link
Copy Markdown
Collaborator

A commit already in testing (1b61b52) fixed the same problem, and the PR conflicts with it.

FractumSeraph pushed a commit to FractumSeraph/OpenCE that referenced this pull request Oct 20, 2026
…rm-keys-psp-vita-uwp

Delta platforms: PSP, Vita and the Xbox One/Series in developer mode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority: high Should be handled soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants