[Bug] Idle desktop app burns ~4% CPU and pins the audio device awake
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
Linux client seems to chew up a lot of CPU doing nothing at all, here's a slop report that might help a bit.
Attached report from opus:
# Idle desktop app burns ~4% CPU and pins the audio device awake: poof-sound `AudioContext` is never
suspended
## Summary
After the first UI "poof" click, `PoofBurstProvider`'s `AudioContext` stays in the
`running` state for the remainder of the app's lifetime. `.suspend()` is never
called on it — in fact `.suspend()` does not appear anywhere in `desktop/src`.
On Linux, WebKitGTK routes WebAudio through GStreamer, so a running context means
the audio graph is pumped continuously whether or not anything is playing. Measured
on an otherwise idle app that has played no sound for ~22 minutes:
- **3.91% of one CPU core**, sustained, in the WebAudio/GStreamer threads
- **~0.5%** more in `pipewire-pulse`, charged to that process rather than to Buzz
- the ALSA device is held in `RUNNING` state permanently, which blocks the audio
codec and CPU from reaching deep idle — the battery cost on laptops is larger
than the CPU number alone suggests
The audio work is ~71% of the idle renderer's total CPU. All of it renders silence.
## Evidence
Version 0.4.26 (`.deb`), Ubuntu 24.04, `libwebkit2gtk-4.1-0` 2.52.3, PipeWire.
App open ~1h, idle, nothing playing.
Per-thread CPU in the `WebKitWebProcess`, sampled from `/proc//stat` over a
22-minute window with no interaction:
| Thread | % of one core |
|---|---|
| `webaudioSrc:src` | 2.53% |
| `queue0:src` | 1.37% |
| `webaudioSrcTask` | (included above) |
| **audio subtotal** | **3.91%** |
| `WebKitWebProces` (renderer main) | 2.04% |
The corresponding PipeWire node, which persists for the app's whole lifetime:
```
node 75
application.process.id:
application.name: buzz-desktop
media.class: Stream/Output/Audio
media.name: Playback Stream
state: running
format: F32LE 2 44100
```
`pw-top` shows this node marked `+`, i.e. **Buzz is the driver of the audio
graph**. It drags the ALSA sink out of its idle `C` state into `R` in lockstep:
```
S ID QUANT RATE WAIT BUSY ERR FORMAT NAME
R 51 1024 48000 155.4us 22.2us 9 S32LE 2 48000 alsa_output.…analog-stereo
R 75 1764 44100 119.0us 31.5us 0 F32LE 2 44100 + buzz-desktop
```
Note the rate mismatch: the context runs at 44.1 kHz into a 48 kHz sink, so
PipeWire resamples continuously as well. The sink has logged 9 xruns.
## Root cause
All line numbers below are against `desktop/src/shared/ui/PoofBurstProvider.tsx`
at `2f004159`.
1. `PoofBurstProvider` wraps the app root (`desktop/src/main.tsx:80`), so its
module-level singleton at line 20 lives for the whole session.
2. Line 60 constructs `new AudioContext({ latencyHint: "interactive" })`.
WebKitGTK creates contexts in the `suspended` state, and a *suspended* context
opens no device — so this alone is free.
3. On the first poof, `playPoofSound` (line 110) sees `state === "suspended"` and
calls `resume()` at line 126. A click is a user gesture, so it succeeds and the
context goes `running`.
4. Nothing ever suspends it again. From that point the device stays open forever.
This matches the observed timeline exactly: the app launched at 11:27 but the
PipeWire node appeared at 11:29 — first click, not mount.
`latencyHint: "interactive"` requests the smallest possible buffer, which
maximises wakeup frequency — the worst setting for something left running idle.
### Secondary: the render graph grows by two nodes per poof
In `playPoofSound`, the `AudioBufferSourceNode` and `GainNode` are connected to
`destination` (lines 119–124) and never disconnected. Every click permanently adds
two nodes to the graph, so per-quantum render cost climbs monotonically over a long
session on top of the fixed cost above.
### Secondary: no explicit `sampleRate`
Leaving `sampleRate` unset makes WebKitGTK default to 44.1 kHz, forcing the
continuous resample described above. `desktop/public/worklet.js` already documents
an assumption that contexts run at 48 kHz, so the two paths disagree.
## Not a memory leak
Worth recording since it's the obvious next suspicion: the renderer sits at
~730 MB RSS / 527 MB private-dirty, but that is **stable, not growing**. Over the
same 22-minute window RSS ranged 692–793 MB and drifted **+6 MB** net — GC
sawtooth, not a leak.
The high baseline looks like frontend bundle weight rather than a runtime fault.
The shipped binary embeds 416 frontend assets including 86 Shiki language grammars
and roughly 20 Shiki themes that each appear at **two different content hashes**
`pw-top` shows this node marked `+`, i.e. **Buzz is the driver of the audio
graph**. It drags the ALSA sink out of its idle `C` state into `R` in lockstep:
```
S ID QUANT RATE WAIT BUSY ERR FORMAT NAME
R 51 1024 48000 155.4us 22.2us 9 S32LE 2 48000 alsa_output.…analog-stereo
R 75 1764 44100 119.0us 31.5us 0 F32LE 2 44100 + buzz-desktop
```
Note the rate mismatch: the context runs at 44.1 kHz into a 48 kHz sink, so
PipeWire resamples continuously as well. The sink has logged 9 xruns.
## Root cause
All line numbers below are against `desktop/src/shared/ui/PoofBurstProvider.tsx`
at `2f004159`.
1. `PoofBurstProvider` wraps the app root (`desktop/src/main.tsx:80`), so its
module-level singleton at line 20 lives for the whole session.
2. Line 60 constructs `new AudioContext({ latencyHint: "interactive" })`.
WebKitGTK creates contexts in the `suspended` state, and a *suspended* context
opens no device — so this alone is free.
3. On the first poof, `playPoofSound` (line 110) sees `state === "suspended"` and
calls `resume()` at line 126. A click is a user gesture, so it succeeds and the
context goes `running`.
4. Nothing ever suspends it again. From that point the device stays open forever.
This matches the observed timeline exactly: the app launched at 11:27 but the
PipeWire node appeared at 11:29 — first click, not mount.
`latencyHint: "interactive"` requests the smallest possible buffer, which
maximises wakeup frequency — the worst setting for something left running idle.
### Secondary: the render graph grows by two nodes per poof
In `playPoofSound`, the `AudioBufferSourceNode` and `GainNode` are connected to
`destination` (lines 119–124) and never disconnected. Every click permanently adds
two nodes to the graph, so per-quantum render cost climbs monotonically over a long
session on top of the fixed cost above.
### Secondary: no explicit `sampleRate`
Leaving `sampleRate` unset makes WebKitGTK default to 44.1 kHz, forcing the
continuous resample described above. `desktop/public/worklet.js` already documents
an assumption that contexts run at 48 kHz, so the two paths disagree.
## Not a memory leak
Worth recording since it's the obvious next suspicion: the renderer sits at
~730 MB RSS / 527 MB private-dirty, but that is **stable, not growing**. Over the
same 22-minute window RSS ranged 692–793 MB and drifted **+6 MB** net — GC
sawtooth, not a leak.
The high baseline looks like frontend bundle weight rather than a runtime fault.
The shipped binary embeds 416 frontend assets including 86 Shiki language grammars
and roughly 20 Shiki themes that each appear at **two different content hashes**
(`vitesse-light`, `tokyo-night`, `rose-pine`, `solarized-*`, …), which suggests two
import paths causing Vite to emit duplicate copies. That is inferred from the asset
manifest and has not been confirmed against the source — a separate issue if
someone wants to chase the memory baseline.
## Suggested fix
Keep the singleton context (the comment in `HuddleContext.tsx` about the ~6
concurrent-context limit is a good reason not to create one per click), but stop
leaving it running:
1. **Suspend when idle.** After playback ends, `suspend()` the context on a short
debounce (~1.5s) so rapid clicks don't thrash it, and `resume()` on the next
poof. `playPoofSound` already handles the resume-from-suspended case, so this
closes the loop the existing code anticipates.
2. **Disconnect finished nodes** in an `ended` handler so the graph stays flat.
3. **Set `sampleRate: 48000`** explicitly, matching `worklet.js`, to drop the
resample.
4. **Decode via `OfflineAudioContext`.** `decodeAudioData` only needs a
`BaseAudioContext`; an offline one never opens a device, so the startup preload
can't touch audio hardware at all.
An alternative worth considering: the Rust side already links `rodio` + `cpal`, so
routing UI chimes through a Tauri command would remove this WebAudio path
entirely. Larger change, cleaner end state.
## Reproduce
```bash
# 1. Open Buzz. Confirm no audio stream exists yet:
pw-dump | jq -r '.[] | select(.info.props."media.class" == "Stream/Output/Audio")
| "\(.info.props."application.name") \(.info.state)"'
# 2. Click anything that poofs — e.g. hover a sidebar card and click its × dismiss
# button (the trigger lives on sidebar-action-card.tsx and SelectedRecipientChip).
# 3. Re-run the command above. A `buzz-desktop … running` stream now exists, and
# stays for as long as the app runs. Also visible in `pw-top` as the graph driver.
# 4. Watch the cost with no further interaction:
top -H -p "$(pgrep -f 'WebKitWebProcess' | head -1)"
# → webaudioSrc:src, webaudioSrcTask and queue0:src accumulate CPU indefinitely
```
## Notes
- Evidence is Linux/WebKitGTK-specific, where WebAudio goes through GStreamer.
The underlying defect — never calling `suspend()` — is platform-independent, but
the cost profile on macOS/CoreAudio will differ and is unmeasured.
- The other three `AudioContext` sites are correctly scoped and are **not**
implicated: `huddle/lib/audioWorklet.ts` requests 48 kHz and closes at line 130,
and both `HuddleContext.tsx` contexts are gated on `micConnected` and closed in
effect cleanup.
- Observed on `main` @ `2f004159`.
Contributor guide
Research direction
Start in desktop/src/shared/ui/PoofBurstProvider.tsx and inspect its use from desktop/src/main.tsx, then review desktop/public/worklet.js for the 48 kHz assumption. Reproduce the persistent stream with the provided pw-dump and pw-top commands after a poof click. Done means idle audio no longer keeps the device running, finished nodes do not accumulate, and the relevant audio behavior is covered by tests or documented verification.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- audio-video-rtc, desktop, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100