Skip to content

Drain audio engine off main queue#655

Open
grohith327 wants to merge 5 commits into
mainfrom
rohith/audio-engine-recovery
Open

Drain audio engine off main queue#655
grohith327 wants to merge 5 commits into
mainfrom
rohith/audio-engine-recovery

Conversation

@grohith327

@grohith327 grohith327 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Description

Right now, whenever audio input device switches the AVAudioEngine is destroyed and created. However, sometimes, the audio events can be be sent through the destroyed engine which the ASRService doesn't realize. This results in a crash. This change fixes this by making the destruction/creation sequential and also waiting for a quiet period (if it doesn't happen instantly) before switching the engine. This should prevent crashses

Type of Change

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

Related Issue or Discussion

#640, #644, #636 and #651

Testing

  • Tested on Apple Silicon Mac
  • Tested on macOS version:
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally:

Screenshots / Video

Attempted transcription and also switching between audio input devices to make sure it works as expected

CleanShot 2026-07-18 at 21 27 50@2x

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a5802b15e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift
@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the previous fire-and-forget RetiredAudioEngineReference approach with a new AudioEngineRetirementDrain that serializes all AVAudioEngine releases on a dedicated utility queue and provides an async/await-compatible barrier. The result is that capture startup, route recovery, and device-switch paths all wait for the old engine to fully deallocate before touching Core Audio again, eliminating the crash described in issues #640, #644, and #651.

  • AudioEngineRetirementDrain introduces schedule, releaseAndWait, and waitForScheduledReleases so callers can choose fire-and-forget or blocking retirement; ASRService wires all start/stop/recovery paths through this drain.
  • A generation counter + pendingAudioRouteRecovery structure serializes re-entrant route-change events, replacing the previous single-task cancel-and-replace pattern.
  • isStarting is now publicly readable and backed by waitForPendingStart() / finishAudioCaptureStart(), allowing hotkey handlers to correctly defer stop actions until start() completes.

Confidence Score: 4/5

The core audio-engine drain logic is well-structured and the generation-counter approach correctly handles re-entrant route changes, but a pre-existing race in cancelAudioRouteRecoveryAndWait where a newly-armed recovery task can be silently dropped remains unresolved.

The drain serialization and async start/stop coordination work correctly across the paths exercised in review. The outstanding concern is cancelAudioRouteRecoveryAndWait: after it awaits the old task's result, a newer recovery task armed by finishAudioRouteRecovery during that suspension window can be overwritten by the unconditional self.audioRouteRecoveryTask = nil that follows — leaving the replacement task orphaned with no cancellation handle.

ASRService.swift — specifically the cancelAudioRouteRecoveryAndWait function and how it interacts with finishAudioRouteRecovery when a new route-change event arrives during the await task?.result suspension.

Reviews (4): Last reviewed commit: "Fix for concurrent toggles" | Re-trigger Greptile

Comment on lines +1 to +41
@testable import FluidVoice_Debug
import Foundation
import XCTest

final class AudioEngineRetirementDrainTests: XCTestCase {
func testReleaseAndWaitCompletesAfterOffMainDeinit() async throws {
let drain = AudioEngineRetirementDrain(label: "test.audio-engine-retirement.single")
let recorder = DeinitRecorder()
var probe: DeinitProbe? = DeinitProbe(id: 1, recorder: recorder)
let token = AudioEngineRetirementToken(try XCTUnwrap(probe))
probe = nil

await drain.releaseAndWait(token)

XCTAssertEqual(
recorder.events,
[DeinitEvent(id: 1, occurredOnMainThread: false)]
)
}

func testAwaitedReleaseRunsAfterPreviouslyScheduledRelease() async throws {
let drain = AudioEngineRetirementDrain(label: "test.audio-engine-retirement.serial")
let recorder = DeinitRecorder()
var first: DeinitProbe? = DeinitProbe(id: 1, recorder: recorder)
var second: DeinitProbe? = DeinitProbe(id: 2, recorder: recorder)
let firstToken = AudioEngineRetirementToken(try XCTUnwrap(first))
let secondToken = AudioEngineRetirementToken(try XCTUnwrap(second))
first = nil
second = nil

drain.schedule(firstToken)
await drain.releaseAndWait(secondToken)

XCTAssertEqual(
recorder.events,
[
DeinitEvent(id: 1, occurredOnMainThread: false),
DeinitEvent(id: 2, occurredOnMainThread: false),
]
)
}

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 Missing coverage for waitForScheduledReleases barrier

The two tests cover releaseAndWait alone and schedule + releaseAndWait ordering, but waitForScheduledReleases — the barrier used in startPreferredAudioCapture, startCompatibilityAudioCapture, and cancelAudioRouteRecoveryAndWait — is not exercised at all. A test that calls drain.schedule(token) then awaits drain.waitForScheduledReleases() and asserts the engine was released before the barrier returned would give confidence that the most-travelled production path (capture start after a fire-and-forget retirement) actually serializes correctly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/FluidDictationIntegrationTests/AudioEngineRetirementDrainTests.swift
Line: 1-41

Comment:
**Missing coverage for `waitForScheduledReleases` barrier**

The two tests cover `releaseAndWait` alone and `schedule` + `releaseAndWait` ordering, but `waitForScheduledReleases` — the barrier used in `startPreferredAudioCapture`, `startCompatibilityAudioCapture`, and `cancelAudioRouteRecoveryAndWait` — is not exercised at all. A test that calls `drain.schedule(token)` then awaits `drain.waitForScheduledReleases()` and asserts the engine was released before the barrier returned would give confidence that the most-travelled production path (capture start after a fire-and-forget retirement) actually serializes correctly.

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

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 336c5351f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08bc0eec64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

self.audioRouteRecoveryTask?.cancel()
self.audioRouteRecoveryTask = nil
self.isRecoveringAudioRoute = false
await self.cancelAudioRouteRecoveryAndWait()

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 Badge Treat startup as active in automatic hotkey routing

In automatic activation mode, this new suspension leaves isRunning false long enough for a second, different mode shortcut to take the isRunning == false branches in GlobalHotkeyManager (for example handlePromptModeKeyDown and the command/rewrite equivalents). Those branches invoke beginDictationRecording, which changes the active mode and prompt selection before its guard !asr.isRunning rejects the duplicate engine start. Consequently, a quick mode-key press while route recovery is draining can capture audio started by the first shortcut but finalize/output it using the second mode's configuration; the automatic paths need to consider isStarting just as the updated toggle paths do.

Useful? React with 👍 / 👎.

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.

1 participant