Skip to content

fix(audio): sequence engine rebuild behind retired-engine teardown to stop launch freeze (#620)#621

Open
devzahirul wants to merge 5 commits into
altic-dev:mainfrom
devzahirul:fix/620-idle-route-rebuild-freeze
Open

fix(audio): sequence engine rebuild behind retired-engine teardown to stop launch freeze (#620)#621
devzahirul wants to merge 5 commits into
altic-dev:mainfrom
devzahirul:fix/620-idle-route-rebuild-freeze

Conversation

@devzahirul

Copy link
Copy Markdown
Contributor

Description

Fixes the permanent launch-time freeze reported in #620 (1.6.4 build 15).

What the spindump shows. The main thread is parked in kevent_id — a dispatch workloop sync-wait — and the kernel's turnstile marks it as blocked on an in-process worker (0xcd437a) that is itself parked forever in psynch_cvwait. A second worker (0xcd438f) is also stuck in a condition wait, and every CoreAudio/AudioToolbox thread (caulk messengers, AUScheduledParameterRefresher) went idle at the same instant, ~15 s after launch. That is a main-thread-waits-on-audio-workloop deadlock, the same family as #542.

Root cause. Since the #542 fix, a retired AVAudioEngine's final release always drains on a background queue — but the idle route-change path immediately builds the replacement engine on the main thread in the same call:

self.retireAudioEngine(reason: "idle_route_change:\(reason)")   // dealloc drains concurrently on a global queue
self.prewarmAudioEngineIfPossible(reason: "idle_route_change")  // new engine inputNode/prepare() on main, right now

-[AVAudioEngine dealloc] and inputNode/prepare() on a fresh engine both serialize inside the HAL. When they interleave badly, the main thread's bring-up parks behind the wedged teardown — permanently. Route changes fire in bursts right after launch (Bluetooth devices reconnecting, default device settling), which is why the freeze presents as "opens … freezes" and survives a reinstall: it needs only a route change while the app is idle with a prewarmed engine. The mid-recording recovery path (recoverAudioRoute) has the same overlap.

The fix — never overlap engine teardown with engine bring-up:

  • Retired engines now drain on a single serial queue (drains can no longer race each other either), and scheduleRelease(onDrained:) lets callers order follow-up work strictly after dealloc has completed.
  • Idle route change: the rebuild moves into the drain completion. A generation counter collapses a burst of route changes into one rebuild of the newest configuration.
  • Mid-recording recovery: cooperatively awaits the drain (bounded at 5 s; the main thread stays responsive — this is a suspension, not a block). If the teardown ever wedges, recording stops cleanly through the existing device-disconnected path instead of racing a new engine against it. isRunning is re-checked after the suspension since recording can legitimately stop while the drain runs.

Verified live

With the app idle and the engine prewarmed, programmatically flipping the macOS default output device (aggregate-device wrap → restore) triggers the exact code path. Before, retire and rebuild ran in the same instant; now the log shows the rebuild strictly after the drain, and the app stays fully responsive across repeated flips:

[16:35:33.489] Audio engine retired (idle_route_change:default output changed)
[16:35:33.518] audio_engine_prewarm reason=idle_route_change elapsedMs=6   ← ~30 ms later, after dealloc completed
[16:35:38.489] Audio engine retired (idle_route_change:default output changed)
[16:35:38.520] audio_engine_prewarm reason=idle_route_change elapsedMs=6

Type of Change

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

Related Issue or Discussion

Closes #620

Testing

  • Tested on Intel Mac
  • Tested on Apple Silicon Mac
  • Tested on macOS version: 26.5.2
  • Ran linter locally: swiftlint --strict --config .swiftlint.yml Sources — 0 violations
  • Ran formatter locally: swiftformat --config .swiftformat Sources
  • Ran tests locally: full xcodebuild test suite with CI's invocation — all suites pass

Screenshots / Video

  • No UI/visual changes; screenshots/video are not applicable.

Notes

  • The reporter's spindump has truncated app frames, so the exact wedge point inside the HAL can't be read off it — but the turnstile chain (main → workloop sync-wait → in-process servicer in a condition wait, second worker also in a condition wait) matches this teardown/bring-up overlap, which is demonstrably present in the code and demonstrably eliminated by the sequencing. If the reporter can attach a full spindump, the frames should confirm.
  • Behavior cost is nil in practice: the idle rebuild lands ~30 ms later than before (after dealloc), and recording recovery adds a drain await that normally completes in milliseconds inside its existing 1 s debounce window.

…ltic-dev#620)

An idle route change retires the prewarmed AVAudioEngine — whose
dealloc always drains on a background queue since the altic-dev#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 altic-dev#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.

@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: cf995a71a9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
// 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

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 Gate recording starts on the pending idle drain

When an idle route change retires a warm engine, this only sequences the background prewarmAudioEngineIfPossible behind onDrained, but engineStorage is cleared immediately. If the user starts recording before the drain callback fires, startPreferredAudioCapture() can still fall through to configureSession()/startEngine() and instantiate/prepare a new AVAudioEngine while the retired one is deallocating, recreating the HAL overlap this change is trying to avoid. Consider tracking the pending drain and awaiting it from the start path as well as from idle prewarm.

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.

Agreed and fixed in 7679bb1start() now awaits awaitPendingEngineDrains() before startPreferredAudioCapture() whenever a drain is pending (the common path pays nothing; the count is only non-zero for the few milliseconds a dealloc is in flight). If teardown doesn't settle within 5s, start fails through the existing start-failure notification instead of entering the HAL against the wedged teardown. Together with the self-deferring prewarm, every path into configureSession/startEngine/prepareDirectAudioInputIfPossible is now behind the same fence.

@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes the launch-time freeze (#620) by ensuring a retired AVAudioEngine's dealloc on the HAL's internal serial queue never overlaps a fresh engine's inputNode/prepare() call. The fix centers on RetiredAudioEngineReference switching from a per-instance global queue to a single shared serial drainQueue, plus a pendingEngineDrainCount MainActor counter that gates every bring-up path.

  • Idle route-change path: prewarmAudioEngineIfPossible checks pendingEngineDrainCount == 0 and, if not, defers itself via notifyAfterPendingDrains; coalescion through the hasPreparedAudioCapture guard eliminates the generation-counter that was previously needed.
  • Mid-recording recovery (recoverAudioRoute): cooperatively awaits the drain fence (5 s timeout) with awaitPendingEngineDrains; a wedged teardown now causes a clean stop via the existing device-disconnected path instead of racing a new engine against it.
  • start() drain fence + abortStartIfPending: a fast-path check at the top of start() awaits any in-flight drains; GlobalHotkeyManager sets the new startAbortRequested flag on key-release so a recording session that was suspended at the fence is cancelled rather than materialising after the user has already let go.

Confidence Score: 5/5

Safe to merge — the sequencing logic is correct, the drain fence handles burst route-changes cleanly, and all three bring-up paths (idle prewarm, mid-recording recovery, start-time fence) are correctly guarded.

The change is a well-scoped concurrency fix. The serial drain queue provides cross-retirement ordering by construction; the pendingEngineDrainCount counter increments and decrements symmetrically; the async fence loop is bounded by the 5-second timeout and the while-loop re-checks the count after each yield, correctly handling late-arriving MainActor decrements and any new drains scheduled during a suspension. The abortStartIfPending / startAbortRequested pair closes the window where a key-release could be silently dropped while start() was suspended at the fence. No new bring-up path touches the HAL while a dealloc is in flight.

No files require special attention.

Reviews (5): Last reviewed commit: "fix(audio): abort pending start when sto..." | Re-trigger Greptile

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
Comment thread Sources/Fluid/Services/ASRService.swift Outdated
@devzahirul

Copy link
Copy Markdown
Contributor Author

Measured proof of both the defect and the fix, since the reporter's spindump frames are truncated.

Method. I built a probe dylib (loaded via DYLD_INSERT_LIBRARIES, local ad-hoc builds only) that swizzles AVAudioEngine init / inputNode / prepare / dealloc and logs each enter/exit with a monotonic timestamp, engine pointer, and thread. I then ran the identical workload against 1.6.4 (724be13) and this branch: with the app idle and prewarmed, programmatically flip the macOS default output device 6× at 800 ms spacing, then 8× at 120 ms (aggregate-device wrap → restore), i.e. 28 idle route changes per run. Both runs produced exactly 28 dealloc windows and 209 bring-up events, so the comparison is controlled.

Defect, on 1.6.4: the race precondition is present on essentially every idle route change — 37 bring-up events (init/inputNode, main thread) landed strictly inside a different engine's in-flight dealloc window (background thread). Several dealloc_exit → next-bring-up gaps measured 0.0 ms, i.e. the successor engine was entering the HAL while the predecessor was still tearing down inside it. On this wired-only test Mac each dealloc takes ~1 ms so the machine tolerates the overlap; on a Bluetooth teardown (the reporter's environment) that window stretches to whatever the device-stop handshake takes, and the spindump shows what happens when the HAL serializes the main thread's bring-up behind a wedged teardown: main parked in kevent_id, turnstile-blocked on an in-process workloop servicer in psynch_cvwait, second worker (the drain) also in a condition wait.

Fix, this branch, same workload: 0 bring-up events inside any foreign dealloc window across all 28 route changes; minimum dealloc_exit → bring-up gap 23.1 ms (every rebuild strictly after teardown completed, including the 120 ms burst); no concurrent dealloc windows (serial drain queue); app responsive throughout, main thread never observed in kevent_id.

Ordering argument (why it can't recur through this path): onDrained runs on the serial drain queue strictly after drain() returns, and drain() returns only after -[AVAudioEngine dealloc] completes — so the rebuild has a happens-after edge on teardown by construction, not by timing. The drain queue never waits on the main thread (onDrained only spawns a MainActor task), so no #542-style inversion is reintroduced; the recording path suspends (cooperatively — the run loop keeps servicing) rather than blocks, re-checks isRunning after the suspension, and bails out through the existing device-disconnected path if a teardown ever exceeds 5 s instead of racing a new engine against it.

What this doesn't prove: the exact frames of the reporter's wedge (their spindump is truncated — full spindump requested on #620). What it does prove: 1.6.4 overlapped engine teardown with engine bring-up on every idle route change, that overlap is the only in-code path matching all three thread signatures in the spindump simultaneously, and this branch eliminates the overlap categorically under the same workload.

…v#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.
@devzahirul

Copy link
Copy Markdown
Contributor Author

Proof re-validated after the review rework in 7679bb1 (the sequencing mechanism changed from per-retire completions to a drain fence, so the measurement was repeated): same probe, same workload — 28 idle route changes, 28 dealloc windows, 209 bring-up events. Result unchanged: 0 bring-up events inside any foreign dealloc window, 0 concurrent deallocs, minimum dealloc_exit → bring-up gap 21.2 ms, app responsive throughout. The fence now also covers recording starts, which the measured run couldn't exercise (no synthetic keypress) but which shares the identical awaitPendingEngineDrains machinery.

@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: 7679bb18ad

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1405 to +1407
if self.pendingEngineDrainCount > 0 {
guard await self.awaitPendingEngineDrains() else {
throw NSError(

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.

…es (altic-dev#620)

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.

@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: 727361dfba

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread Sources/Fluid/Services/ASRService.swift Outdated
Comment on lines +2305 to +2308
defer {
for retired in enginesRetiredDuringRetry {
self.scheduleRetiredEngineDrain(retired)
}

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 Delay retry drains until tap setup completes

Fresh evidence after the retry fix: startCompatibilityAudioCapture() still calls setupEngineTap() immediately after startEngine() returns, but this defer schedules all failed-attempt engines for dealloc as soon as startEngine() exits. When attempt 1 fails and attempt 2 succeeds, the old engine can dealloc on the drain queue while the new engine is still reading inputFormat/installing the tap on the main actor, so the retry path can still overlap HAL teardown with bring-up. Keep these references alive until the whole compatibility setup, including tap installation, has completed.

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.

Correct — the hold ended one step too early; setupEngineTap()'s inputFormat/installTap are still bring-up. Fixed in the follow-up commit: startEngine(holdingRetiredEnginesIn:) now appends detached engines to a caller-owned list, and startCompatibilityAudioCapture() owns the defer, so drains are scheduled only after configure + start + tap install have all completed (or thrown — the defer covers both exits, and a subsequent failure-path retire drains behind the held ones on the same serial queue). Probe workload re-verified after the change: zero bring-up events inside any foreign dealloc window.

…ic-dev#620)

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.

@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: 9157cf5421

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +1431 to +1432
if self.pendingEngineDrainCount > 0 {
guard await self.awaitPendingEngineDrains() else {

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.

…ltic-dev#620)

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.
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.

[BUG] App not responding on MacOS after update

1 participant