-
-
Notifications
You must be signed in to change notification settings - Fork 503
fix(audio): sequence engine rebuild behind retired-engine teardown to stop launch freeze (#620) #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(audio): sequence engine rebuild behind retired-engine teardown to stop launch freeze (#620) #621
Changes from all commits
cf995a7
7679bb1
727361d
9157cf5
2eb0823
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Void, Never>? | ||
| 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<Void, Never>? | ||
| 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( | ||
|
Comment on lines
+1449
to
+1451
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This only fences drains that are already pending before Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right again — that was the last unfenced rebuild site (I re-audited every |
||
| 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() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
pendingEngineDrainCount > 0, this new await suspendsstart()beforeisRunningis set; during that window stop paths such asGlobalHotkeyManager.stopRecordingIfNeededstill return early on!asrService.isRunning. In press-and-hold mode, or if the user toggles off while an idle route-change drain is pending, that stop request is dropped andstart()continues intostartPreferredAudioCapture()after the drain, leaving recording active after the user released/stopped.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid — the fence added the only suspension ahead of
isRunning = true, and every hotkey stop path funnels throughstopRecordingIfNeeded(), whoseisRunningguard 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 is about). Fixed in 2eb0823: the funnel's early-return now callsabortStartIfPending(), which arms only whileisStartingis set withisRunningstill false — a stray key-up can't poison a later session — andstart()re-checks the flag immediately after the fence, unwinding with the failure path's pipeline cleanup (no retire, no error notification; the flag clears in the existingisStartingdefer on every exit).Scope note: toggle mode's off-press during the window routes to
start()and is absorbed by theisStartingre-entrancy guard (self-heals on the next press); treating a duplicatestart()as a stop would break key-repeat during press-and-hold, so that stays as-is. The pre-existing sub-millisecond window (release processed beforestart()'s first synchronous slice) is unchanged — it exists on main today and would need a hotkey-side pending-start handshake if ever worth closing.