Apple audio engine starts leave the main thread — input never waits on the audio server #223

Merged
enricobuehler merged 1 commits from worktree-macos-mic-rebuild-loop into main 2026-08-14 11:05:58 +00:00
Owner

The second half of #221, which was merged while this commit was still in flight — the PR body's "Commit 2" section described it, but the merge caught only e0c10bad.

An engine start can block on the audio server for seconds (~1.9 s per attempt in the 2026-08-14 field case), and macOS captures and sends the stream's input from the main thread — so every device-change rebuild, loop or no loop, froze the stream's input for the length of the rebuild, and a mic-on session start stalled the UI at connect.

All engine lifecycle work (start/startEngines and below, teardown, rebuild) now runs on a per-session serial engineQueue; the main queue keeps only the trigger bookkeeping — debounce, backoff, retry ladder — which is cheap by construction. The rebuild path splits accordingly: rebuildFire (main: bookkeeping) → performRebuild (engineQueue: teardown + start) → rebuildFailed (main: ladder scheduling; a fresh trigger already queued wins over a retry).

Confinement moves with the work: ring, startConfig and enginesAttempted go under the existing stateLock (start paths write on engineQueue; stats and the revive gate read elsewhere); combinedGate is engineQueue-confined; the permission-grant continuation lands on engineQueue instead of main. The engines were already lock-guarded and stopped cross-thread by stop(), and every start path already re-checks the stop flag after publishing, so the in-flight-start-vs-stop race keeps its existing resolution.

⚠ Embedder-visible edge: SessionAudio.start() is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is live shortly after the call, not on return; stats is safe from any thread.

Gates (run on exactly this commit): swift build macOS + 295 tests, 0 failures + full-package arm64-apple-ios17.0 typecheck.

The second half of #221, which was merged while this commit was still in flight — the PR body's "Commit 2" section described it, but the merge caught only `e0c10bad`. An engine start can block on the audio server for seconds (~1.9 s per attempt in the 2026-08-14 field case), and macOS captures and sends the stream's input from the main thread — so every device-change rebuild, loop or no loop, froze the stream's input for the length of the rebuild, and a mic-on session start stalled the UI at connect. All engine lifecycle work (start/startEngines and below, teardown, rebuild) now runs on a per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping — debounce, backoff, retry ladder — which is cheap by construction. The rebuild path splits accordingly: `rebuildFire` (main: bookkeeping) → `performRebuild` (engineQueue: teardown + start) → `rebuildFailed` (main: ladder scheduling; a fresh trigger already queued wins over a retry). Confinement moves with the work: `ring`, `startConfig` and `enginesAttempted` go under the existing `stateLock` (start paths write on engineQueue; `stats` and the revive gate read elsewhere); `combinedGate` is engineQueue-confined; the permission-grant continuation lands on engineQueue instead of main. The engines were already lock-guarded and stopped cross-thread by `stop()`, and every start path already re-checks the stop flag after publishing, so the in-flight-start-vs-stop race keeps its existing resolution. ⚠ Embedder-visible edge: `SessionAudio.start()` is now asynchronous on macOS too (it always was on iOS/tvOS) — playback is live shortly after the call, not on return; `stats` is safe from any thread. Gates (run on exactly this commit): `swift build` macOS + 295 tests, 0 failures + full-package `arm64-apple-ios17.0` typecheck.
enricobuehler added 1 commit 2026-08-14 11:05:43 +00:00
fix(apple): engine starts leave the main thread — input never waits on the audio server
ci / rust (pull_request) Successful in 5m26s
apple / swift (pull_request) Successful in 1m57s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 5m7s
ci / bun-nix (pull_request) Successful in 2m11s
ci / web (pull_request) Successful in 3m32s
ci / docs-site (pull_request) Successful in 3m32s
1a8fa2282f
An AVAudioEngine start can block on the audio server for seconds (~1.9 s
per attempt in the 2026-08-14 field case), and macOS captures and sends the
stream's input from the main thread — so every device-change rebuild, loop
or no loop, froze the stream's input for the length of the rebuild, and a
mic-on session start stalled the UI at connect.

All engine lifecycle work (start/startEngines and below, teardown, rebuild)
now runs on a per-session serial engineQueue; the main queue keeps only the
trigger bookkeeping — debounce, backoff, and the retry ladder — which is
cheap by construction. The rebuild path splits accordingly: rebuildFire
(main: bookkeeping, reads the config) → performRebuild (engineQueue: the
actual teardown + start) → rebuildFailed (main: ladder scheduling; a fresh
trigger already queued wins over a retry).

Confinement moves with the work: ring, startConfig and enginesAttempted go
under the existing stateLock (start paths write on engineQueue, stats and
the revive gate read elsewhere); combinedGate is engineQueue-confined; the
permission-grant continuation lands on engineQueue instead of main. The
engines were already lock-guarded and stopped cross-thread by stop(), and
every start path already re-checks the stop flag after publishing, so the
in-flight-start-vs-stop race keeps its existing resolution.

Embedder-visible edge: SessionAudio.start() is now asynchronous on macOS
too (it always was on iOS/tvOS) — playback is live shortly after the call,
not on return; stats is safe from any thread.

Gates: swift build + 295 tests 0 failures (macOS), full-package
arm64-apple-ios17.0 typecheck.
enricobuehler merged commit 7cb70bf6ea into main 2026-08-14 11:05:58 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#223