Skip to content

Fix iOS OOM in DcSctpTransport from receive_buffer COW capacity bloat - #234

Open
teancom wants to merge 1 commit into
webrtc-sdk:m144_releasefrom
teancom:fix/dcsctp-receive-buffer-cow-bloat
Open

Fix iOS OOM in DcSctpTransport from receive_buffer COW capacity bloat#234
teancom wants to merge 1 commit into
webrtc-sdk:m144_releasefrom
teancom:fix/dcsctp-receive-buffer-cow-bloat

Conversation

@teancom

@teancom teancom commented May 3, 2026

Copy link
Copy Markdown

Found this while developing the https://github.com/music-assistant/mobile-app iOS app. It was a bit of a humdinger to track down, I don't mind saying. Once I compiled the SDK myself and had dSYMs, though, it was easy enough to spot the allocation issue. Now, it's perfect (or at least, I'm pretty sure the remaining problems are all on my end. 😅)

Anyway, here's the robot's description of the exact change:

DcSctpTransport::OnMessageReceived reused a CopyOnWriteBuffer member via Clear() + AppendData() on each incoming SCTP message. CopyOnWriteBuffer::Clear() preserves capacity (it only resets size), so the member retained the high-water-mark capacity of the largest message ever delivered. When a downstream consumer still held a CopyOnWriteBuffer reference at the moment Clear() ran, the COW detach branch allocated a fresh buffer at that prior capacity.

On iOS the network thread is a webrtc::Thread (pthread, no NSRunLoop), so its autorelease pool only drains opportunistically. Each incoming message produces an autoreleased RTCDataBuffer (via DataChannelDelegateAdapter:: OnMessage in sdk/objc/api/peerconnection/RTCDataChannel.mm) which retains the CopyOnWriteBuffer until the pool drains. With sustained high-rate receives shortly after a single large message -- e.g. an early album-art payload followed by streaming audio over the same data channel -- multiple multi-megabyte allocations stack up and the app aborts via std::bad_alloc within sub-second of playback starting.

Replace the receive_buffer_ member with a function-local CopyOnWriteBuffer sized to the actual message. Capacity becomes a property of the current message rather than session history, no shared underlying buffer is left to detach, and the transport no longer pins the most recent payload between receives.

Add a regression test asserting that the buffer reported to the sink for a small message after a large message tracks the small message's size, not the prior high-water mark.

DcSctpTransport::OnMessageReceived reused a CopyOnWriteBuffer member via
Clear() + AppendData() on each incoming SCTP message.
CopyOnWriteBuffer::Clear() preserves capacity (it only resets size), so
the member retained the high-water-mark capacity of the largest message
ever delivered. When a downstream consumer still held a CopyOnWriteBuffer
reference at the moment Clear() ran, the COW detach branch allocated a
fresh buffer at that prior capacity.

On iOS the network thread is a webrtc::Thread (pthread, no NSRunLoop), so
its autorelease pool only drains opportunistically. Each incoming message
produces an autoreleased RTCDataBuffer (via DataChannelDelegateAdapter::
OnMessage in sdk/objc/api/peerconnection/RTCDataChannel.mm) which retains
the CopyOnWriteBuffer until the pool drains. With sustained high-rate
receives shortly after a single large message -- e.g. an early album-art
payload followed by streaming audio over the same data channel --
multiple multi-megabyte allocations stack up and the app aborts via
std::bad_alloc within sub-second of playback starting.

Replace the receive_buffer_ member with a function-local CopyOnWriteBuffer
sized to the actual message. Capacity becomes a property of the current
message rather than session history, no shared underlying buffer is left
to detach, and the transport no longer pins the most recent payload
between receives.

Add a regression test asserting that the buffer reported to the sink for
a small message after a large message tracks the small message's size,
not the prior high-water mark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@hiroshihorie hiroshihorie left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and verified locally. The mechanism is exactly as described. CopyOnWriteBuffer::Clear() on a shared buffer allocates a fresh RefCountedBuffer at the prior capacity (rtc_base/copy_on_write_buffer.cc, the HasOneRef false branch), so the old member buffer both pinned the last large payload between receives and forced a full high-water-mark allocation per message whenever a consumer still held a reference. The per-message local buffer removes both problems, and since the sink path copies into a DataBuffer anyway there is no extra copy introduced.

I built rtc_media_unittests on macOS arm64 and ran the suite. All 10 DcSctpTransportTest cases pass with this change. I also confirmed the regression test does its job by running it against the old implementation, where it fails with every small message reporting capacity 4194304.

One observation, not blocking: for empty PPID messages the sink now always receives a default constructed buffer whose data() is nullptr, where the old code handed out a non null pointer once a prior message had populated the member. That nullptr case already existed when the first ever message was empty, so this only makes existing behavior consistent rather than introducing it.

Nice find and a well aimed test. Thanks for tracking this down.

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.

2 participants