Menu ▾ ▴

#234 macOS: Messenger's idle AudioContext holds the Bluetooth route and can stall output (foreground; #232 follow-up)

closed
nobody
2026-08-22
2026-08-17
Anonymous
No

Originally created by: kristofferR

Summary

A second, distinct macOS Bluetooth-audio failure, separate from the one fixed in [#232] (v1.9.0). While Carrier is the foreground / active audio owner, Messenger's always-on AudioContext keeps an active macOS AudioSession for the page. That idle session:

  1. hijacks the Bluetooth route from whatever you're actually listening to (e.g. music/video in another app), restarting the A2DP stream on every swap; and
  2. is a real-time IO client rendering silence 24/7 that, under CPU pressure, trips a HAL overload (SafetyViolationOccurred) which stalls the shared output device — silencing AirPods system-wide.

[#232] / v1.9.0 fixed the hidden-window mechanism (the process suspend/resume flip after the 240 s "recently visible" grace) via background_throttling(Disabled). That fix is intact and correct. This report is the foreground mechanism, which that fix does not touch.

Reproduction

  • Carrier open, Bluetooth output (AirPods) selected.
  • Play audio in another app (browser, music) through the same output.
  • Observed: audio drops out / routes fight; a HAL-overload burst coincides with the dropout.

Evidence (macOS 27, installed v1.10.0)

Carrier's WebKit GPU process owns a Bluetooth audio session:

# launchctl procinfo <gpu-pid> | grep "responsible path"
responsible path = /Applications/Carrier.app/Contents/MacOS/carrier

audioaccessoryd route snapshots flip between Carrier and the other app:

AudioStateSnapshot: BtState PoweredOn Route Bluetooth App com.apple.WebKit.GPU,                Score 201
AudioStateSnapshot: BtState PoweredOn Route Bluetooth App org.mozilla.firefoxdeveloperedition, Score 301

The overload client is a pure-silence real-time client (i.e. the idle AudioContext, not active playback), and the burst is the dropout:

coreaudiod HALS_OverloadMessage: Overload  (235 in one minute, 170 the next)
cause: ClientHALIODurationExceededBudget, SafetyViolationOccurred
num_continuous_silent_io_cycles: 227386   num_continuous_nonzero_io_cycles: 0

WebKit sets the page's session AmbientSound + setEligibleForSmartRouting(true) and marks it tryToSetActive(0) is active = true at context creation.

Root cause

WebKit keeps an active AudioSession for the page for as long as an AudioContext exists, in any state. Messenger creates one eagerly at module-eval (qS_TdTobCGT.js, global code — not a React effect) and never closes it. So even a silent, idle Messenger tab is a Bluetooth-route candidate and a real-time IO client.

What does NOT work (verified on a debug build)

Suspending the AudioContext from the page does not release the session. Built an AudioContext idle-gate (suspend when no sound is scheduled, resume on start()/media/call) and watched the GPU's WebKit Media logs:

  • AudioContext.suspend() produced no RemoteAudioDestination::stop and no tryToSetActive(0) is active = false; the session stayed active = true.
  • Same wall as the hidden case: a suspended context stays registered, so the session (and the route ownership) persists.
  • Only close() removes the session — but that permanently breaks Messenger's sound engine (one shared context; the page holds a reference), so it is not viable.

WIP idle-gate preserved on branch fix/web-audio-idle-route for the record; not shipped.

Candidate fix (native, needs evaluation)

The only remaining lever is native: [WKWebView setAllMediaPlaybackSuspended:YES] while Carrier is not the active audio owner, resumed on focus / call. Reachable via the existing window.with_webview(|wv| msg_send![wv.inner(), ...]) pattern (cf. macos/theme.rs, macos/share.rs).

Open questions before building:

  • Does it actually deactivate the AudioSession? (JS suspend() did not — this must be verified, not assumed.)
  • Call safety. It suspends all webview media, including live WebRTC calls, so it must be gated on !inCall and on focus/visibility. Call state currently lives page-side (window.__carrierInCall / carrier:protection-change) and would need to reach the native side.
  • Interaction with the existing background_throttling(Disabled) hidden-window fix.

Ref [#232]

Related

Tickets: #232
Tickets: #235
Tickets: #439

Discussion

  • Anonymous

    Anonymous - 2026-08-17

    Originally posted by: kristofferR

    Investigation update: no page-side fix; standard native lever also fails

    Tested every plausible lever on debug builds with WebKit Media logging + audioaccessoryd tracing. Result: there is no available way to make Messenger's idle AudioContext release its macOS AudioSession short of navigating/reloading the webview.

    1. AudioContext.suspend() (page JS) — does NOT release the session

    Hooked the constructor, suspended when no sound is scheduled. On suspend WebKit logged no RemoteAudioDestination::stop and no tryToSetActive(0) is active = false; the session stayed active = true. A suspended context stays registered, so the route ownership persists. (Same wall as the hidden-window case.)

    2. AudioContext.close() (page JS) — releases the session, but unusable

    close() does work at the WebKit level: MediaSessionManagerInterface::removeSession fires and the WebAudio(n) count drops. But:

    • It is permanent — Messenger holds the reference and never recreates its sound engine, so all in-app sound (message pops, call ringtones) would be dead.
    • It cannot reach every context. WebContent registers two WebAudio sessions, but only one AudioContext comes through the wrapped window.AudioContext / webkitAudioContext / OfflineAudioContext constructors in the top frame (no iframe context observed). Closing the one we can see leaves the second alive, so the session never deactivates. The second context is created via a path page JS can't intercept.

    3. Native -[WKWebView setAllMediaPlaybackSuspended:completionHandler:] — does NOT touch Web Audio

    Wired it to the main window's focus/blur (macOS). respondsToSelector = true and it is called on blur, but the AudioContext-owning WebContent logs zero Media activity in response — the API only suspends <audio>/<video> media elements, not AudioContext. The session stays active. (Earlier "route owner became App NA" readings were a red herring — just the other app not playing at that instant.)

    Root cause (restated)

    WebKit keeps an active AudioSession (category AmbientSound, setEligibleForSmartRouting(true)) for the page as long as any AudioContext exists, in any state. Suspending doesn't drop it; only closing the context (or navigating away) does.

    Only remaining working fix — disruptive, needs a decision

    Navigate/reload the Messenger webview when it has been idle + hidden + not in a call for a while: navigation drops all AudioContexts and releases the session. Carrier already recreates the webview for theme changes and login survives via cookies. Cost: a page reload loses scroll position / unsent drafts / open UI, so it can only run when the user is definitely not looking, and even then it is a real UX tradeoff.

    Deferring implementation pending a call on whether that tradeoff is worth it versus documenting this as a WebKit/Messenger limitation. Investigation preserved on branch fix/web-audio-idle-route (commits 128572a, 5043701), not shipped.

     
  • Anonymous

    Anonymous - 2026-08-17

    Originally posted by: kristofferR

    Two more levers tested — and a fundamental conflict

    4. -[WKWebView _setPageMuted:] (audio-muted bit, on blur/hide) — respondsToSelector = true, called correctly, but produces zero Media activity for the AudioContext's WebContent: no RemoteAudioDestination::stop, no session deactivate, no removeSession. Muting the page does not release the WebAudio session or stop the silent render.

    5. Close all contexts — enumerated every AudioContext from the node side (AudioNode.connect + every create*), which catches any construction path in the frame. Only one JS AudioContext exists (top frame). Yet WebKit tracks two WebAudio sessions; closing the single reachable context removes one and leaves the other active. So even close-all cannot drive the session count to 0 from page JS — a second WebAudio session with no JS handle persists.

    The fundamental conflict

    The OS audio session is released only by tearing down the FB page (navigate away, or suspend the WebContent process). But:

    • Navigating away / reloading drops the session — then the freshly-loaded page recreates an AudioContext, so the session comes right back. It's only gone during the reload.
    • Suspending the WebContent process (or navigating to blank) releases the session and keeps it released — but a suspended/blank page can't run Messenger's realtime connection, so new-message notifications stop while hidden, which is Carrier's core purpose.

    So: release the audio session ⟺ tear down the page ⟺ lose hidden notifications. You cannot have both with WebKit's current behavior. There is no page-side or public-native lever that makes an existing, page-held AudioContext stop owning the macOS audio session while the page keeps running.

    Practical options

    1. Document as a WebKit/Messenger limitation (this issue) and revisit if WebKit exposes a per-webview routing/eligibility control.
    2. Suspend the WebContent when hidden + not-in-call, accepting that Carrier stops delivering new-message notifications while hidden (turns it into a foreground-only client while hidden). Releases the route cleanly.
    3. Keep as-is; the acute "audio disappeared" event is a HAL overload that is mostly triggered by heavy system CPU load, less common in ordinary use.

    Investigation branch fix/web-audio-idle-route (commits 128572a, 5043701, ab57160), not shipped.

     
  • Anonymous

    Anonymous - 2026-08-17

    Originally posted by: kristofferR

    Note to future agents (from Claude Fable 5, who did not ship a fix here)

    I spent a long session on this and came up empty on a clean fix. That's not a reason for the next model to confidently charge in and repeat the same dead ends — read this before you "just disable the AudioContext on the page," because that is exactly the move that does not work.

    Everything below is verified on live debug builds against WebKit's own com.apple.WebKit:Media logs + audioaccessoryd routing snapshots, not guessed:

    • AudioContext.suspend() — session stays active, no RemoteAudioDestination::stop, no tryToSetActive(0) false. A suspended context stays registered. Does not release the route.
    • AudioContext.close() on every context you can reach — close() does remove a session, but there is only one JS-reachable AudioContext in the top frame (confirmed by enumerating from AudioNode.connect + every create*, not just the constructor) while WebKit tracks two WebAudio sessions. Closing the reachable one leaves the second active. And close() is permanent — Messenger never rebuilds its sound engine. Dead end.
    • -[WKWebView setAllMediaPlaybackSuspended:completionHandler:] — only touches <audio>/<video> elements. Zero effect on Web Audio. Dead end.
    • -[WKWebView _setPageMuted:] — responds, gets called, zero Media activity, silent render continues. Dead end.

    The wall is structural, not a missing clever trick: the macOS audio session is released only by tearing down the page (navigate away / suspend the WebContent), and doing that stops Messenger's realtime connection, so hidden-window notifications die. Free the Bluetooth route ⟺ kill hidden notifications. Pick one. There is no public API to make a single webview's audio session non-route-eligible.

    So, to the other models that autofix this repo: by all means find the thing I missed — a private WebKit SPI, a routing-eligibility flag, something. But do it by reading this thread and beating the evidence, not by reflexively reaching for ctx.suspend() / ctx.close() / mute and declaring victory without checking the session actually deactivated. That check is the whole ballgame, and all four "obvious" fixes fail it. Don't be that agent. Verify with the logs or don't touch it.

    — Fable

     
  • Anonymous

    Anonymous - 2026-08-18

    Originally posted by: kristofferR

    Correction: I was wrong about suspend(). It does release the session.

    Retracting a central claim from my earlier comments — including, with some irony, the one where I told other agents not to reflexively reach for ctx.suspend(). That advice was based on a wrong measurement. suspend() works. My gate was broken.

    The minimal repro that settled it

    A 30-line page in plain Safari (no Carrier, no Messenger): one AudioContext, zero audio nodes ever created or connected, nothing ever audible.

    16:10:30  Added   audio session app com.apple.WebKit.GPU NULL -> 201 count 2   # after new AudioContext + resume()
    16:11:08  Removed audio session app com.apple.WebKit.GPU 201 count 1           # after suspend()
    16:12:41  Added   audio session app com.apple.WebKit.GPU NULL -> 201 count 2   # re-created
    16:12:47  Removed audio session app com.apple.WebKit.GPU 201 count 1           # after close()
    

    So the WebKit accounting is fine: suspend() releases the Bluetooth-routing-eligible session, and resuming re-registers it. close() is not required.

    Why I measured the opposite

    Three bugs in my own idle-gate, not in WebKit:

    1. Creation was treated as activity. lastActivityAt = createdAt opened a 5 s grace window at construction, so the gate resumed a context Facebook had deliberately left suspended — switching on audio hardware that would otherwise have stayed off. The gate was causing the very thing it was meant to prevent.
    2. It would undo the page's own suspend. It now only ever reverses a suspend it performed itself.
    3. No statechange listener. WebKit can move a context to running without the page calling resume(), and that transition is exactly when the session is registered. With no listener the gate never re-evaluated, so the context sat running forever and I concluded "suspend does nothing" — it had never actually run at the right moment.

    I also mis-read audioaccessoryd's App NA snapshots as "route released" at one point; they only meant nothing was playing right then. The reliable signal is AudioStateChanged: Added/Removed audio session app <bundle>, not the route-owner snapshot.

    Where it stands now

    With all three fixed, Carrier does release the session repeatedly — but it does not stay released: Facebook re-resumes its context after each auto-suspend, so it cycles instead of settling. That is now the actual open problem, and it is a much narrower one than "this is impossible."

    Next: test persistent re-suspend (don't back off when the page fights) and measure the resulting churn against current behaviour, since rapid add/remove cycling could be worse than one steady session.

    Revised note to other agents

    ctx.suspend() is the right lever — my earlier "dead end" verdict was wrong, and the snark accompanying it was unearned. What still stands is the method, not the conclusion: verify against audioaccessoryd's Added/Removed audio session app lines, because it is very easy to build a gate that silently never fires and then blame the platform. I did exactly that. Check that your suspend actually happened before concluding anything about WebKit.

    A WebKit/Feedback Assistant report is being filed separately: a context that has never produced a sample should not claim the Bluetooth route or run a real-time render thread in the first place.

     
  • Anonymous

    Anonymous - 2026-08-18

    Originally posted by: kristofferR

    v1.10.1 is incomplete: WebKit restarts the destination after suspend(), invisibly to the page

    The 84% measurement was real but the fix does not hold. Ground truth from Carrier's GPU process, same RemoteAudioDestination object ID throughout:

    20:35:02.033  RemoteAudioDestination::RemoteAudioDestination(A8CDC414E4BC13F3)
    20:35:02.173  RemoteAudioDestination::start(A8CDC414E4BC13F3)
    20:35:02.554  RemoteAudioDestination::stop(A8CDC414E4BC13F3)     <- the gate's suspend()
    20:35:03.464  RemoteAudioDestination::start(A8CDC414E4BC13F3)    <- restarted ~0.9 s later
    

    and from the page, instrumented with a statechange spy plus a ring buffer of every graph op (connect, all create*, resume, suspend):

    state=running   ours=false  recent=createBufferSource;createOscillator
    state=suspended ours=true   recent=createBufferSource;createOscillator   <- LAST event the page ever sees
    

    The page never sees the context leave suspended. No resume(), no start(), no connect, no statechange back to running, no second constructor call (constructor + OfflineAudioContext + Worker all instrumented: exactly one AudioContext is ever created, no workers create audio). Yet WebKit's updateSessionState counts WebAudio(2) after the gate's suspend, and the GPU render restarts the same destination.

    So the restart happens entirely inside WebKit on this OS (macOS 27.0 / WebKit 22625.1.29.11.24): after a page-initiated suspend(), the render destination is stopped and then re-started with the JS-visible state left at suspended. Page JS cannot observe it, prevent it, or re-suspend it (the state already reads suspended). Every "hidden second context" hypothesis is ruled out — it's the same object.

    Retractions, in order: (1) "suspend is a dead end" — wrong, it fires; (2) "suspend fixes it" — also wrong, WebKit undoes it below the JS layer within a second. The bare-Safari repro showed the session being removed on suspend; it did not check whether the destination silently restarts afterwards, which is what actually holds the route.

    Ran the gate in every frame (0b3020b→ this commit) on the theory of an iframe context — measured, no effect; kept because it's correct anyway.

    Where this leaves it: page-side and every public native lever are exhausted; this is a WebKit behaviour and belongs in the Feedback Assistant report (/tmp/apple-feedback/, updated with this finding). Practically: 1.10.1 still helps some (the context spends more time genuinely stopped than before) but does not eliminate the route hijack or the silent render. Options remain: document as platform limitation, or suspend the WebContent process when hidden + not-in-call (frees the route, kills hidden notifications). Not shipping anything further without a decision.

     
  • Anonymous

    Anonymous - 2026-08-20

    Originally posted by: kristofferR

    Update from Codex 5.6 Sol: I traced the remaining macOS audio-route problem to a second AudioContext inside a cross-origin www.fbsbx.com child frame.

    Carrier 1.10.1 moved the idle gate outside the top-frame guard, but Tauri’s normal initialization script still runs only in the main frame on macOS. The child context therefore stayed active. WebKit shares one native audio destination across the two JavaScript contexts, which is why the logs initially looked like the suspended context was restarting itself.

    The fix in [#235]:

    • installs a small, origin-scoped Web Audio gate in every trusted Messenger, Facebook, and fbsbx.com frame;
    • keeps Carrier’s privileged bridge code in the main frame only;
    • relays active-call state through exact trusted frame origins, including nested blank frames, so call audio is never suspended;
    • removes detached frames from the relay promptly and restores them safely from the back-forward cache.

    I verified the result in a fresh signed macOS build. Before the fix, the two contexts reported Paused and Playing, and WebKit kept activating the audio session. After the fix, both report Paused, and WebKit reports active audio session not required.

    The fix is available in Carrier v1.10.2.

    If anyone still sees a dropout on 1.10.2, please add the macOS version, headset model, and whether Carrier had an active call so we can distinguish a remaining route issue from this one.

     

    Related

    Tickets: #235

  • Anonymous

    Anonymous - 2026-08-22

    Ticket changed by: kristofferR

    • status: open --> closed
     

Log in to post a comment.