Skip to content

Queue incoming streams instead of dropping them - #499

Open
idy wants to merge 2 commits into
pion:mainfrom
GizClaw:fix/accept-queue-backlog
Open

idy wants to merge 2 commits into
pion:mainfrom
GizClaw:fix/accept-queue-backlog

Conversation

@idy

@idy idy commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What changed

Replace the 16-entry acceptCh with an unbounded queue of incoming streams guarded by the association lock, and a sync.Cond bound to that lock to wake AcceptStream.

  • createStream always registers the stream and appends it to the queue; it no longer returns nil, so acceptPayloadData no longer discards DATA for a new stream.
  • AcceptStream returns the head of the queue, waits on the condition variable while the queue is empty, and passes the wakeup on when more streams remain. Its signature is unchanged.
  • On close, readLoop marks the queue closed and wakes all waiters instead of closing the channel. As before, AcceptStream first returns the streams already queued and then io.EOF.

Why

When a peer opens more than 16 streams in a burst (e.g. WebRTC DataChannels opened in quick succession), the non-blocking send to the full acceptCh dropped the new stream and its DATA was discarded without a SACK. The sender had to wait for T3-rtx (1 s initial RTO, backing off), and retransmissions kept being dropped while the application was still catching up. A single packet can carry enough new-stream DATA chunks to fill the channel, and the accepting side usually cannot run in between because replying to the stream (e.g. a DCEP ACK) needs the association lock that the read loop holds. We measured over 1000 dropped streams and 3–4 s tail latency with 150 concurrent opens on loopback.

The non-blocking send was introduced for #30: with a blocking send, the read loop waited on the full channel while holding the association lock, and the goroutine calling AcceptStream needed that same lock to make progress, so both sides deadlocked. The new queue cannot reintroduce that: the read loop only appends to a slice and calls Cond.Signal, neither of which blocks, and AcceptStream releases the lock while it waits in Cond.Wait.

The queue does not need a fixed bound: queued streams are limited by the stream identifier space, their buffered bytes still count against the receive window, and a peer can already open that many streams today if the application accepts fast enough.

Validation

  • new TestAssocAcceptBacklogBurst: 64 streams opened and written while the server does not accept; all DATA is acknowledged, all 64 are then accepted with the right payloads, no T3 timeouts and well under the initial RTO (fails on main: the DATA is never acknowledged)
  • new TestAssocAcceptStreamAfterClose: queued streams are returned after close before io.EOF, and close wakes blocked callers
  • updated the two tests that asserted the old drop behavior
  • go test -race ./..., golangci-lint, GOOS=js GOARCH=wasm go vet ./...

🤖 Generated with Claude Code

idy added a commit to GizClaw/pion-webrtc that referenced this pull request Sep 11, 2026
pion-sctp now queues incoming streams instead of discarding the DATA
of any stream beyond a 16-entry accept backlog (pion/sctp#499).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@idy
idy force-pushed the fix/accept-queue-backlog branch from c56450e to c71c0fc Compare September 20, 2026 14:49
@codecov

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.07%. Comparing base (e1fbf8b) to head (3e5f5ad).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #499      +/-   ##
==========================================
+ Coverage   89.04%   89.07%   +0.02%     
==========================================
  Files          56       56              
  Lines        4756     4758       +2     
==========================================
+ Hits         4235     4238       +3     
+ Misses        521      520       -1     
Flag Coverage Δ
go 89.07% <100.00%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

createStream pushed new incoming streams onto a 16-entry channel with a
non-blocking send and dropped the stream when it was full. Its DATA was
then discarded without a SACK, so a peer opening more than 16 streams
in a burst had to wait for T3-rtx (1s initial RTO, backing off) for
every extra stream, and retransmissions kept being dropped while the
application had not caught up.

The non-blocking send dates back to pion#30: a blocking send made
the read loop wait on the channel while holding the association lock,
which the accepting goroutine needed. Keep incoming streams in an
unbounded slice guarded by the association lock instead, and wake
AcceptStream through a sync.Cond bound to that lock. Appending and
signaling never block, and AcceptStream releases the lock while it
waits, so the read loop can never wait on the application. Queued
streams remain bounded by the stream ID space and their buffered bytes
still count against the receive window.

AcceptStream keeps its signature and close semantics: after the
association closes it returns the streams already queued, then io.EOF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@idy
idy force-pushed the fix/accept-queue-backlog branch from c71c0fc to a5be0f9 Compare September 21, 2026 18:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant