Fix iOS OOM in DcSctpTransport from receive_buffer COW capacity bloat - #234
Fix iOS OOM in DcSctpTransport from receive_buffer COW capacity bloat#234teancom wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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.