Skip to content

[Bug] Idle desktop app burns ~4% CPU and pins the audio device awake #2868

Description

@instagibbs

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/<tid>/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: <WebKitWebProcess>
  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

# 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions