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
@altic-dev

Copy link
Copy Markdown
Owner

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a permanent main-thread deadlock (#542) triggered when a system default audio device change occurs while FluidVoice is idle with "sync audio devices with system" enabled. The root cause was a circular wait between the main thread (releasing an AVAudioEngine whose dealloc synchronises with the engine's internal serial queue) and that same queue (posting AVAudioEngineConfigurationChange with queue: .main, blocking until main could process the notification).

  • Observer fix: registerEngineConfigurationChangeObserver now passes queue: nil, making notification delivery synchronous on the engine's internal queue. The handler body only creates a Task { @MainActor … }, which is safe from any thread, so observable behaviour is unchanged.
  • Retirement fix: retireAudioEngine wraps the outgoing engine in RetiredAudioEngineReference before nilling engineStorage, ensuring the final ARC release — and the resulting dealloc — always occurs on the utility queue rather than main. The previous DispatchQueue.global.async { _ = oldEngine } pattern was racy: the trivial block could complete before the caller returned, leaving the caller's local as the last owner.

Confidence Score: 4/5

Safe to merge; the deadlock fix is logically correct and both changed code paths are well-isolated within ASRService.

Both legs of the fix are sound: the queue: nil change eliminates blocking notification delivery without altering handler semantics (Task hop to main actor is thread-safe), and RetiredAudioEngineReference provably holds the sole strong reference after engineStorage is nilled — ARC dealloc will always land on the utility queue. The only gap is that drain() is not private, so a future in-file caller could invoke it on the wrong thread and silently reintroduce the problem.

Sources/Fluid/Services/ASRService.swift — specifically the RetiredAudioEngineReference.drain() access level.

Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Sources/Fluid/Services/ASRService.swift:3583-3585
`drain()` is the single operation that must run off the main thread, but it is not access-controlled. Any code in `ASRService.swift` that somehow holds a live `RetiredAudioEngineReference` — e.g., a future refactor that temporarily stores it in a property — could call `drain()` on the wrong thread and re-introduce the dealloc-on-main problem the fix is guarding against. Since there is only one intended call site (the dispatch block), marking it `private` costs nothing and makes the invariant enforced by the compiler.

```suggestion
    fileprivate func drain() {
        self.engine = nil
    }
```

Reviews (1): Last reviewed commit: "fix(audio): break permanent main-thread ..." | Re-trigger Greptile

Comment on lines +3583 to +3585
func drain() {
self.engine = nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 drain() is the single operation that must run off the main thread, but it is not access-controlled. Any code in ASRService.swift that somehow holds a live RetiredAudioEngineReference — e.g., a future refactor that temporarily stores it in a property — could call drain() on the wrong thread and re-introduce the dealloc-on-main problem the fix is guarding against. Since there is only one intended call site (the dispatch block), marking it private costs nothing and makes the invariant enforced by the compiler.

Suggested change
func drain() {
self.engine = nil
}
fileprivate func drain() {
self.engine = nil
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Fluid/Services/ASRService.swift
Line: 3583-3585

Comment:
`drain()` is the single operation that must run off the main thread, but it is not access-controlled. Any code in `ASRService.swift` that somehow holds a live `RetiredAudioEngineReference` — e.g., a future refactor that temporarily stores it in a property — could call `drain()` on the wrong thread and re-introduce the dealloc-on-main problem the fix is guarding against. Since there is only one intended call site (the dispatch block), marking it `private` costs nothing and makes the invariant enforced by the compiler.

```suggestion
    fileprivate func drain() {
        self.engine = nil
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

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)

2 participants