Fixing a simple memory leak in alsa_rs

2024-11-25

druskus

I recently wrote a Rust wrapper of libltc by x42 to use in my master's thesis. The fact that the original library uses only * mut pointers made me realize how much I like Rust's explicitness. In the C library, there is no guarantee that a given pointer won't mutate the data it points to, and that is left to a mix of reading the code, and making assumptions.

While using the library on my project, I tried to pay attention to whether the memory was being freed correctly and I noticed several memory leaks. They did not come, however from my humble bindings, but rather from cpal - which I was using to play the audio. The output of valgrind was something like this:

(...)
==1297036== 1,512 bytes in 21 blocks are possibly lost in loss record 142 of 164
==1297036==    at 0x484D953: calloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==1297036==    by 0x48B3D92: ??? (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x48C4C44: ??? (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x48C5289: ??? (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x48CFD84: snd_config_update_r (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x48D0447: snd_config_update_ref (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x48FB35F: snd_pcm_open (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==1297036==    by 0x4E95A4: alsa::pcm::PCM::open (error.rs:21)
==1297036==    by 0x4E94A0: alsa::pcm::PCM::new (pcm.rs:147)
==1297036==    by 0x4D9F04: cpal::host::alsa::DeviceHandles::try_open (mod.rs:214)
==1297036==    by 0x4DA099: cpal::host::alsa::DeviceHandles::take (mod.rs:233)
==1297036==    by 0x4DA372: cpal::host::alsa::Device::build_stream_inner (mod.rs:251)

# Fixing the leak

The leak happens in libasound (alsa's C library). The snd_pcm_open function allocates memory for a pulse code modulation (PCM) device.

  1. Who's fault is it? - me?, cpal? alsa_rs? libasound?...
  2. Is this fixed already? How easy is it to fix?
  3. How did audio on Linux work again?

# Going straight to the source

I started by creating a program using alsa_rs alone, removing cpal from the mix, I order to confirm that the leak was still there.

fn main() {
    let _ = alsa::PCM::new("default", alsa::Direction::Capture, false).unwrap();
    // PCM::drop is called by the borrow checker
}

Running valgrind --leak-check=full --show-leak-kinds=all ./alsa_leak_test showed the same leak as before (and a bunch of others).

(...)
==625479== LEAK SUMMARY:
==625479==    definitely lost: 1,560 bytes in 22 blocks
==625479==    indirectly lost: 1,670 bytes in 12 blocks
==625479==      possibly lost: 76,900 bytes in 2,360 blocks
==625479==    still reachable: 37,113 bytes in 68 blocks
==625479==         suppressed: 0 bytes in 0 blocks
==625479== Reachable blocks (those to which a pointer was found) are not shown.
==625479== To see them, rerun with: --leak-check=full --show-leak-kinds=all
==625479==
==625479== ERROR SUMMARY: 114 errors from 114 contexts (suppressed: 0 from 0)

So the problem is still there, confirming that the leak is not cpal's responsibility. Great. Now let's figure out if the leak is an alsa_rs issue or an alsa issue.

I replicated my example code to C, and linked to the alsa library on my system.

#include <alsa/asoundlib.h>
#include <stdio.h>

int main() {
  snd_pcm_t *handle;
  int err;
  err = snd_pcm_open(&handle, "default", SND_PCM_STREAM_CAPTURE, SND_PCM_NONBLOCK);
  err = snd_pcm_close(handle);
  return 0;
}

Still there. Same exact leak. It was at this point when I realized, snd_pcm_open is actually allocating memory for a global configuration struct. This struct is not freed on snd_pcm_close, but rather on snd_config_update_free_global. This is a naming issue snd_pcm_open does not communicate that it is allocating memory for a global. The docs probably mention something about it (but who reads docs right?).

Adding a function call to snd_config_update_free_global after snd_pcm_close seems to do something. The output of valgrind now looks like this:

(...)
==647830== 17,172 (16,616 direct, 556 indirect) bytes in 1 blocks are definitely lost in loss record 38 of 38
==647830==    at 0x484D953: calloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==647830==    by 0x510ECE8: ???
==647830==    by 0x5129096: ???
==647830==    by 0x510F818: ???
==647830==    by 0x508967B: ???
==647830==    by 0x48CB6CD: ??? (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==647830==    by 0x48CBD6D: ??? (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==647830==    by 0x48FB37E: snd_pcm_open (in /usr/lib/x86_64-linux-gnu/libasound.so.2.0.0)
==647830==    by 0x1091E0: main (a.c:7)
==647830==
==647830== LEAK SUMMARY:
==647830==    definitely lost: 18,185 bytes in 24 blocks
==647830==    indirectly lost: 2,226 bytes in 21 blocks
==647830==      possibly lost: 0 bytes in 0 blocks
==647830==    still reachable: 16,224 bytes in 43 blocks
==647830==         suppressed: 0 bytes in 0 blocks
==647830==
==647830== For lists of detected and suppressed errors, rerun with: -s
==647830== ERROR SUMMARY: 11 errors from 11 contexts (suppressed: 0 from 0)

Great. So that's 114 errors down to 11. It's slightly misleading that the amount of definitely lost bytes is actually larger than before, but possibly lost is now 0, which tells me that probably the memory is being freed correctly better than before.

But just to be sure, I wanted to build the latest version of libasound from source and see if there had been any changes, and if the "leftover leaks" were still there.

# Building libasound from source

I am not very familiar with the C build process, so I started by cloning the alsa-lib repository. My system (Ubuntu 24) is using libasound 1.2.11 while the latest version at the moment is 1.2.13.

I figured out that the project is meant to be built with GNU autoconf so I generated the configure script and ran it. I also added a flag to enable pipewire support, as I am using pipewire on my system.

autoreconf -i
./configure --prefix=$HOME/local/alsa-lib-1.2.13 --enable-pipewire
make 
make install

In order to link to the newly built library, I set LD_LIBRARY_PATH=$(HOME)/local/alsa-lib-1.2.13/lib:$$LD_LIBRARY_PATH before compiling. It is useful to double check with ldd that the binary is linked against the right library. Running the same C code as before, I got the same valgrind result as before

# Final steps

For the last 11 errors in valgrind I eventually got the idea to try opening a device different from "Default". The devices can be listed with aplay -L, I tried to choose a device as raw as possible.

hw:CARD=Generic,DEV=9
    HD-Audio Generic, HDMI 3
    Direct hardware device without any conversions

Turns out, when using this device, all the rest of the leaks are gone. This confirms that the solution above is correct, and that the "leftover leaks" are caused by pipewire, pipewire-pulse or any of it's siblings. At this point I had already spent more time than I wanted on this, so I decided to leave it there for now.

I would eventually like to see if upgrading the rest of the audio libraries to the latest version will fix this problem.

🦆