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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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,startConfigandenginesAttemptedgo under the existingstateLock(start paths write on engineQueue;statsand the revive gate read elsewhere);combinedGateis engineQueue-confined; the permission-grant continuation lands on engineQueue instead of main. The engines were already lock-guarded and stopped cross-thread bystop(), 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;statsis safe from any thread.Gates (run on exactly this commit):
swift buildmacOS + 295 tests, 0 failures + full-packagearm64-apple-ios17.0typecheck.