The mic tap's format snapshot could abort the whole app #387

Merged
enricobuehler merged 1 commits from worktree-fix-mic-tap-format-abort into main 2026-08-24 09:32:28 +00:00
Owner

Found chasing a reported "Apple TV crashes on launch". There is no tvOS crash — the reports are macOS, and this one is not at launch but at session start.

The crash

Exception Type:  EXC_CRASH (SIGABRT)
AVAudioNode installTapOnBus:bufferSize:format:error:
  → _AVAE_CheckAndReturnErr → +[NSException raise:format:] → abort
SessionAudio.installMicTap(on:micUID:micChannel:)  SessionAudio.swift:1536
SessionAudio.startCapture(micUID:micChannel:)      SessionAudio.swift:1391
SessionAudio.startEngines(...)                     SessionAudio.swift:450

Reported against 0.31.0 (14460) on macOS 27.0.

Why

installTap(onBus:bufferSize:format:) validates a non-nil format against the bus and raises an Objective-C exception on any mismatch. Swift cannot catch that, so it reaches the terminate handler and aborts the process — the whole app, for a microphone that failed to attach.

The format we hand it is necessarily read a moment earlier (input.outputFormat(forBus: 0)), and on macOS the input can move underneath it in that window:

  • the default input device changes (AudioDeviceWatcher exists precisely because this happens mid-session),
  • the device changes clock/rate,
  • or the kAudioOutputUnitProperty_CurrentDevice swap that startCapture itself performs two lines before this call.

The existing guard only rejected sampleRate == 0 || channelCount == 0. That is a device that is absent — a different failure from one that changed, which is what the crash is.

The fix

Install with format: nil, which is the documented "use the bus's own format". The mismatch becomes unrepresentable rather than merely unlikely — there is no snapshot left to go stale.

The tap then has to follow the real format. The rate-dependent pieces (mono bus, resampler, and both scratch buffers — they are only ever valid as a set) move into a MicChain that the tap rebuilds when buffer.format.sampleRate differs from what the chain was built for. A chain pinned to a stale rate would resample by the wrong ratio and pitch-shift the mic, so following the format is not optional once the tap is installed with nil.

That rebuild subsumes the old grow-on-larger-quantum branch (same trigger shape, one condition wider). The steady state still allocates nothing.

Channel count and interleaving already came from buffer.format inside the tap, and foldToMono already range-checks the pinned channel per call, so those need no change.

Tests

AudioMicChainTests — 4 cases over the sizing arithmetic the rebuild rests on, no engine, device or mic grant required.

Worth calling out one: staging holds the resampled 48 kHz mono, so it must fit the upward ratio. Sized for the input rate instead, it silently truncates every packet on any device below 48 kHz — a quiet-mic bug rather than a crash, so nothing else would have caught it.

Gates

  • swift build clean
  • full suite: 384 tests, 0 failures (8 skipped)
  • macOS shell launches and runs 90s+

Not covered here

The mic tap was the one installTap site, so this closes that specific abort. It does not make AVFAudio's other raising APIs (connect, attach, converter setup) non-fatal — Swift still cannot catch those, and doing so needs an Objective-C @try shim. Worth a follow-up if more _AVAE_CheckAndReturnErr aborts show up in the reports.

Found chasing a reported "Apple TV crashes on launch". There is no tvOS crash — the reports are macOS, and this one is not at launch but at **session start**. ## The crash ``` Exception Type: EXC_CRASH (SIGABRT) AVAudioNode installTapOnBus:bufferSize:format:error: → _AVAE_CheckAndReturnErr → +[NSException raise:format:] → abort SessionAudio.installMicTap(on:micUID:micChannel:) SessionAudio.swift:1536 SessionAudio.startCapture(micUID:micChannel:) SessionAudio.swift:1391 SessionAudio.startEngines(...) SessionAudio.swift:450 ``` Reported against 0.31.0 (14460) on macOS 27.0. ## Why `installTap(onBus:bufferSize:format:)` validates a **non-nil** format against the bus and raises an Objective-C exception on any mismatch. Swift cannot catch that, so it reaches the terminate handler and aborts the process — the whole app, for a microphone that failed to attach. The format we hand it is necessarily read a moment earlier (`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath it in that window: - the default input device changes (`AudioDeviceWatcher` exists precisely because this happens mid-session), - the device changes clock/rate, - or the `kAudioOutputUnitProperty_CurrentDevice` swap that `startCapture` itself performs **two lines before** this call. The existing guard only rejected `sampleRate == 0 || channelCount == 0`. That is a device that is *absent* — a different failure from one that *changed*, which is what the crash is. ## The fix Install with `format: nil`, which is the documented "use the bus's own format". The mismatch becomes unrepresentable rather than merely unlikely — there is no snapshot left to go stale. The tap then has to follow the real format. The rate-dependent pieces (mono bus, resampler, and both scratch buffers — they are only ever valid as a set) move into a `MicChain` that the tap rebuilds when `buffer.format.sampleRate` differs from what the chain was built for. A chain pinned to a stale rate would resample by the wrong ratio and pitch-shift the mic, so following the format is not optional once the tap is installed with nil. That rebuild subsumes the old grow-on-larger-quantum branch (same trigger shape, one condition wider). The steady state still allocates nothing. Channel count and interleaving already came from `buffer.format` inside the tap, and `foldToMono` already range-checks the pinned channel per call, so those need no change. ## Tests `AudioMicChainTests` — 4 cases over the sizing arithmetic the rebuild rests on, no engine, device or mic grant required. Worth calling out one: `staging` holds the *resampled* 48 kHz mono, so it must fit the **upward** ratio. Sized for the input rate instead, it silently truncates every packet on any device below 48 kHz — a quiet-mic bug rather than a crash, so nothing else would have caught it. ## Gates - `swift build` clean - full suite: **384 tests, 0 failures** (8 skipped) - macOS shell launches and runs 90s+ ## Not covered here The mic tap was the one `installTap` site, so this closes that specific abort. It does not make AVFAudio's *other* raising APIs (`connect`, `attach`, converter setup) non-fatal — Swift still cannot catch those, and doing so needs an Objective-C `@try` shim. Worth a follow-up if more `_AVAE_CheckAndReturnErr` aborts show up in the reports.
enricobuehler added 1 commit 2026-08-24 09:21:45 +00:00
fix(clients/apple): the mic tap's format snapshot could abort the whole app
ci / bun-nix (pull_request) Successful in 27s
ci / docs-drift (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m8s
ci / web (pull_request) Successful in 1m17s
ci / rust-arm64 (pull_request) Successful in 1m59s
apple / swift (pull_request) Successful in 2m16s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 9m38s
f60b6e30e2
`installTap(onBus:bufferSize:format:)` validates a non-nil format against the
bus and raises an Objective-C exception on ANY mismatch. Swift cannot catch
that, so it reached the terminate handler and aborted the process — SIGABRT in
`AVAudioEngineGraph::InstallTapOnNode`, crashing macOS 0.31.0 at session start,
not at launch.

The format handed to the tap is necessarily read a moment earlier
(`input.outputFormat(forBus: 0)`), and on macOS the input can move underneath
it in that window: a device switch, a clock/rate change, or the
`kAudioOutputUnitProperty_CurrentDevice` swap `startCapture` itself performs two
lines before. The existing guard only rejected the 0 Hz / 0-channel case, which
is a different failure — a device that is absent, not one that changed.

Install with `format: nil` instead, which is the documented "use the bus's own
format" and makes the mismatch unrepresentable rather than merely unlikely.
The tap then has to follow the real format, so the rate-dependent pieces (mono
bus, resampler, both scratch buffers) move into a `MicChain` the tap rebuilds
when `buffer.format.sampleRate` differs — a chain pinned to a stale rate would
resample by the wrong ratio and pitch-shift the mic. That rebuild subsumes the
old grow-on-larger-quantum branch; the steady state still allocates nothing.

Tests cover the sizing arithmetic the rebuild rests on — including that
`staging` fits the UPWARD ratio, which silently truncates every packet on any
device below 48 kHz if it is sized for the input rate instead.
enricobuehler merged commit 4690a166ca into main 2026-08-24 09:32:28 +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#387