[Bug][Linux/AppImage] Renderer leaks one /memfd:pulseaudio per notification; 35k fds in 4 days kills WebKitWebProcess at the default 1024 NOFILE
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
**Describe the bug**
On Linux (AppImage, WebKitGTK), the `WebKitWebProcess` renderer leaks **one `/memfd:pulseaudio` file descriptor per notification** and never releases it. After a 4-day uptime on this machine, **34,952 of the renderer's 35,020 open fds (99.8%)** were leaked pulse memfds.
This is fatal in practice because GLib restores the 1024 compatibility soft `RLIMIT_NOFILE` for processes it spawns, regardless of the parent's limit — so an app launched from the GNOME icon runs at **soft 1024** even though `gnome-shell` itself is at 524288. At the leak rate seen here, a busy workspace exhausts 1024 fds in **~35 minutes**, and the renderer dies:
```
Aug 24 21:57:41 WebKitWebProcess 3949264 starts
Aug 24 22:33:01 Failed to load cookie file from cookie: Too many open files
Aug 24 22:33:01 kernel: traps: WebKitWebProces[3949264] general protection fault in libc.so.6
```
`WebKitNetworkProcess` and all seven `buzz-acp` agent children stayed alive and healthy for the next 21 hours. So the user-visible symptom is a **permanently frozen window while the agents keep answering on the relay** — this presents as #4359, but the cause is an fd leak, not an unexplained segfault.
**Steps to reproduce**
1. Run the Linux AppImage on a desktop that spawns it at the default soft `RLIMIT_NOFILE` of 1024 (GNOME launcher, Wayland).
2. Be a member of several busy channels — anything that generates a steady stream of notifications. Managed agents make this fast.
3. Watch the renderer's fds: `watch -n60 'ls /proc/$(pgrep -f WebKitWebProcess | head -1)/fd | wc -l'`
4. Census what they are: `for f in /proc//fd/*; do readlink $f; done | sort | uniq -c | sort -rn | head`
5. The count climbs monotonically, ~99% `/memfd:pulseaudio (deleted)`, and is never reclaimed. At 1024 the renderer dies; the window freezes and the backend stays up.
**Expected behavior**
Notification sounds should not retain a PulseAudio client context after playback (or after a failed load). The renderer's fd count should be bounded across long uptimes.
**Version and platform**
- Buzz version: 0.5.18 (AppImage). 0.5.20 is installed but not yet exercised, so I can't say whether it changes this.
- OS: Ubuntu, kernel 7.0.0-30-generic, x86_64, GNOME/Wayland
- Audio: `pipewire` + `pipewire-pulse` (PulseAudio API; no `pulseaudio` daemon)
- GStreamer: host plugins, e.g. `/usr/lib/x86_64-linux-gnu/gstreamer-1.0/libgstpulseaudio.so`, mapped into the renderer
- Workload: 15 channels, 7 managed agents
**Logs / additional context**
fd census after 4d 10h uptime (`readlink` over every entry in `/proc//fd`):
```
34952 /memfd:pulseaudio (deleted)
30 socket:[N]
15 anon_inode:[eventfd]
6 anon_inode:[timerfd]
5 pipe:[N]
4 /dev/urandom
3 /dev/dri/renderD128
2 /memfd:WebKitSharedMemory (deleted)
```
Daily growth, from a 10-minute sampler:
| Date | fds at start | fds at end | growth |
|---|---|---|---|
| 2026-08-26 | 645 | 10,578 | +9,933 |
| 2026-08-27 | 10,578 | 27,939 | **+17,361** |
| 2026-08-28 | 27,939 | 31,835 | +3,896 |
| 2026-08-29 | 31,835 | 34,983 | +3,148 |
**It tracks notifications, not time.** Per-10-minute deltas across a night and the following morning, with the app running and connected throughout — the only variable is whether messages were arriving:
```
23:10 +0 23:20 +0 ... 56 consecutive zero buckets, 23:10 -> 08:20 ... 08:20 +0
08:30 +30 <- first messages of the day arrive
08:40 +0
08:50 +0
09:00 +7 <- two more messages
```
Nine idle hours: zero growth. Two bursts of traffic: +37.
**Likely mechanism.** `libgstpulseaudio.so` is mapped into the renderer, so notification audio runs through WebKitGTK media playback → GStreamer `pulsesink` → `libpulse`, and `libpulse` allocates a memfd per client context. One leaked memfd therefore looks like one audio pipeline that was created and never torn down (element not disposed / pipeline never set to `GST_STATE_NULL`, so the `pa_context` is never freed).
This may compound with #2562 (*Linux: notification sounds never play, WebKitGTK can't load media from Tauri's custom URI scheme*): on this machine notification sounds are **not audible**, yet every notification still allocates a pulse context. A media element whose load fails may be exactly the path that never reaches teardown — the sound the user never hears still costs a permanent fd.
**Suggested fix**
1. Reuse a single long-lived `HTMLAudioElement` for the notification sound instead of constructing one per notification; or
2. release it explicitly after playback — `pause()`, clear `src`, `load()`, drop the reference — and make sure the **error path** does the same, since the failing-load case appears to be the leaking one here.
3. Independently: a dead `WebKitWebProcess` should not present as a silent frozen window (#4359), and the AppImage launcher could raise `RLIMIT_NOFILE` at startup, since GLib resets spawned children to 1024 regardless of what the launching shell or systemd unit set.
**Workaround for other Linux users**
Raise the soft limit in the `Exec=` target of the `.desktop` file, which is the one path every launch goes through:
```sh
ulimit -S -n "$(ulimit -H -n)" 2>/dev/null || true
exec /opt/Buzz/Buzz_x.y.z_amd64.AppImage "$@"
```
That is a delay, not a cure — at ~7,600 leaked fds/day it buys roughly 69 days instead of 35 minutes. Restarting the app resets the count.
**Related**
- #4359 — renderer crash surfaces as a silent dead window (this is one concrete cause of it)
- #2562 — notification sounds never play on Linux/WebKitGTK (likely the same code path)
- #2560 — mixed GStreamer dependencies on Ubuntu
- #6272 — different resource, same process: renderer memory growth
Contributor guide
Assessment
This issue has not been assessed yet.