Skip to content

Guard AVAudioNode.installTap against Objective-C exceptions#23840

Merged
tkheyfets merged 5 commits into
mainfrom
devin/1775517702-fix-installtap-objc-exception-crash
Apr 6, 2026
Merged

Guard AVAudioNode.installTap against Objective-C exceptions#23840
tkheyfets merged 5 commits into
mainfrom
devin/1775517702-fix-installtap-objc-exception-crash

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Summary

AVAudioNode.installTap(onBus:bufferSize:format:block:) throws an Objective-C NSException (not a Swift Error) on format mismatch or stale engine state during audio route changes (Bluetooth connect/disconnect, AirPods mode switch, USB mic plug/unplug). Swift's do/catch cannot intercept NSException — it propagates unhandled through the ObjC runtime and calls abort(), crashing the app.

This PR adds a thin ObjC bridge (VLMPerformWithObjCExceptionHandling) that wraps the installTap call in @try/@catch, converting the fatal crash into a graceful return false. Applied to all installTap call sites:

  • macOS: AudioEngineController.installTapAndStartImpl (dictation + OpenAI voice)
  • iOS: InputBarView.beginRecording

The ObjC bridge is packaged as a standalone SPM target (ObjCExceptionCatcher) depended on by both VellumAssistantShared and the iOS Xcode project.

Crash report reference: Vellum Staging 0.6.1-staging.4, Thread 2 (com.vellum.audioEngine.voiceInput), EXC_CRASH (SIGABRT) in AVAudioEngineImpl::InstallTapOnNode during concurrent audio hardware state change.

Relationship to #23766: That PR (already merged) adds stopremoveTapreset before installTap to flush stale format caches — a proactive measure. This PR is the defensive complement: if the format mismatch race still occurs despite the reset, the NSException is caught instead of crashing.

Updates since initial revision

  • iOS: Added removeTap(onBus: 0) in the installTap exception handler to prevent a stale partial tap from breaking subsequent recording attempts (matches the pattern in the audioEngine.start() catch block).
  • iOS: Added AVAudioSession.setActive(false) in both the installTap exception handler and the pre-existing audioEngine.start() catch block to ensure the .record session (with .duckOthers) is deactivated on failure, preventing other apps' audio from staying ducked.

Review & Testing Checklist for Human

  • Verify Xcode build (macOS + iOS) — CI skips native builds and Swift isn't available on the build VM, so the SPM target structure, ObjC compilation, and Swift-ObjC import bridging are completely unverified. Build both targets locally in Xcode.
  • Verify #import "include/ObjCExceptionCatcher.h" resolves — the relative path in the .m file depends on SPM's working directory for clang compilation. If it fails, may need #import <ObjCExceptionCatcher/ObjCExceptionCatcher.h> instead.
  • Verify iOS XcodeGen resolves the new dependencyproject.yml now references product: ObjCExceptionCatcher; run xcodegen and confirm the Xcode project builds.
  • Test dictation during audio route change — connect/disconnect Bluetooth headphones or AirPods while triggering PTT dictation. The app should show a graceful error instead of crashing.
  • Verify the escaping tap closure inside the NS_NOESCAPE bridge block — on iOS, the installTap trailing closure (which is @escaping) is passed inside the NS_NOESCAPE block to VLMPerformWithObjCExceptionHandling. This should be fine since installTap retains the tap block internally, but worth a sanity check.

Notes

  • The ObjCExceptionCatcher target is intentionally minimal (one .h, one .m) to keep the ObjC surface area as small as possible.
  • OpenAIVoiceService.swift also calls installTapAndStart but routes through AudioEngineController, so it's already covered by the macOS fix.
  • VellumAssistantShared declares a dependency on ObjCExceptionCatcher for transitive availability; the iOS project.yml also adds an explicit dependency. The transitive dep is redundant but harmless.

Link to Devin session: https://app.devin.ai/sessions/8724a0e44b3144acb5c06a0241748b1f
Requested by: @tkheyfets


