diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 5366c12b..6335c358 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -104,6 +104,9 @@ final class ASRService: ObservableObject { private(set) var dictionaryTrainingAudioGeneration = 0 private var isStarting: Bool = false // Guard against re-entrant start() calls + // Set by abortStartIfPending() while start() is suspended at the engine- + // drain fence; the stop paths gate on isRunning, which is still false there. + private var startAbortRequested = false private var hasCompletedFirstTranscription: Bool = false // Track if model has warmed up with first transcription private var lastBoostHitTerm: String? private var hasPendingParakeetVocabularyReload: Bool = false @@ -619,6 +622,18 @@ final class ASRService: ObservableObject { } private func retireAudioEngine(reason: String) { + if let retired = self.detachAudioEngineForRetirement(reason: reason) { + self.scheduleRetiredEngineDrain(retired) + } + } + + /// Tears the capture stack down and returns the holder carrying the + /// engine's final reference WITHOUT scheduling its release. The start-retry + /// loop keeps the holder alive until its synchronous rebuild is done — + /// postponing dealloc past bring-up satisfies the same "never overlap" + /// invariant as the drain fence where awaiting is impossible (#620). All + /// other callers go through retireAudioEngine, which drains immediately. + private func detachAudioEngineForRetirement(reason: String) -> RetiredAudioEngineReference? { self.audioEngineStandbyTask?.cancel() self.audioEngineStandbyTask = nil @@ -646,12 +661,62 @@ final class ASRService: ObservableObject { // block finishes before this function returns, the local's release // becomes the final one and dealloc lands back on main. The holder keeps // the engine out of main-thread locals entirely. + var retired: RetiredAudioEngineReference? if self.engineStorage != nil { - let retired = RetiredAudioEngineReference(self.engineStorage) + retired = RetiredAudioEngineReference(self.engineStorage) self.engineStorage = nil - retired.scheduleRelease() } DebugLogger.shared.debug("Audio engine retired (\(reason))", source: "ASRService") + return retired + } + + private func scheduleRetiredEngineDrain(_ retired: RetiredAudioEngineReference) { + self.pendingEngineDrainCount += 1 + retired.scheduleRelease(onDrained: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.pendingEngineDrainCount = max(0, self.pendingEngineDrainCount - 1) + } + }) + } + + /// Resolves once every drain scheduled so far has finished, or returns + /// false at the timeout. Cooperative — the main thread stays responsive. + private func awaitEngineDrainFence(timeoutNanoseconds: UInt64) async -> Bool { + let (fenceStream, fenceContinuation) = AsyncStream.makeStream(of: Void.self) + RetiredAudioEngineReference.notifyAfterPendingDrains { + fenceContinuation.yield() + fenceContinuation.finish() + } + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + for await _ in fenceStream { return true } + return true + } + group.addTask { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + return false + } + let fenceWonTheRace = await group.next() ?? false + group.cancelAll() + return fenceWonTheRace + } + } + + /// Suspends until no retired engine is still deallocating, so bring-up can + /// never enter the HAL against an in-flight teardown (#620). Returns false + /// if teardown does not settle within `audioEngineDrainTimeoutNanoseconds`. + private func awaitPendingEngineDrains() async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + self.audioEngineDrainTimeoutNanoseconds + while self.pendingEngineDrainCount > 0 { + let now = DispatchTime.now().uptimeNanoseconds + guard now < deadline else { return false } + guard await self.awaitEngineDrainFence(timeoutNanoseconds: deadline - now) else { return false } + // The count decrements via a MainActor hop; yield so it can land + // before the next check. + await Task.yield() + } + return true } private func scheduleAudioEngineStandbyRetirement() { @@ -709,6 +774,19 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("Audio capture prewarm skipped - backend already prepared", source: "ASRService") return } + guard self.pendingEngineDrainCount == 0 else { + // A retired engine is still deallocating; bring-up must not enter + // the HAL until it finishes (#620). Re-attempt once the drains + // settle — repeat deferrals self-coalesce through the prepared + // guard above. + DebugLogger.shared.debug("Audio engine prewarm deferred - engine drain pending (\(reason))", source: "ASRService") + RetiredAudioEngineReference.notifyAfterPendingDrains { [weak self] in + Task { @MainActor [weak self] in + self?.prewarmAudioEngineIfPossible(reason: reason) + } + } + return + } let startedAt = Date().timeIntervalSince1970 if SettingsStore.shared.experimentalDirectAudioCaptureEnabled, @@ -827,7 +905,16 @@ final class ASRService: ObservableObject { private func startCompatibilityAudioCapture(reason: String) throws { self.benchmarkLog("audio_backend kind=av_audio_engine_fallback reason=\(reason)") try self.configureSession() - try self.startEngine() + // Engines retired by failed start attempts stay referenced until the + // whole bring-up — including tap installation — has finished, so their + // dealloc can never overlap any of it (#620). + var enginesRetiredDuringRetry: [RetiredAudioEngineReference] = [] + defer { + for retired in enginesRetiredDuringRetry { + self.scheduleRetiredEngineDrain(retired) + } + } + try self.startEngine(holdingRetiredEnginesIn: &enginesRetiredDuringRetry) try self.setupEngineTap() self.activeAudioCaptureBackend = .audioEngine } @@ -994,6 +1081,10 @@ final class ASRService: ObservableObject { private var engineConfigurationChangeObserver: NSObjectProtocol? private var audioRouteRecoveryTask: Task? private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 + /// Retired engines whose dealloc is still running on the drain queue. + /// Every engine bring-up path must wait for this to reach zero (#620). + private var pendingEngineDrainCount = 0 + private let audioEngineDrainTimeoutNanoseconds: UInt64 = 5_000_000_000 private var audioEngineStandbyTask: Task? private let audioEngineStandbyNanoseconds: UInt64 = 8_000_000_000 private var isEngineTapInstalled = false @@ -1261,6 +1352,18 @@ final class ASRService: ObservableObject { } } + /// Cancels an in-flight `start()` that is suspended at the engine-drain + /// fence. During that suspension `isRunning` is still false, so the stop + /// paths would silently drop the request and capture would come up after + /// the user already released the hotkey (#620). Returns `true` when a + /// pending start will abort; no-op otherwise. + @discardableResult + func abortStartIfPending() -> Bool { + guard self.isStarting, self.isRunning == false else { return false } + self.startAbortRequested = true + return true + } + /// Starts the speech recognition session. /// /// This method initiates audio capture and real-time processing. The service will: @@ -1332,10 +1435,35 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("✅ Buffers cleared", source: "ASRService") self.isStarting = true - defer { self.isStarting = false } + defer { + self.isStarting = false + self.startAbortRequested = false + } self.isDictionaryTrainingCaptureActive = false do { + // A route change just before this start may still be draining the + // retired engine; entering the HAL against that teardown is the + // #620 freeze. The common path pays nothing — the count is only + // non-zero for the few milliseconds a dealloc is in flight. + if self.pendingEngineDrainCount > 0 { + guard await self.awaitPendingEngineDrains() else { + throw NSError( + domain: "ASRService", + code: -1101, + userInfo: [NSLocalizedDescriptionKey: "The previous audio engine is still shutting down."] + ) + } + } + // The fence above is the only suspension before isRunning is set; + // a stop that landed during it must cancel the session here or + // recording would begin after the user already let go. + if self.startAbortRequested { + self.audioCapturePipeline.setRecordingEnabled(false) + self.benchmarkLog("recording_start_aborted reason=stop_during_engine_drain") + DebugLogger.shared.info("🛑 START() aborted - stop requested while awaiting engine teardown", source: "ASRService") + return + } try self.startPreferredAudioCapture() self.isDictionaryTrainingCaptureActive = forDictionaryTraining self.isRunning = true @@ -2201,7 +2329,12 @@ final class ASRService: ObservableObject { } } - private func startEngine() throws { + /// Retry attempts detach the failed engine into `retiredEngines` instead + /// of draining it: this loop and the tap setup that follows are + /// synchronous, so the async drain fence cannot be awaited here. The + /// caller schedules the drains once the entire bring-up has completed, + /// which preserves the same never-overlap invariant (#620). + private func startEngine(holdingRetiredEnginesIn retiredEngines: inout [RetiredAudioEngineReference]) throws { DebugLogger.shared.debug("🚀 startEngine() - ENTERED", source: "ASRService") var attempts = 0 var lastError: Error? @@ -2260,7 +2393,9 @@ final class ASRService: ObservableObject { // If this isn't the last attempt, recreate engine and reconfigure if attempts < 3 { DebugLogger.shared.debug("⚠️ Start failed, recreating engine for retry...", source: "ASRService") - self.retireAudioEngine(reason: "start_retry") + if let retired = self.detachAudioEngineForRetirement(reason: "start_retry") { + retiredEngines.append(retired) + } // Need to reconfigure the new engine try? self.configureSession() DebugLogger.shared.debug("✅ Engine recreated and reconfigured, will retry", source: "ASRService") @@ -2365,6 +2500,9 @@ final class ASRService: ObservableObject { guard self.isRunning else { self.audioLevelSubject.send(0.0) if self.hasPreparedAudioCapture { + // prewarmAudioEngineIfPossible defers itself behind the drain + // this retire just scheduled, so the rebuild can never enter + // the HAL while the old engine is still deallocating (#620). self.retireAudioEngine(reason: "idle_route_change:\(reason)") self.prewarmAudioEngineIfPossible(reason: "idle_route_change") } @@ -2407,7 +2545,28 @@ final class ASRService: ObservableObject { self.stopMonitoringDevice() self.stopActiveAudioCapture() + + // Same HAL serialization hazard as the idle path (#620): never build the + // replacement capture stack while the retired engine is still tearing + // down. If the drain wedges, fail the recovery cleanly instead of racing. self.retireAudioEngine(reason: "audio_route_recovery") + let drained = await self.awaitPendingEngineDrains() + guard drained else { + self.audioCapturePipeline.setRecordingEnabled(false) + DebugLogger.shared.error( + "Audio route recovery aborted: engine teardown did not complete in time", + source: "ASRService" + ) + await self.stopWithoutTranscription() + NotificationCenter.default.post( + name: NSNotification.Name("ASRServiceDeviceDisconnected"), + object: nil, + userInfo: ["errorMessage": "Recording stopped because the audio device changed."] + ) + return + } + // The drain suspends; recording can legitimately stop while it runs. + guard self.isRunning else { return } do { self.audioCapturePipeline.setRecordingEnabled( @@ -3743,16 +3902,33 @@ private extension ASRService { /// `@unchecked Sendable`: created on the main thread, then handed off and touched /// exactly once by the draining block; the dispatch provides the ordering. private final nonisolated class RetiredAudioEngineReference: @unchecked Sendable { + /// One shared serial queue across all retired engines: drains can never + /// overlap each other, and `notifyAfterPendingDrains` can fence bring-up + /// work behind every teardown scheduled so far (#620). + private static let drainQueue = DispatchQueue(label: "com.fluidapp.audio-engine-drain", qos: .utility) + private var engine: AnyObject? init(_ engine: AnyObject?) { self.engine = engine } + /// Runs `completion` on the drain queue after every drain scheduled so far + /// has finished — a fence over pending teardowns, regardless of which + /// retirement scheduled them. + static func notifyAfterPendingDrains(_ completion: @escaping @Sendable () -> Void) { + self.drainQueue.async { completion() } + } + /// Schedules the retained engine's release off the main thread. Keeping the /// actual drain private prevents callers from bypassing this queue hop. - func scheduleRelease() { - DispatchQueue.global(qos: .utility).async { self.drain() } + /// `onDrained` runs on the drain queue after `-[AVAudioEngine dealloc]` has + /// fully completed. + func scheduleRelease(onDrained: (@Sendable () -> Void)? = nil) { + Self.drainQueue.async { + self.drain() + onDrained?() + } } private func drain() { diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index e974e41f..ff4d2aba 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -1812,6 +1812,10 @@ final class GlobalHotkeyManager: NSObject { } guard self.asrService.isRunning else { + // start() may still be suspended at its engine-drain fence + // with isRunning false; mark the session aborted so capture + // does not come up after the key was already released. + self.asrService.abortStartIfPending() return }