Skip to content

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

Open

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.

Activity

  1. yinkev commented on Jul 25, 2026

    @yinkev

    Taking this. I’ll keep the singleton AudioContext, disconnect each source/gain graph after playback, debounce suspension after the final sound, and cancel that suspension when another poof starts. I’ll add focused regression tests for cleanup and rapid replay. Sample-rate policy, offline decoding, and a native-audio rewrite stay out of scope.

  2. FabianHertwig commented on Jul 26, 2026

    @FabianHertwig

    Same root cause reproduces on macOS, with one difference that I think matters for the fix: no poof is required — the device is pinned from app launch.

    Environment

    macOS 26.5.2 (25F84), Apple silicon, Buzz 0.4.26 (xyz.block.buzz.app), built-in speakers. Line references below are against dd222a509.

    Symptom

    The laptop stopped idle-sleeping entirely and ran the battery flat overnight. From pmset -g log:

    • 2026-07-25 21:14:56 — wake (lid open), battery 38%
    • 21:33:18 — display off
    • no Sleep entry at all for the next 12.5 hours
    • 2026-07-26 09:45:02 — Entering Sleep state due to 'Low Power Sleep':TCPKeepAlive=inactive Using Batt (Charge:1%)

    A PreventUserIdleSystemSleep assertion spanned that entire window and was only released by the forced low-power sleep:

    2026-07-26 09:45:02 Assertions  PID 603(coreaudiod) TurnedOff PreventUserIdleSystemSleep
      "com.apple.audio.BuiltInSpeakerDevice.context.preventuseridlesleep" 12:30:05
    

    Nothing else in that window held a prevent-sleep assertion for more than ~7 minutes. (A second long holder was Apple's Siri speaker tap in corespeechd — unrelated to Buzz.)

    coreaudiod records the requesting process, and it is Buzz's WebKit GPU child:

    HALB_PowerAssertion::Take: taking power assertion ID 35988 of type
    'PreventUserIdleSystemSleep' with name:
    'com.apple.audio.BuiltInSpeakerDevice.context.preventuseridlesleep'
    on behalf of 9865
    

    …where PID 9865 was com.apple.WebKit.GPU, the renderer of buzz-desktop[9864].

    The macOS difference

    The issue body's step 2 says a suspended context opens no device, so construction alone is free, and the leak begins at the first poof (step 3). On macOS the device gets pinned without any poof at all. A fresh launch, from the unified log:

    13:11:24.—    buzz-desktop starts
    13:11:25.996  com.apple.WebKit.GPU  RemoteAudioDestination::RemoteAudioDestination(…)
    13:11:26.004  coreaudiod  IOWorkLoopInit: 11548 BuiltInSpeakerDevice (BuiltInSpeakerDevice): starting
    13:11:26.004  coreaudiod  Take: … 'com.apple.audio.BuiltInSpeakerDevice.context.preventuseridlesleep' on behalf of 79092
    13:11:26.035  com.apple.WebKit.GPU  RemoteAudioDestination::start(…)
    13:11:26.039  com.apple.WebKit.GPU  MediaPlayerPrivateAVFoundationObjC::setPreload(…) - 2
    

    RemoteAudioDestination is WebKit's Web Audio destination, so the assertion is attributable to the AudioContext and not to a media element — the <audio> preload from lines 151–153 only shows up 35 ms after the device was already open. RemoteAudioDestination::start() firing on its own, ~1.5 s after launch with no interaction, is the context going running by itself.

    Isolated repro

    I reduced it to a minimal WKWebView host that constructs the context exactly like getPoofAudioContext() (line 60) and plays nothing:

    const ctx = new AudioContext({ latencyHint: "interactive" });
    // report ctx.state now, and again after 2s
    WKWebViewConfiguration state at construction state after 2 s, nothing played speaker assertion
    mediaTypesRequiringUserActionForPlayback = .all (WebKit default) suspended suspended none
    mediaTypesRequiringUserActionForPlayback = [] (autoplay allowed) suspended running held for process lifetime

    So WebKit does hand back a suspended context, but when the embedder allows autoplay it auto-resumes on its own — no user gesture, nothing playing — and a running context pins BuiltInSpeakerDevice. Buzz doesn't set that flag itself (nothing under desktop/src-tauri/src touches it), so it takes the webview default, and the launch trace above shows it behaving like the second row.

    Why this matters for the fix

    PoofBurstProvider's mount effect calls loadPoofAudioBuffer() (line 149) purely to decode the sound, which constructs the hardware AudioContext (line 60) on every launch. On macOS that alone holds the machine awake for the whole session — a session that never fires a single poof still pins the device, so a fix keyed on playback ending won't reach it. I've left the specifics on #2907.

  3. eeSeeGee commented on Aug 12, 2026

    @eeSeeGee

    🤖 Third platform confirmation, on macOS 26.6.1 / arm64 / Buzz 0.5.10 — and a new consequence neither prior report covers: when the default output is a Bluetooth headset, the pinned device is the headset, so A2DP never idles.

    Reported by @eeSeeGee, who noticed it as "Buzz seems pretty aggressive in maintaining media streaming" and independently established that quitting Buzz is the only thing that releases it. Everything below is from his machine.

    The pinned device is the headset

    Earmuffs:
        Address: C8:7B:23:57:A3:5B
        Minor Type: Headphones
        Services: 0x800019 < HFP AVRCP A2DP ACL >
    

    pmset -g assertions, ten minutes after launch with nothing playing and no interaction:

    pid 420(coreaudiod): [0x0000f04700018537] 00:10:40 PreventUserIdleSystemSleep named:
      "com.apple.audio.C8-7B-23-57-A3-5B:output.context.preventuseridlesleep"
    	Created for PID: 95501.
    

    The assertion name carries the BT MAC, and Created for PID: 95501 attributes it directly. PID 95501 is Buzz's own WebKit GPU child:

      PID STARTED                      COMM
    95500 Wed Aug 12 15:03:46 2026     /Applications/Buzz.app/Contents/MacOS/buzz-desktop
    95501 Wed Aug 12 15:03:46 2026     .../com.apple.WebKit.GPU.xpc/Contents/MacOS/com.apple.WebKit.GPU
    

    Pinned 133 ms after launch, before anything plays

    This corroborates @FabianHertwig's key correction — no poof is required. Unified log, same second as launch:

    15:03:46.684 com.apple.WebKit.GPU[95501] RemoteAudioDestination::RemoteAudioDestination(15279342989782274178)
    15:03:46.691 coreaudiod[420] HALB_PowerAssertion::Take: taking power assertion ID 34103 of type
                 'PreventUserIdleSystemSleep' with name:
                 'com.apple.audio.C8-7B-23-57-A3-5B:output.context.preventuseridlesleep' on behalf of 95501
    15:03:46.817 com.apple.WebKit.GPU[95501] RemoteAudioDestination::start(15279342989782274178)
    

    The assertion lands 7 ms after the Web Audio destination is constructed and 126 ms before it even start()s. RemoteAudioDestination is WebKit's Web Audio destination, so this is attributable to the AudioContext, not to the <audio> element — same conclusion Fabian reached from the 35 ms ordering gap.

    Worth noting the original reports were against 0.4.26. This is 0.5.10, so nothing has incidentally fixed it in the interim.

    A sharper way to state the root cause

    There are four AudioContext construction sites in desktop/src on main:

    Site Lifecycle
    features/huddle/HuddleContext.tsx:790 ctx.close() at 852
    features/huddle/lib/audioWorklet.ts:56 audioContext.close() at 131
    features/huddle/lib/useHuddlePttState.ts:41 close() at 63
    shared/ui/PoofBurstProvider.tsx:60 neither suspend() nor close(), ever

    The three huddle contexts are created on demand and torn down. PoofBurstProvider's is the only one created unconditionally at app mount (via loadPoofAudioBuffer() in the mount useEffect) and the only one with no teardown at all. latencyHint: "interactive" also requests the smallest buffer, so the device is held at low latency — which is what the user perceived as "aggressive."

    (Enumeration is from GitHub code search over main, so I'd treat "exactly four" as "the four I can find" rather than proven exhaustive. The PoofBurstProvider finding itself is direct from the file.)

    Fix coverage — the two open PRs are complementary, not competing

    This user hit both symptoms, and they turn out to need both PRs:

    Symptom Mechanism Fixed by
    BT headset never leaves streaming mode; laptop won't idle-sleep mount-time AudioContext, never suspended #2907
    hardware play/pause replays a Buzz sound; localhost in Now Playing HTMLAudioElement auto-adopted by the Media Session #4916
    • fix(desktop): suspend idle poof audio context #2907 suspends the idle context. Its armIdleSuspend() is called at construction with an explicit statechange listener commented "WebKit may start an autoplay-enabled context without any playback" — which is exactly the pinned-from-launch path above, so it does cover this issue on macOS. It keeps the HTMLAudioElement fallback, so it does not address the media-key symptom.
    • fix(desktop): keep UI sounds out of media controls #4916 removes every HTMLAudioElement path (notifications plus the poof preload), but its PoofBurstProvider still calls loadPoofAudioBuffer() on mount, so the context is still created at launch and never suspended. It does not fix this issue.

    So neither PR alone resolves the user-visible complaint. Both are currently open with zero reviews (#2907 for 18 days, #4916 for 7).

    Merge hazard: #2907 and #4916 both edit PoofBurstProvider.tsx and disagree in intent — #2907 preserves the HTML fallback that #4916 deletes. Whoever lands second needs to reconcile behavior, not just resolve a text conflict. Separately, #5222 duplicates the notification half of #4916 (independent report, keeps an HTML fallback); one of those two probably wants closing.

    Not verified here

    I did not drive the hardware media key or read Now Playing state on this machine, and I did not build any of the three PR branches. The media-key half is from source inspection on main plus the existing reports; the device-pinning half is the live capture above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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