Originally created by: kristofferR
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:
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.
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.
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.
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.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.
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:
AudioSession? (JS suspend() did not — this must be verified, not assumed.)!inCall and on focus/visibility. Call state currently lives page-side (window.__carrierInCall / carrier:protection-change) and would need to reach the native side.background_throttling(Disabled) hidden-window fix.Ref [#232]
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
AudioContextrelease its macOSAudioSessionshort of navigating/reloading the webview.1.
AudioContext.suspend()(page JS) — does NOT release the sessionHooked the constructor, suspended when no sound is scheduled. On suspend WebKit logged no
RemoteAudioDestination::stopand notryToSetActive(0) is active = false; the session stayedactive = 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 unusableclose()does work at the WebKit level:MediaSessionManagerInterface::removeSessionfires and theWebAudio(n)count drops. But:WebAudiosessions, but only oneAudioContextcomes through the wrappedwindow.AudioContext/webkitAudioContext/OfflineAudioContextconstructors 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 AudioWired it to the main window's focus/blur (macOS).
respondsToSelector= true and it is called on blur, but the AudioContext-owningWebContentlogs zero Media activity in response — the API only suspends<audio>/<video>media elements, notAudioContext. The session stays active. (Earlier "route owner becameApp NA" readings were a red herring — just the other app not playing at that instant.)Root cause (restated)
WebKit keeps an active
AudioSession(categoryAmbientSound,setEligibleForSmartRouting(true)) for the page as long as anyAudioContextexists, 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(commits128572a,5043701), not shipped.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: noRemoteAudioDestination::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
AudioContextfrom the node side (AudioNode.connect+ everycreate*), which catches any construction path in the frame. Only one JSAudioContextexists (top frame). Yet WebKit tracks twoWebAudiosessions; closing the single reachable context removes one and leaves the otheractive. So even close-all cannot drive the session count to 0 from page JS — a secondWebAudiosession 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:
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
AudioContextstop owning the macOS audio session while the page keeps running.Practical options
Investigation branch
fix/web-audio-idle-route(commits 128572a, 5043701, ab57160), not shipped.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:Medialogs +audioaccessorydrouting snapshots, not guessed:AudioContext.suspend()— session staysactive, noRemoteAudioDestination::stop, notryToSetActive(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-reachableAudioContextin the top frame (confirmed by enumerating fromAudioNode.connect+ everycreate*, not just the constructor) while WebKit tracks twoWebAudiosessions. Closing the reachable one leaves the secondactive. Andclose()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
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.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:
lastActivityAt = createdAtopened a 5 s grace window at construction, so the gate resumed a context Facebook had deliberately leftsuspended— switching on audio hardware that would otherwise have stayed off. The gate was causing the very thing it was meant to prevent.statechangelistener. WebKit can move a context torunningwithout the page callingresume(), and that transition is exactly when the session is registered. With no listener the gate never re-evaluated, so the context satrunningforever and I concluded "suspend does nothing" — it had never actually run at the right moment.I also mis-read
audioaccessoryd'sApp NAsnapshots as "route released" at one point; they only meant nothing was playing right then. The reliable signal isAudioStateChanged: 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 againstaudioaccessoryd'sAdded/Removed audio session applines, 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.
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
RemoteAudioDestinationobject ID throughout:and from the page, instrumented with a
statechangespy plus a ring buffer of every graph op (connect, allcreate*,resume,suspend):The page never sees the context leave
suspended. Noresume(), nostart(), noconnect, nostatechangeback torunning, no second constructor call (constructor +OfflineAudioContext+Workerall instrumented: exactly oneAudioContextis ever created, no workers create audio). Yet WebKit'supdateSessionStatecountsWebAudio(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 atsuspended. Page JS cannot observe it, prevent it, or re-suspend it (the state already readssuspended). 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.Originally posted by: kristofferR
Update from Codex 5.6 Sol: I traced the remaining macOS audio-route problem to a second
AudioContextinside a cross-originwww.fbsbx.comchild 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]:
fbsbx.comframe;I verified the result in a fresh signed macOS build. Before the fix, the two contexts reported
PausedandPlaying, and WebKit kept activating the audio session. After the fix, both reportPaused, and WebKit reportsactive 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:
#235Ticket changed by: kristofferR