The bug (field, 2026-08-14, Mac Studio ↔ Windows host, both 0.28.0)
Streaming with the microphone enabled cut audio AND input on a ~2.5 s metronome while video ran untouched. Regression introduced in 0.27.0 (bf913c57 added the device-change recovery); 0.26.0 clean, mic-off clean, iPad clean — all user-bisected on glass.
The loop, confirmed live in log stream: the voice-processing engine cannot start on the reporter's 6-channel input device (combined engine failed to start, every time). The recovery re-tried it on every rebuild, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy fallback engines — which posted the AVAudioEngineConfigurationChange that scheduled the next rebuild. Each ~1.9 s rebuild ran on the main thread, where macOS input capture/sending lives, so input froze on the same beat. Audio, input and mic share the QUIC datagram plane while video rides its own socket — the host's mic-uplink health lines independently show one gap every 1.4–1.8 s, and its METRONOMIC keyframe heuristic pointed at the display stack, which is what made the field report read as a network fault.
Commit 1 — three layered defenses against the loop (e0c10bad)
CombinedTopologyGate (macOS): a VPIO start failure latches per input device; rebuilds go straight to the split topology instead of re-running a failure that is a property of the device. A different default input earns exactly one fresh attempt — so echo cancellation is not lost forever, and one attempt per device change cannot loop. Latched in all three startCombined fallback arms; iOS/tvOS latch nothing (routes are session-managed there).
Echo guard: AudioDeviceWatcher now passes the posting engine through; a configuration change from an engine that is running is the rebuild's own echo (engines stop themselves before posting) and is ignored.
RebuildBackoff: rebuilds that chain anyway (retrigger within 10 s of the last rebuild) double the floor 0.5 s → 30 s cap, reset by quiet — an unforeseen feedback shape costs one blip per half-minute instead of a metronome, and chaining logs a WARN that names the condition.
Both policies live in a new AudioRebuildPolicy.swift, where unit tests reach them.
Commit 2 — engine starts leave the main thread (1a8fa228)
An engine start can block on the audio server for seconds, so even a single legitimate device switch froze input for the length of the rebuild, and a mic-on session start stalled the UI at connect. All engine lifecycle work now runs on a per-session serial engineQueue; the main queue keeps only the trigger bookkeeping (debounce, backoff, retry ladder). Confinement moves with the work: ring/startConfig/enginesAttempted go under the existing stateLock, combinedGate is engineQueue-confined, and the permission-grant continuation lands on engineQueue. ⚠ 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.
Verification
295 Swift tests, 0 failures (7 new), after each commit. The loop test replays the field pattern against the real constants and is plant-the-defect verified: restoring the flat floor produces 800 rebuilds in the 10-minute simulation and fails loudly; the ladder allows ≤ 25 and the test also asserts full responsiveness returns after quiet.
swift build (macOS) and a full-package arm64-apple-ios17.0 typecheck, both green after each commit.
Not yet verified on glass — the definitive check is a mic-on stream from the Mac Studio: restarting the audio engines at most once per real device change, audio and input steady. Until then the field workaround is mic off.
## The bug (field, 2026-08-14, Mac Studio ↔ Windows host, both 0.28.0)
Streaming with the microphone **enabled** cut audio AND input on a ~2.5 s metronome while video ran untouched. Regression introduced in **0.27.0** (`bf913c57` added the device-change recovery); 0.26.0 clean, mic-off clean, iPad clean — all user-bisected on glass.
**The loop, confirmed live in `log stream`:** the voice-processing engine cannot start on the reporter's 6-channel input device (`combined engine failed to start`, every time). The recovery re-tried it on every rebuild, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy fallback engines — which posted the `AVAudioEngineConfigurationChange` that scheduled the next rebuild. Each ~1.9 s rebuild ran on the **main thread**, where macOS input capture/sending lives, so input froze on the same beat. Audio, input and mic share the QUIC datagram plane while video rides its own socket — the host's mic-uplink health lines independently show one gap every 1.4–1.8 s, and its METRONOMIC keyframe heuristic pointed at the display stack, which is what made the field report read as a network fault.
## Commit 1 — three layered defenses against the loop (`e0c10bad`)
1. **`CombinedTopologyGate`** (macOS): a VPIO start failure latches **per input device**; rebuilds go straight to the split topology instead of re-running a failure that is a property of the device. A different default input earns exactly one fresh attempt — so echo cancellation is not lost forever, and one attempt per device change cannot loop. Latched in all three `startCombined` fallback arms; iOS/tvOS latch nothing (routes are session-managed there).
2. **Echo guard**: `AudioDeviceWatcher` now passes the posting engine through; a configuration change from an engine that **is running** is the rebuild's own echo (engines stop themselves *before* posting) and is ignored.
3. **`RebuildBackoff`**: rebuilds that chain anyway (retrigger within 10 s of the last rebuild) double the floor 0.5 s → 30 s cap, reset by quiet — an unforeseen feedback shape costs one blip per half-minute instead of a metronome, and chaining logs a WARN that names the condition.
Both policies live in a new `AudioRebuildPolicy.swift`, where unit tests reach them.
## Commit 2 — engine starts leave the main thread (`1a8fa228`)
An engine start can block on the audio server for seconds, so even a single *legitimate* device switch froze input for the length of the rebuild, and a mic-on session start stalled the UI at connect. All engine lifecycle work now runs on a per-session serial `engineQueue`; the main queue keeps only the trigger bookkeeping (debounce, backoff, retry ladder). Confinement moves with the work: `ring`/`startConfig`/`enginesAttempted` go under the existing `stateLock`, `combinedGate` is engineQueue-confined, and the permission-grant continuation lands on engineQueue. ⚠ 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.
## Verification
- 295 Swift tests, 0 failures (7 new), after each commit. The loop test replays the field pattern against the real constants and is **plant-the-defect verified**: restoring the flat floor produces 800 rebuilds in the 10-minute simulation and fails loudly; the ladder allows ≤ 25 and the test also asserts full responsiveness returns after quiet.
- `swift build` (macOS) and a full-package `arm64-apple-ios17.0` typecheck, both green after each commit.
- Not yet verified on glass — the definitive check is a mic-on stream from the Mac Studio: `restarting the audio engines` at most once per real device change, audio and input steady. Until then the field workaround is mic off.
The voice-processing engine cannot start on some input devices (field case:
a 6-channel interface — 'combined engine failed to start', every time). The
device-change recovery re-tried it on every rebuild, and the failed attempt's
HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy
fallback engines, which posted the AVAudioEngineConfigurationChange that
scheduled the next rebuild: a self-sustaining ~2.5 s loop for the session's
whole life. Each ~1.9 s rebuild runs on the main thread — where macOS input
capture and sending live — so the stream's INPUT cut out on the same beat,
while video (own socket, own threads) ran untouched; the wire signature
matched network loss and the host's METRONOMIC heuristic pointed at the
display stack, which is what made the field report so misleading.
Three defenses, layered because no single one covers every feedback shape:
a VPIO start failure latches per input device (CombinedTopologyGate — a
rebuild goes straight to the split topology; a different default input earns
exactly one fresh attempt); a configuration change posted by an engine that
is RUNNING is the rebuild's own echo and is ignored (an engine stops itself
before posting, so a live poster was already restarted); and rebuilds that
chain anyway back off exponentially (RebuildBackoff, 0.5 s floor doubling to
a 30 s cap, reset by 10 s of quiet) with a WARN that names the condition.
Both policies extracted to AudioRebuildPolicy.swift where a unit test can
reach them: 7 new tests, the loop test plant-the-defect verified (the shipped
flat floor produces 800 rebuilds in the 10-minute sim; the ladder ≤ 25, and
responsiveness after quiet is asserted). iOS/tvOS semantics untouched.
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 bug (field, 2026-08-14, Mac Studio ↔ Windows host, both 0.28.0)
Streaming with the microphone enabled cut audio AND input on a ~2.5 s metronome while video ran untouched. Regression introduced in 0.27.0 (
bf913c57added the device-change recovery); 0.26.0 clean, mic-off clean, iPad clean — all user-bisected on glass.The loop, confirmed live in
log stream: the voice-processing engine cannot start on the reporter's 6-channel input device (combined engine failed to start, every time). The recovery re-tried it on every rebuild, and the failed attempt's HAL churn (VPIO builds and tears down an aggregate device) stopped the healthy fallback engines — which posted theAVAudioEngineConfigurationChangethat scheduled the next rebuild. Each ~1.9 s rebuild ran on the main thread, where macOS input capture/sending lives, so input froze on the same beat. Audio, input and mic share the QUIC datagram plane while video rides its own socket — the host's mic-uplink health lines independently show one gap every 1.4–1.8 s, and its METRONOMIC keyframe heuristic pointed at the display stack, which is what made the field report read as a network fault.Commit 1 — three layered defenses against the loop (
e0c10bad)CombinedTopologyGate(macOS): a VPIO start failure latches per input device; rebuilds go straight to the split topology instead of re-running a failure that is a property of the device. A different default input earns exactly one fresh attempt — so echo cancellation is not lost forever, and one attempt per device change cannot loop. Latched in all threestartCombinedfallback arms; iOS/tvOS latch nothing (routes are session-managed there).AudioDeviceWatchernow passes the posting engine through; a configuration change from an engine that is running is the rebuild's own echo (engines stop themselves before posting) and is ignored.RebuildBackoff: rebuilds that chain anyway (retrigger within 10 s of the last rebuild) double the floor 0.5 s → 30 s cap, reset by quiet — an unforeseen feedback shape costs one blip per half-minute instead of a metronome, and chaining logs a WARN that names the condition.Both policies live in a new
AudioRebuildPolicy.swift, where unit tests reach them.Commit 2 — engine starts leave the main thread (
1a8fa228)An engine start can block on the audio server for seconds, so even a single legitimate device switch froze input for the length of the rebuild, and a mic-on session start stalled the UI at connect. All engine lifecycle work now runs on a per-session serial
engineQueue; the main queue keeps only the trigger bookkeeping (debounce, backoff, retry ladder). Confinement moves with the work:ring/startConfig/enginesAttemptedgo under the existingstateLock,combinedGateis engineQueue-confined, and the permission-grant continuation lands on engineQueue. ⚠ 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.Verification
swift build(macOS) and a full-packagearm64-apple-ios17.0typecheck, both green after each commit.restarting the audio enginesat most once per real device change, audio and input steady. Until then the field workaround is mic off.