Open with Devin

AVAudioNode.installTap(onBus:bufferSize:format:block:) throws an
Objective-C NSException on format mismatch or stale engine state during
audio route changes (Bluetooth, AirPods, USB mic). Swift's do/catch
cannot intercept NSExceptions — they propagate unhandled and abort().

Add a thin ObjC bridge (VLMPerformWithObjCExceptionHandling) that wraps
the call in @try/@catch, converting the fatal crash into a graceful
return false. Applied to all installTap call sites:

- AudioEngineController.installTapAndStartImpl (macOS dictation + voice)
- InputBarView.beginRecording (iOS voice input)

Co-Authored-By: tkheyfets <timur@vellum.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69313617fe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +389 to +394
guard installed else {
log.error("installTap threw ObjC exception: \(installError?.localizedDescription ?? "unknown")")
isVoiceOrbExpanded = false
viewModel.errorText = "Voice input failed. Please try again."
cleanupRecognition()
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deactivate audio session when tap installation fails

beginRecording() activates AVAudioSession before installing the tap, but the new guard installed else { ... return } branch only calls cleanupRecognition(). If installTap throws during an audio route change, this path returns without setActive(false), so the .record session (with .duckOthers) can remain active and leave other app audio attenuated until a later successful recording teardown happens. Please deactivate the session in this failure path before returning.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 81a463a. Added try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) before cleanupRecognition() in the failure path, matching the pattern used in stopRecording() and finishRecordingForAutoStop().

Co-Authored-By: tkheyfets <timur@vellum.ai>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread clients/ios/Views/InputBarView.swift
Comment thread clients/Package.swift
),
.target(
name: "VellumAssistantShared",
dependencies: ["ObjCExceptionCatcher"],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 VellumAssistantShared depends on ObjCExceptionCatcher but doesn't use it directly

In Package.swift:45, VellumAssistantShared declares a dependency on ObjCExceptionCatcher, but the actual consumers are the iOS app (InputBarView.swift imports it directly) and the macOS library (AudioEngineController.swift imports it directly). VellumAssistantShared itself doesn't appear to import or use ObjCExceptionCatcher. The dependency on VellumAssistantShared is likely added so it transitively reaches the iOS target (which depends on VellumAssistantShared via the XcodeGen project). The iOS project.yml also adds an explicit dependency on ObjCExceptionCatcher (lines 25-26), which is the correct approach. The transitive dependency through VellumAssistantShared is redundant but harmless — worth noting for future cleanup.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the transitive dependency through VellumAssistantShared is indeed redundant now that the iOS project.yml has an explicit dependency. It's harmless and ensures the target is available regardless of how the dependency graph is resolved, but noted for future cleanup.

@tkheyfets

Copy link
Copy Markdown
Contributor

@codex review

devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: tkheyfets <timur@vellum.ai>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 iOS beginRecording() missing AVAudioSession deactivation on channelCount early return

At clients/ios/Views/InputBarView.swift:341-346, the channelCount guard returns early after the AVAudioSession has been activated (lines 323-326), but does not deactivate it with try? AVAudioSession.sharedInstance().setActive(false, ...). This PR correctly adds session deactivation to the two new/modified error paths (installTap exception at line 396, engine start failure at line 474), but misses this pre-existing path. The session remains active until the next recording attempt or app backgrounding. This is a pre-existing issue not introduced by this PR, but given the PR is specifically about hardening error paths with proper cleanup, it may be worth addressing here for consistency.

(Refers to lines 341-346)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is a pre-existing issue (the channelCount guard at line 341 predates this PR). Since the AVAudioSession activation at line 326 is also pre-existing, fixing this early return path is outside the scope of this PR. Worth a follow-up though.

@tkheyfets tkheyfets merged commit 7960050 into main Apr 6, 2026
7 checks passed
@tkheyfets tkheyfets deleted the devin/1775517702-fix-installtap-objc-exception-crash branch April 6, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant