The macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s #221

Merged
enricobuehler merged 1 commits from worktree-macos-mic-rebuild-loop into main 2026-08-14 10:49:39 +00:00
Owner

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 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.
enricobuehler added 1 commit 2026-08-14 10:45:50 +00:00
fix(apple): the macOS device-change recovery answered itself — mic-on streams cut audio and input every ~2.5 s
ci / bun-nix (pull_request) Successful in 22s
ci / docs-site (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m25s
apple / swift (pull_request) Successful in 2m6s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 4m33s
e0c10bad85
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.
enricobuehler merged commit d669064dc0 into main 2026-08-14 10:49:39 +00:00
enricobuehler deleted branch worktree-macos-mic-rebuild-loop 2026-08-14 10:49:41 +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#221