Skip to content

fix(audio): break permanent main-thread deadlock on default device change while idle#543

Open
devzahirul wants to merge 1 commit into
altic-dev:mainfrom
devzahirul:fix/542-idle-route-change-deadlock
Open

fix(audio): break permanent main-thread deadlock on default device change while idle#543
devzahirul wants to merge 1 commit into
altic-dev:mainfrom
devzahirul:fix/542-idle-route-change-deadlock

Conversation

@devzahirul

Copy link
Copy Markdown

Description

Fixes the permanent main-thread deadlock reported in #542: a system default audio device change while FluidVoice is idle (with "sync audio devices with system" enabled) could beachball the app forever at 0% CPU, recoverable only by force quit.

The sample attached to the issue shows a circular wait between two threads, and both legs live in ASRService:

  1. Main thread — the CoreAudio default-device listener runs scheduleAudioRouteRecoveryretireAudioEngine(reason: "idle_route_change:..."), and the final strong reference to the old AVAudioEngine gets released on main. -[AVAudioEngine dealloc] then does a synchronous hop onto the engine's internal serial queue and blocks.
  2. Engine's internal queue — the same device change makes the (idle-but-prewarmed) engine run IOUnitConfigurationChanged(), which posts AVAudioEngineConfigurationChange. Because our observer was registered with queue: .main, NotificationCenter wraps delivery in an NSOperation and blocks the posting thread in waitUntilFinished until the main thread runs it — which it never can.

This PR fixes both legs independently:

  • Observer (registerEngineConfigurationChangeObserver): register with queue: nil so delivery is synchronous on the posting thread and the engine's queue never waits on main. The handler body only spawns a Task { @MainActor ... }, which is safe from any thread, so behavior is unchanged. This eliminates the whole deadlock class — any main-thread call that synchronizes with the engine queue (dealloc, stop(), removeTap) can no longer wait forever on a blocked post.
  • Retirement (retireAudioEngine): the existing hand-off (DispatchQueue.global(qos: .utility).async { _ = oldEngine }) answers the question left open in the issue ("some other strong reference is being dropped synchronously"). There is no other reference — the hand-off itself is racy. Dispatching the block only guarantees the utility queue holds a reference, not the last one: when the trivial block finishes before retireAudioEngine returns (likely, since main still runs the DebugLogger call), the caller's own local performs the final release and dealloc lands back on main, directly under the retireAudioEngine frame — exactly what all 2,557 sample snapshots show. The fix moves the retired engine into a small RetiredAudioEngineReference holder so no main-thread local ever owns it past the dispatch; every main-side release happens-before the enqueue, so the drain on the utility queue is provably the final release.

Type of Change

  • 🐞 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 🧹 Chore
  • 📝 Documentation update

Related Issue or Discussion

Fixes #542

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.1
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml (0 violations)
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally: full xcodebuild test suite passes on platform=macOS,arch=arm64 (all suites green)

The deadlock itself is a scheduling race between engine retirement and the engine's own configuration-change handling, so it cannot be reproduced on demand (the reporter hit it via Bluetooth headset connect/disconnect). The fix was validated by matching both changed code paths against the two blocked stacks in the issue's sample output, plus a clean build and full test run.

Screenshots / Video

  • No UI/visual changes; screenshots/video are not applicable.

Notes

  • queue: nil is intentionally load-bearing and now documented inline: block-based observers on an explicit queue make postNotification wait (-[NSOperation waitUntilFinished] in the issue's sample), so the engine's internal queue must never target main. Semantics are preserved because the handler already hopped to the main actor via Task; delivery order relative to the recovery guards (isRunning / isRecoveringAudioRoute) is unaffected.
  • RetiredAudioEngineReference follows the existing pattern in this file for cross-thread helpers (private final nonisolated class ... : @unchecked Sendable, cf. AudioCapturePipeline). It is created on main and touched exactly once by the draining block; the dispatch enqueue provides the ordering.
  • Considered and rejected: keeping queue: .main and filtering by object: (the notification legitimately comes from our own engine, so the wait would remain), and moving engine.stop() off main (unnecessary once the posting thread can no longer block on main, and it would change engine state timing during recovery).
  • No regression test is included: forcing IOUnitConfigurationChanged() to post at the exact moment of retirement requires real device-route changes and cannot be scripted deterministically in CI without flakiness.

…altic-dev#542)

A default audio device change while the app is idle could freeze it
permanently: retiring the prewarmed AVAudioEngine dropped the final
engine reference on the main thread, so -[AVAudioEngine dealloc]
blocked waiting on the engine's internal serial queue, while that same
queue was blocked posting AVAudioEngineConfigurationChange to a
main-queue observer. Neither side could proceed; only force quit
recovered.

Fix both legs of the cycle:

- Register the AVAudioEngineConfigurationChange observer with
  queue: nil so delivery runs synchronously on the posting thread and
  the engine's internal queue never waits on the main thread. The
  handler already only spawns a MainActor task, which is safe from any
  thread.
- Release the retired engine's final reference deterministically on the
  background queue. The previous hand-off captured the engine in an
  async block, but the caller's locals raced it: when the block
  finished first, the last release - and dealloc - landed back on the
  main thread, which is exactly what the sample in the issue shows. A
  dedicated holder keeps the retired engine out of main-thread locals
  entirely, so the drain on the utility queue is always the final
  release.

Fixes altic-dev#542
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Permanent main-thread deadlock on default audio device change while idle (AVAudioEngine dealloc vs AVAudioEngineConfigurationChange observer)

1 participant