node: Cap packet/frame pool memory via LimitedPool + MemoryLimiter #826
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Wire up the existing LimitedPool and MemoryLimiter infrastructure (added in 2023, never used in Context) to cap packet and frame buffer pool memory. This prevents unbounded memory growth (~137 MB/min) when roc_receiver_read() is not called, e.g. when a PipeWire node is suspended while UDP sources keep transmitting.
Changes:
Description
roc-toolkit 0.4.0's receiver grows memory without bound when UDP packets arrive,
regardless of whether
roc_receiver_read()is being called by the consumer. Theprocess grows from ~40 MB to 10-15 GB over hours, triggering the OOM killer.
Root cause: The receiver's UDP network thread (
NetworkLoop::run()) allocatespacket buffers via
UdpPort::alloc_cb_()andUdpPort::recv_cb_()for everyincoming packet. These buffers are stored in the
SlabPool, which growsexponentially and never frees memory. There is no back-pressure mechanism, no
queue size limit, and no way for the receiver to signal the network thread to
stop accepting packets when internal queues are full.
The problem is most severe when
roc_receiver_read()is not called (e.g., whenthe PipeWire node is suspended with no consumers), but also occurs during normal
operation due to session churn — the watchdog kills sessions during blank audio
periods, and new sessions are immediately created when packets arrive, each
cycle growing the slab pool.
I traced this using heaptrack with a debug build of roc-toolkit and roc-toolkit's
own debug logging (via
roc_log_set_level(ROC_LOG_DEBUG)).Environment
roc-toolkit 0.4.0-1; debug build from git commitd599961a)libpipewire-module-roc-sourceandlibpipewire-module-roc-sink)Configuration
4 ROC endpoints loaded via PipeWire modules (2 receivers, 2 senders) for ham radio audio streaming between two machines:
The remote machine running the corresponding roc-sink/roc-source endpoints does NOT exhibit the leak.
Heaptrack Evidence
Run 1 — stripped binary (84 minutes)
Run 2 — debug build (44 minutes)
Leak rate: ~3.9 GB/hour (under heaptrack), ~700 MB/hour observed without heaptrack.
Leak path 1 — packet buffers (2.13 GB, 5 slab allocations)
Leak path 2 — packets (731 MB, 5 slab allocations)
Session churn (10 KB, 58 sessions in 44 min)
58 sessions created in 44 minutes (~1.3/min). Sessions are created and destroyed
repeatedly. roc-toolkit debug logging confirmed SSRCs are consistent (not randomly
changing); instead, the watchdog kills sessions due to blank audio, and new
sessions are immediately created when packets continue arriving.
Run 3 — with roc debug logging (6 minutes, 2026-02-07)
Growth rate: ~137 MB/min measured, ~117 MB/min theoretical
(2 receivers × 345 pkt/s × 2816 bytes/pkt).
roc-toolkit Debug Log Evidence (2026-02-07)
Enabled via
roc_log_set_level(ROC_LOG_DEBUG):Key observations from debug logs:
.=OKi=initb=blankD=dropAnalysis
Primary issue: No back-pressure on UDP receive path
The receiver's UDP network thread allocates a packet buffer for every incoming
UDP packet via
UdpPort::alloc_cb_(). These are stored inSlabPool, which:slab_cur_slots_ *= 2, slab_pool_impl.cpp:231)max_slab=0(no limit on slab count or memory)When
roc_receiver_read()is not called by the consumer (PipeWire node suspended),packets accumulate in internal queues without limit. Even when it IS called, the
rate of packet allocation in the network thread exceeds the rate of consumption.
Growth rate math
packet.len=128samples at 44.1kHz ≈ 345 packets/second per streampacket_buffer_poolslot_size=2096 +packet_poolslot_size=720 = 2816 B/pktSession churn amplifies growth
Each session cycle (create → blank → drop → remove → recreate) grows the slab
pool because the new session's allocations overlap briefly with the old session's
cleanup, pushing the high-water mark. The
SpeexResamplerconstructor allocates~60 MB of frame buffers per session from the shared
frame_buffer_pool.Why the remote machine doesn't leak
Both machines run identical roc-toolkit 0.4.0-1 and pipewire 1.4.10-2. The remote
machine (zAI) is stable at 33 MB RSS after 3+ days. The difference is traffic
direction: zAI's roc-sources (receivers) get very few packets because the
laptop's TX sinks are suspended (nobody transmitting). The leak is proportional
to incoming UDP traffic volume.
OOM kill history (single boot, Feb 3-4 2026)
Key observations
receivers get very few packets (the laptop's senders are suspended)
no effect — slabs never become fully empty because packet allocations
overlap across session cycles
Proposed fix (PR submitted)
I've implemented a fix that wires up roc-toolkit's existing
LimitedPool+MemoryLimiterinfrastructure (added in 2023, never used inContext) to cappacket pool memory. The fix is minimal (5 files, +94/-12 lines), fully backwards
compatible (default limits are 0 = unlimited for upstream), and uses only existing
roc-toolkit classes.
Files modified:
roc_node/context.hMemoryLimiter+LimitedPoolmembers,max_packet_pool_bytes/max_frame_pool_bytesconfig fieldsroc_node/context.cppNetworkLooproc_netio/udp_port.hrecv_retry_timer_andrecv_retry_cb_()roc_netio/udp_port.cpprecv_cb_(), pause UDP recv + 200ms retry timer when pool fullroc_core/memory_limiter.cppLogErrortoLogTrace(expected behavior when limit active)How it works:
LimitedPoolwraps eachSlabPool, callingMemoryLimiter::acquire()before allocatingallocate()returns NULL →alloc_cb_()returnsbuf->base=NULLrecv_cb_()detects NULL, callsuv_udp_recv_stop()to pause receivingMemoryLimiter::release()restores budgetImplementation notes:
uv_udp_recv_stop()must be called fromrecv_cb_(), NOT fromalloc_cb_()— callingit from
alloc_cb_()causes libuv to NULL the recv callback pointer, and libuv thenSEGVs when it tries to call
recv_cb_()afteralloc_cb_()returnsLogErrorinMemoryLimiter::acquire()was changed toLogTracebecause hitting thelimit is expected behavior, not an error — at high packet rates (~300/sec) the log volume
alone pegs the CPU at 27%
poll→alloc fail→poll) because the socket alwayshas data ready. The 200ms timer breaks the busy-wait loop.
Verified results (32 MB packet limit, 8 MB frame limit):
Suggested additional improvements
max_packet_pool_bytes/max_frame_pool_bytesto
roc_context_configso users can tune limits without recompilingroc_receiver_read()hasn't been calledfor a timeout period, stop UDP reception entirely (would be a PipeWire-side improvement)
OS. (Note: this alone does NOT fix the issue since slabs rarely become fully empty)
Reproducer
roc_receiver(or use PipeWirelibpipewire-module-roc-source)roc-sendorlibpipewire-module-roc-sinkfromanother machine
roc_receiver_read()(or let the PipeWire node be suspendedwith no consumers connected)
while true; do ps -o rss= -p $(pidof pipewire); sleep 60; donebuffer accumulation outpacing consumption
Attachments
I can provide: