Skip to content

Fix audio buffer memory leak: use in-place del instead of slice-copy#228

Merged
aliev merged 6 commits intomainfrom
fix/audio-track-buffer-leak
Mar 26, 2026
Merged

Fix audio buffer memory leak: use in-place del instead of slice-copy#228
aliev merged 6 commits intomainfrom
fix/audio-track-buffer-leak

Conversation

@aliev
Copy link
Member

@aliev aliev commented Mar 24, 2026

Why

pymalloc allocates memory for small objects in large blocks (1MB arenas) to reduce syscalls. An arena can only be returned to the OS when all objects inside it are dead.

AudioStreamTrack.recv() runs every 20ms (50x/sec) and slices the buffer:

self._buffer = self._buffer[self._bytes_per_frame:]
  1. A new bytearray is allocated (new memory from the arena)
  2. Remaining data is copied into it
  3. The old bytearray's refcount drops to 0 and is freed

But "freed" means freed inside pymalloc, not returned to the OS. The old object lived in an arena alongside other objects. If any of those neighbors are still alive, the entire arena (1MB) stays reserved.

At 50 allocations/sec, arenas become fragmented: each one has a mix of live and dead blocks, so none of them can be fully released. RSS grows with every call and never comes back.

Arena (1MB)
├── Pool 1: [free][free][free][free]     <- all free
├── Pool 2: [free][LIVE][free][free]     <- 1 live object
├── Pool 3: [free][free][free][free]     <- all free
└── Pool 4: [free][free][LIVE][free]     <- 1 live object

Result: 64 bytes of live data, 1MB stuck in RSS.

The fix uses in-place deletion:

del self._buffer[:self._bytes_per_frame]

This calls memmove to shift data within the same buffer. No new object, no malloc, no GC, no arena fragmentation.

Changes

  • Replace self._buffer = self._buffer[n:] with del self._buffer[:n] in two places (line 133 and 195)

Memray profiling results (single call, 48kHz mono s16)

Before: audio_track.py:195 (recv) -> 900MB  (#1 allocator by size)
After:  audio_track.py            -> not in top 5

Summary by CodeRabbit

  • Refactor

    • Improved audio buffer handling to avoid reallocating the underlying buffer when consuming frames or trimming excess data, reducing memory churn and improving runtime efficiency and stability.
  • Tests

    • Added automated tests that verify the audio buffer retains its identity and correct size after data consumption and overflow trimming to prevent regressions.

bytearray slice assignment (self._buffer = self._buffer[n:]) creates
a new bytearray every 20ms. At 48kHz mono s16, that's 50 allocations
per second, each copying the remaining buffer. pymalloc retains these
in arena pools and never returns the memory to the OS.

del self._buffer[:n] removes data in-place without allocating a new
object. Memray profiling showed this single line accounted for 900MB
of allocations per call.
@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5d42590e-1cb9-40df-ad92-6d03b5cc825f

📥 Commits

Reviewing files that changed from the base of the PR and between b33a7a3 and 823c419.

📒 Files selected for processing (1)
  • tests/test_audio_stream_track.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_audio_stream_track.py

📝 Walkthrough

Walkthrough

Replaces buffer re-slicing with in-place deletion on the audio track's bytearray (using del) in write and recv, and adds async tests asserting the track's _buffer object identity is preserved after recv() and after overflow trimming.

Changes

Cohort / File(s) Summary
Audio Buffer Optimization
getstream/video/rtc/audio_track.py
Replaced buffer re-slicing (self._buffer = self._buffer[n:]) with in-place deletion (del self._buffer[:n]) in write and recv to avoid allocating a new bytearray.
Tests (buffer identity & overflow)
tests/test_audio_stream_track.py
Added async tests that capture id(track._buffer), call recv() and write data that triggers overflow trimming, and assert _buffer identity is unchanged and remaining length matches expectations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble at the bytes with care,
I trim the front but leave it there.
No new array — the buffer stays,
I hop through frames and saving ways.
🥕

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main change: replacing slice-copy operations with in-place deletion to fix a memory leak in the audio buffer.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audio-track-buffer-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aliev aliev marked this pull request as ready for review March 24, 2026 19:31
@aliev aliev requested a review from dangusev March 24, 2026 20:02
Assert that recv() and overflow trim modify the same bytearray
object rather than creating a new one via slice-copy.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_audio_stream_track.py (1)

306-355: Use fixtures for AudioStreamTrack setup in these new tests.

Both new tests duplicate track setup and direct object construction. Consider a fixture for the mono/s16 track variants used here.

As per coding guidelines, "Use fixtures to inject objects in tests; test client fixtures can use the Stream API client".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_audio_stream_track.py` around lines 306 - 355, The two tests
test_recv_does_not_reallocate_buffer and
test_buffer_overflow_does_not_reallocate duplicate AudioStreamTrack
construction; extract a pytest fixture (e.g., mono_s16_track) that yields an
AudioStreamTrack configured for mono s16 (sample_rate=48000, channels=1,
format="s16"), and update test_recv_does_not_reallocate_buffer to accept that
fixture instead of constructing track locally; for
test_buffer_overflow_does_not_reallocate add a separate fixture or a
parameterized fixture (e.g., mono_s16_track_with_buffer_size or use request
param) to supply audio_buffer_size_ms=100 while keeping the same fixture
pattern, then remove direct constructions and use the fixtures in both tests.
Ensure fixture names match the test parameters and yield a ready-to-use track
for write/recv operations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_audio_stream_track.py`:
- Around line 330-358: The test test_buffer_overflow_does_not_reallocate only
checks identity of track._buffer, which can pass even if trimming didn't occur;
after writing pcm_large, also assert the buffer length equals the 100ms cap
(compute as sample_rate * channels * audio_buffer_size_ms / 1000) to prove the
overflow path executed — e.g., for sample_rate=48000, channels=1,
audio_buffer_size_ms=100 the expected length is 4800 samples; add this length
assertion immediately after the second await track.write(pcm_large) alongside
the existing id(track._buffer) check.

---

Nitpick comments:
In `@tests/test_audio_stream_track.py`:
- Around line 306-355: The two tests test_recv_does_not_reallocate_buffer and
test_buffer_overflow_does_not_reallocate duplicate AudioStreamTrack
construction; extract a pytest fixture (e.g., mono_s16_track) that yields an
AudioStreamTrack configured for mono s16 (sample_rate=48000, channels=1,
format="s16"), and update test_recv_does_not_reallocate_buffer to accept that
fixture instead of constructing track locally; for
test_buffer_overflow_does_not_reallocate add a separate fixture or a
parameterized fixture (e.g., mono_s16_track_with_buffer_size or use request
param) to supply audio_buffer_size_ms=100 while keeping the same fixture
pattern, then remove direct constructions and use the fixtures in both tests.
Ensure fixture names match the test parameters and yield a ready-to-use track
for write/recv operations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86bd7fce-5ac8-4b9c-b080-e74f924e7e53

📥 Commits

Reviewing files that changed from the base of the PR and between 7262262 and 4c2969d.

📒 Files selected for processing (1)
  • tests/test_audio_stream_track.py

Identity check alone doesn't prove overflow path executed.
Add length assertion to verify buffer is trimmed to max size.
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