Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 184 additions & 8 deletions Sources/Fluid/Services/ASRService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Comment on lines +1449 to +1450

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 Handle stops while waiting for engine drains

When pendingEngineDrainCount > 0, this new await suspends start() before isRunning is set; during that window stop paths such as GlobalHotkeyManager.stopRecordingIfNeeded still 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 and start() continues into startPreferredAudioCapture() after the drain, leaving recording active after the user released/stopped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

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 through stopRecordingIfNeeded(), whose isRunning guard 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 calls abortStartIfPending(), which arms only while isStarting is set with isRunning still false — a stray key-up can't poison a later session — and start() 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 existing isStarting defer on every exit).

Scope note: toggle mode's off-press during the window 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 that stays as-is. The pre-existing sub-millisecond window (release processed before start()'s first synchronous slice) is unchanged — it exists on main today and would need a hotkey-side pending-start handshake if ever worth closing.

throw NSError(
Comment on lines +1449 to +1451

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 Fence retry rebuilds behind pending drains

This only fences drains that are already pending before startPreferredAudioCapture() runs. If the first engine.start() attempt fails, startEngine() still calls retireAudioEngine(reason: "start_retry") and immediately configureSession() for the retry without awaiting pendingEngineDrainCount, so device churn that triggers a start retry can still instantiate/prepare a new AVAudioEngine while the retired one is deallocating—the same HAL overlap this patch is trying to eliminate. The retry path needs the same drain fence before rebuilding.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 retireAudioEngine/configureSession/prepareDirectAudioInputIfPossible call site to confirm). Fixed in 727361d, though with postponement rather than the fence: startEngine()'s retry loop is synchronous, so it can't await. The invariant is "no dealloc concurrent with bring-up", and either ordering satisfies it — the retry path now detaches the failed engine into a local holder (detachAudioEngineForRetirement) and schedules all held drains in a defer when startEngine() exits (success or throw). A stopped, tap-removed engine sitting referenced for the duration of the loop is inert; its teardown handshake only happens at dealloc, which now cannot start until every retry attempt has finished bringing an engine up. retireAudioEngine and the retry path share one implementation (detach + scheduleRetiredEngineDrain), so the drains still land on the shared serial queue and still count toward the fence that gates prewarm/start/recovery. First-attempt-succeeds path is unchanged. Probe workload re-verified after the change: zero bring-up events inside any foreign dealloc window.

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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Fluid/Services/GlobalHotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading