From cf995a71a9e3e549304adfbe65571ca7aa84d10c Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Wed, 15 Jul 2026 16:39:27 +0600 Subject: [PATCH 1/5] fix(audio): sequence engine rebuild behind retired-engine teardown (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idle route change retires the prewarmed AVAudioEngine — whose dealloc always drains on a background queue since the #542 fix — and immediately builds its replacement on the main thread. -[AVAudioEngine dealloc] and inputNode/prepare() on a fresh engine serialize inside the HAL, so teardown and bring-up of the two engines can interleave: the main thread parks in a workloop sync wait behind a servicer stuck in a condition wait, and the app freezes for good. The spindump in #620 shows this signature ~15s after launch, when Bluetooth/default devices settle and fire route-change bursts. - Give retired engines one serial drain queue and an onDrained hook so bring-up can be ordered strictly after dealloc completes. - Idle route change: rebuild in the drain completion, with a generation counter so a burst of route changes collapses into a single rebuild of the newest configuration. - Mid-recording recovery: cooperatively await the drain (bounded at 5s) before rebuilding capture; if the teardown wedges, stop the recording cleanly instead of racing a new engine against it, and re-check isRunning after the suspension. --- Sources/Fluid/Services/ASRService.swift | 89 +++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 5366c12b..7182a9d1 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -618,7 +618,7 @@ final class ASRService: ObservableObject { self.directAudioInput != nil || self.hasWarmAudioEngine } - private func retireAudioEngine(reason: String) { + private func retireAudioEngine(reason: String, onDrained: (@Sendable () -> Void)? = nil) { self.audioEngineStandbyTask?.cancel() self.audioEngineStandbyTask = nil @@ -649,11 +649,40 @@ final class ASRService: ObservableObject { if self.engineStorage != nil { let retired = RetiredAudioEngineReference(self.engineStorage) self.engineStorage = nil - retired.scheduleRelease() + retired.scheduleRelease(onDrained: onDrained) + } else { + // Nothing retained; follow-up work can run right away. + onDrained?() } DebugLogger.shared.debug("Audio engine retired (\(reason))", source: "ASRService") } + /// Retires the engine and suspends (cooperatively — the main thread stays + /// responsive) until its final release has completed on the drain queue. + /// Returns false if the teardown did not finish within the timeout. + private func retireAudioEngineAndAwaitDrain(reason: String) async -> Bool { + let (drainStream, drainContinuation) = AsyncStream.makeStream(of: Void.self) + self.retireAudioEngine(reason: reason, onDrained: { + drainContinuation.yield() + drainContinuation.finish() + }) + + let timeoutNanoseconds = self.audioEngineDrainTimeoutNanoseconds + return await withTaskGroup(of: Bool.self) { group in + group.addTask { + for await _ in drainStream { return true } + return true + } + group.addTask { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + return false + } + let drainWonTheRace = await group.next() ?? false + group.cancelAll() + return drainWonTheRace + } + } + private func scheduleAudioEngineStandbyRetirement() { self.audioEngineStandbyTask?.cancel() let delay = self.audioEngineStandbyNanoseconds @@ -994,6 +1023,10 @@ final class ASRService: ObservableObject { private var engineConfigurationChangeObserver: NSObjectProtocol? private var audioRouteRecoveryTask: Task? private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 + /// Bumped on every idle route change so a burst of them collapses into a + /// single engine rebuild once the newest teardown has drained. + private var idleEngineRebuildGeneration = 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 @@ -2365,8 +2398,20 @@ final class ASRService: ObservableObject { guard self.isRunning else { self.audioLevelSubject.send(0.0) if self.hasPreparedAudioCapture { - self.retireAudioEngine(reason: "idle_route_change:\(reason)") - self.prewarmAudioEngineIfPossible(reason: "idle_route_change") + // Rebuild only after the retired engine has fully deallocated: + // -[AVAudioEngine dealloc] on the drain queue and inputNode/ + // prepare() on a fresh engine serialize inside the HAL, and + // overlapping them can park the main thread behind the teardown + // for good (#620). The generation check collapses route-change + // bursts into one rebuild of the newest configuration. + self.idleEngineRebuildGeneration &+= 1 + let generation = self.idleEngineRebuildGeneration + self.retireAudioEngine(reason: "idle_route_change:\(reason)", onDrained: { [weak self] in + Task { @MainActor [weak self] in + guard let self, self.idleEngineRebuildGeneration == generation else { return } + self.prewarmAudioEngineIfPossible(reason: "idle_route_change") + } + }) } return } @@ -2407,7 +2452,27 @@ final class ASRService: ObservableObject { self.stopMonitoringDevice() self.stopActiveAudioCapture() - self.retireAudioEngine(reason: "audio_route_recovery") + + // 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. + let drained = await self.retireAudioEngineAndAwaitDrain(reason: "audio_route_recovery") + 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,6 +3808,11 @@ 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 serial queue for every retired engine: drains can never overlap each + /// other, and callers can order bring-up work behind a drain via + /// `onDrained` (#620). + private static let drainQueue = DispatchQueue(label: "com.fluidapp.audio-engine-drain", qos: .utility) + private var engine: AnyObject? init(_ engine: AnyObject?) { @@ -3751,8 +3821,13 @@ private final nonisolated class RetiredAudioEngineReference: @unchecked Sendable /// 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() { From 7679bb18ad9f42f9145b185fc1a2e961007bb1bc Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Wed, 15 Jul 2026 17:34:23 +0600 Subject: [PATCH 2/5] fix(audio): fence all engine bring-up behind pending drains (#620) Review hardening of the teardown/bring-up sequencing: - A second route change arriving while a drain was still running could fire the rebuild completion synchronously (engineStorage already nil) and race the in-flight dealloc. Replace the per-retire completion with a drain fence: prewarmAudioEngineIfPossible now defers itself whenever any retired engine is still deallocating, re-checks when the fence fires, and repeat deferrals self-coalesce through the existing prepared guard. This also removes the bespoke generation counter. - Recording starts were not gated: start() now awaits pending drains (zero cost when none are in flight) and fails through the existing start-failure path if teardown does not settle within 5s, instead of building a new engine against it. - Track pendingEngineDrainCount on the main actor; it is incremented before a drain is scheduled and decremented only after dealloc has returned, so it can never read zero while a teardown is in flight. - Fix the drain-queue doc comment: one shared serial queue across all retired engines, not one per instance. Re-verified with the same probe workload (28 idle route changes): zero bring-up events inside any foreign dealloc window, minimum dealloc-exit to bring-up gap 21ms, app responsive throughout. --- Sources/Fluid/Services/ASRService.swift | 121 ++++++++++++++++-------- 1 file changed, 81 insertions(+), 40 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 7182a9d1..1985255a 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -618,7 +618,7 @@ final class ASRService: ObservableObject { self.directAudioInput != nil || self.hasWarmAudioEngine } - private func retireAudioEngine(reason: String, onDrained: (@Sendable () -> Void)? = nil) { + private func retireAudioEngine(reason: String) { self.audioEngineStandbyTask?.cancel() self.audioEngineStandbyTask = nil @@ -649,40 +649,56 @@ final class ASRService: ObservableObject { if self.engineStorage != nil { let retired = RetiredAudioEngineReference(self.engineStorage) self.engineStorage = nil - retired.scheduleRelease(onDrained: onDrained) - } else { - // Nothing retained; follow-up work can run right away. - onDrained?() + 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) + } + }) } DebugLogger.shared.debug("Audio engine retired (\(reason))", source: "ASRService") } - /// Retires the engine and suspends (cooperatively — the main thread stays - /// responsive) until its final release has completed on the drain queue. - /// Returns false if the teardown did not finish within the timeout. - private func retireAudioEngineAndAwaitDrain(reason: String) async -> Bool { - let (drainStream, drainContinuation) = AsyncStream.makeStream(of: Void.self) - self.retireAudioEngine(reason: reason, onDrained: { - drainContinuation.yield() - drainContinuation.finish() - }) - - let timeoutNanoseconds = self.audioEngineDrainTimeoutNanoseconds + /// 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 drainStream { return true } + for await _ in fenceStream { return true } return true } group.addTask { try? await Task.sleep(nanoseconds: timeoutNanoseconds) return false } - let drainWonTheRace = await group.next() ?? false + let fenceWonTheRace = await group.next() ?? false group.cancelAll() - return drainWonTheRace + 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() { self.audioEngineStandbyTask?.cancel() let delay = self.audioEngineStandbyNanoseconds @@ -738,6 +754,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, @@ -1023,9 +1052,9 @@ final class ASRService: ObservableObject { private var engineConfigurationChangeObserver: NSObjectProtocol? private var audioRouteRecoveryTask: Task? private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 - /// Bumped on every idle route change so a burst of them collapses into a - /// single engine rebuild once the newest teardown has drained. - private var idleEngineRebuildGeneration = 0 + /// 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 @@ -1369,6 +1398,19 @@ final class ASRService: ObservableObject { 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."] + ) + } + } try self.startPreferredAudioCapture() self.isDictionaryTrainingCaptureActive = forDictionaryTraining self.isRunning = true @@ -2398,20 +2440,11 @@ final class ASRService: ObservableObject { guard self.isRunning else { self.audioLevelSubject.send(0.0) if self.hasPreparedAudioCapture { - // Rebuild only after the retired engine has fully deallocated: - // -[AVAudioEngine dealloc] on the drain queue and inputNode/ - // prepare() on a fresh engine serialize inside the HAL, and - // overlapping them can park the main thread behind the teardown - // for good (#620). The generation check collapses route-change - // bursts into one rebuild of the newest configuration. - self.idleEngineRebuildGeneration &+= 1 - let generation = self.idleEngineRebuildGeneration - self.retireAudioEngine(reason: "idle_route_change:\(reason)", onDrained: { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.idleEngineRebuildGeneration == generation else { return } - self.prewarmAudioEngineIfPossible(reason: "idle_route_change") - } - }) + // 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") } return } @@ -2456,7 +2489,8 @@ final class ASRService: ObservableObject { // 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. - let drained = await self.retireAudioEngineAndAwaitDrain(reason: "audio_route_recovery") + self.retireAudioEngine(reason: "audio_route_recovery") + let drained = await self.awaitPendingEngineDrains() guard drained else { self.audioCapturePipeline.setRecordingEnabled(false) DebugLogger.shared.error( @@ -3808,9 +3842,9 @@ 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 serial queue for every retired engine: drains can never overlap each - /// other, and callers can order bring-up work behind a drain via - /// `onDrained` (#620). + /// 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? @@ -3819,6 +3853,13 @@ private final nonisolated class RetiredAudioEngineReference: @unchecked Sendable 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. /// `onDrained` runs on the drain queue after `-[AVAudioEngine dealloc]` has From 727361dfbafadb5483e29cffc381b107e48d968f Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Wed, 15 Jul 2026 18:07:43 +0600 Subject: [PATCH 3/5] fix(audio): hold start-retry engine references until bring-up completes (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The start-retry loop retired a failed engine and immediately rebuilt, which scheduled the dealloc concurrently with the retry's configureSession()/prepare() — the same HAL overlap the drain fence exists to prevent. The loop is synchronous, so awaiting the fence is not an option there; postponing the drain gives the same guarantee: engines detached during retries are held in a local list and their drains are scheduled only when startEngine() exits (success or throw), so no dealloc can run while any retry attempt is bringing an engine up. Splits retireAudioEngine into detachAudioEngineForRetirement + scheduleRetiredEngineDrain to make both orderings share one implementation. No behavior change on the first-attempt-succeeds path. Re-verified with the probe workload: zero bring-up events inside any foreign dealloc window across 28 route changes. --- Sources/Fluid/Services/ASRService.swift | 47 ++++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 1985255a..06b87da3 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -619,6 +619,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,18 +658,23 @@ 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 - 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) - } - }) } 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 @@ -2280,6 +2297,16 @@ final class ASRService: ObservableObject { DebugLogger.shared.debug("🚀 startEngine() - ENTERED", source: "ASRService") var attempts = 0 var lastError: Error? + // Engines retired by failed attempts are held here and drained only on + // exit, so a retry's configureSession()/prepare() can never run against + // an in-flight dealloc (#620). This loop is synchronous, so the async + // drain fence is not usable here; postponing the drain is equivalent. + var enginesRetiredDuringRetry: [RetiredAudioEngineReference] = [] + defer { + for retired in enginesRetiredDuringRetry { + self.scheduleRetiredEngineDrain(retired) + } + } while attempts < 3 { do { @@ -2335,7 +2362,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") { + enginesRetiredDuringRetry.append(retired) + } // Need to reconfigure the new engine try? self.configureSession() DebugLogger.shared.debug("✅ Engine recreated and reconfigured, will retry", source: "ASRService") From 9157cf542185fa712231767d12e6a43e39ae631f Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Wed, 15 Jul 2026 18:44:54 +0600 Subject: [PATCH 4/5] fix(audio): hold retry-retired engines until tap setup completes (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startEngine()'s exit came before setupEngineTap(), so on a retry-then-success the failed engine's dealloc could begin while the new engine was still reading inputFormat and installing its tap. Ownership of the held references moves up to startCompatibilityAudioCapture(), whose defer schedules the drains only after the entire bring-up — configure, start, tap — has finished or thrown. startEngine() appends detached engines to a caller-owned list instead of draining on its own exit. Probe workload re-verified: zero bring-up events inside any foreign dealloc window across 28 route changes. --- Sources/Fluid/Services/ASRService.swift | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 06b87da3..09c26c50 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -902,7 +902,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 } @@ -2293,20 +2302,15 @@ 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? - // Engines retired by failed attempts are held here and drained only on - // exit, so a retry's configureSession()/prepare() can never run against - // an in-flight dealloc (#620). This loop is synchronous, so the async - // drain fence is not usable here; postponing the drain is equivalent. - var enginesRetiredDuringRetry: [RetiredAudioEngineReference] = [] - defer { - for retired in enginesRetiredDuringRetry { - self.scheduleRetiredEngineDrain(retired) - } - } while attempts < 3 { do { @@ -2363,7 +2367,7 @@ final class ASRService: ObservableObject { if attempts < 3 { DebugLogger.shared.debug("⚠️ Start failed, recreating engine for retry...", source: "ASRService") if let retired = self.detachAudioEngineForRetirement(reason: "start_retry") { - enginesRetiredDuringRetry.append(retired) + retiredEngines.append(retired) } // Need to reconfigure the new engine try? self.configureSession() From 2eb0823777a6766dd2504b773158c387277f1817 Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Thu, 16 Jul 2026 10:32:33 +0600 Subject: [PATCH 5/5] fix(audio): abort pending start when stop arrives during drain fence (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drain fence gave start() its only suspension point ahead of isRunning = true, and every hotkey stop path funnels through stopRecordingIfNeeded(), whose isRunning guard silently dropped a release landing in that window — normally milliseconds, but up to the 5 s drain bound in exactly the wedged-teardown case this branch addresses. Capture would then come up after the key was already up and stay recording. stopRecordingIfNeeded()'s early-return now marks the in-flight start aborted via abortStartIfPending(), which arms only while isStarting is set with isRunning still false, so a stray key-up cannot poison a later session. start() re-checks the flag right after the fence and unwinds with the failure path's pipeline cleanup — no engine retire, no error notification — and the flag clears in the existing isStarting defer on every exit. Toggle mode's off-press during the window still routes to start() and is absorbed by the isStarting re-entrancy guard (self-heals on the next press); treating a duplicate start() as a stop would break key-repeat during press-and-hold, so it stays out of scope. --- Sources/Fluid/Services/ASRService.swift | 29 ++++++++++++++++++- .../Fluid/Services/GlobalHotkeyManager.swift | 4 +++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 09c26c50..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 @@ -1349,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: @@ -1420,7 +1435,10 @@ 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 { @@ -1437,6 +1455,15 @@ final class ASRService: ObservableObject { ) } } + // 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 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 }