Skip to content

Fix AudioEngine NSException crash on Begin tap (#262)#263

Merged
auerbachb merged 3 commits into
mainfrom
issue-262-audio-engine-crash
Apr 27, 2026
Merged

Fix AudioEngine NSException crash on Begin tap (#262)#263
auerbachb merged 3 commits into
mainfrom
issue-262-audio-engine-crash

Conversation

@auerbachb
Copy link
Copy Markdown
Owner

@auerbachb auerbachb commented Apr 27, 2026

User description

Summary

Build 9 (currently shipping on TestFlight) still crashes on Begin tap. The defensive `currentDay` clamp from #256 / PR #256 was a strict improvement but did not address the proximate cause. Symbolicated `.ips` from a real device (iPhone 17 Pro / iOS 26.3.1) confirms the actual crash is in `AVAudioEngine`.

Root cause

`AVAudioEngine.startAndReturnError(_:)` raises an Objective-C `NSException` (not a Swift error) on iOS 26 when called on a "bare" engine — no source node connected to `mainMixerNode`. Swift `try`/`catch` cannot catch ObjC NSExceptions, so it propagates → `std::terminate` → `abort()` → SIGABRT.

Stack from `StillPoint-2026-04-27-171902.ips`:

```
+[NSException raise:format:]
AVAudioEngineGraph::Initialize(NSError**)
AVAudioEngineImpl::Initialize(NSError**)
-[AVAudioEngine startAndReturnError:]
[StillPoint warmUp closure]
_dispatch_call_block_and_release (queue: com.stillpoint.audioengine)
```

Trigger: `AudioEngine.init()` and `AudioEngine.warmUp()` both eagerly called `engine.start()` before any source node was scheduled. The previous "warm engine" pattern was undefined behavior on iOS 26.

Fix

In `AudioEngine.swift`:

  • init(): drops the eager `ensureEngineRunning()` call. Keeps `configureAudioSession()` and lifecycle observers.
  • warmUp(): reactivates the audio session only — still useful after backgrounding / interruption — but does not start the engine.
  • resumeAfterInterruptionIfNeeded() / handleWillEnterForeground(): same — reconfigure session and clear flags; do not restart a bare engine.
  • ensureEngineRunning(): only called from `playSynthesized()` now, where `attach` + `connect` have already happened.

Bundled E2E coverage (closes #254's regression class)

While the production note-save bug from #254 could not be reproduced (closed earlier today), the existing iOS UI test had no coverage for the Save Note path at all — the button had no `accessibilityIdentifier`. Adding it here so a future regression fails the new pre-flight gate from #253 / PR #261.

  • CompletionView.swift: `accessibilityIdentifier`s on the SESSION NOTE `TextEditor` (`completion.endNoteEditor`), the Save note button (`completion.saveNoteButton`), and the "Saved" indicator (`completion.savedIndicator`)
  • StillPointAppUITests.swift: `testLaunchLoginCompleteSessionAndHistoryPersistence` is extended to type a note, tap Save note, and assert the "Saved" indicator before tapping Return

Test plan

  • `swift build` from `ios/StillPointShared/` succeeds (verified locally — Build complete in 0.51s)
  • iOS app builds cleanly under Release configuration in CI
  • iOS UI tests pass on simulator (after PR #261 lands so the runner actually runs them; until then they continue silently skipping per the existing bug)
  • On a Release-signed TestFlight install on iPhone 17 Pro / iOS 26.3.1: tapping Begin loads the timer with no crash
  • Audio still plays: tick / chime / completion all fire when their toggles are on
  • Audio session interruption recovery still works (incoming call / Siri / lock-screen)
  • After this lands, cut build 10 and verify on the same physical device the build 9 `.ips` came from

Coordination with PR #261

This PR adds a UI test that exercises the Begin → SessionView → Completion → Save Note path. PR #261 fixes the runner script so iOS UI tests actually run in CI (they have been silently skipping). Until #261 merges, the new test added here will silently skip too — that's a known interim state, not a regression. Once both land, the gate is fully closed for both the Begin-crash class and the Save-Note regression class.

Related

Closes #262

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added ability to save end-of-session notes with UI editor, indicator, and save confirmation.
  • Accessibility

    • Enhanced accessibility support by adding identifiers to note editor, save button, and status indicator.
  • Refactor

    • Optimized audio engine initialization and lifecycle management for improved performance and resource handling.

CodeAnt-AI Description

Prevent the app from crashing when audio starts or resumes

What Changed

  • The audio engine no longer starts too early, avoiding the crash that could happen when tapping Begin on iOS 26.
  • Backgrounding, foregrounding, and interruption recovery now only reactivate the audio session; the engine starts later, when a sound is actually played.
  • The session note field, Save note button, and Saved label now have test hooks so the end-of-session flow can be verified automatically.
  • The main UI test now types a note, saves it, and checks for the Saved state before returning home.

Impact

✅ Fewer Begin-tap crashes
✅ Safer audio resume after interruptions
✅ More reliable end-of-session note saving

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Build 9 still crashes on Begin despite the defensive currentDay clamp
from #256. Symbolicated .ips from a real device (iPhone 17 Pro / iOS
26.3.1) shows the proximate cause is AVAudioEngine, not currentDay:

  +[NSException raise:format:]
  AVAudioEngineGraph::Initialize(NSError**)
  -[AVAudioEngine startAndReturnError:]
  [StillPoint warmUp closure]
  _dispatch_call_block_and_release  (queue: com.stillpoint.audioengine)

AVAudioEngine.start() raises an Objective-C NSException (not a Swift
error) on iOS 26 when invoked on a "bare" engine — no source node
connected to mainMixerNode. Swift try/catch cannot catch NSExceptions,
so it propagates to std::terminate -> abort -> SIGABRT.

The trigger is the previous "warm engine" pattern: AudioEngine.init()
and AudioEngine.warmUp() both eagerly called engine.start() before any
sound was scheduled. Fix: only start the engine inside playSynthesized()
where attach + connect have already happened.

- AudioEngine.init: drop the eager ensureEngineRunning() call. Keep
  configureAudioSession() and observers.
- AudioEngine.warmUp: reactivate the audio session only (still useful
  after backgrounding); engine.start() is the playSynthesized path's
  responsibility.
- resumeAfterInterruptionIfNeeded / handleWillEnterForeground: same —
  reconfigure session and clear flags; don't restart a bare engine.
- ensureEngineRunning is now only called from playSynthesized, which
  attaches + connects a source node before starting.

Bundles E2E coverage for issue #254's regression class:
- CompletionView gets accessibility identifiers on the SESSION NOTE
  TextEditor, the Save note button, and the "Saved" indicator
- testLaunchLoginCompleteSessionAndHistoryPersistence is extended to
  type a note, tap Save note, and assert the "Saved" indicator before
  returning home

Together with #261's pre-flight gate, a future regression on either
the Begin-button path or the end-of-session save will fail CI before
TestFlight upload.

Closes #262

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
still-point Ignored Ignored Preview Apr 27, 2026 9:48pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR adds accessibility identifiers to completion view UI elements, implements a UI test for end-of-session note saving, and refactors the AudioEngine lifecycle to defer engine startup from initialization to actual playback via the playSynthesized() method.

Changes

Cohort / File(s) Summary
Completion View Accessibility
ios/StillPointApp/Views/CompletionView.swift
Added three accessibility identifiers: end note editor, save button, and "Saved" indicator. No functional logic changes.
UI Test Save Flow
ios/StillPointAppUITests/StillPointAppUITests.swift
Implemented end-of-session save interaction test: taps end note editor, types text, waits for save button, taps save, verifies "Saved" indicator visibility.
AudioEngine Lifecycle Refactoring
ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift
Removed eager engine startup during initialization and warmUp; eliminated .AVAudioEngineConfigurationChange observer; deferred engine-start responsibility to playSynthesized() path; simplified interruption handling to only reconfigure session without restarting engine; streamlined background/foreground lifecycle handlers.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Hop-along to save, no rush to start,
The engine waits for play's true art,
Accessibility marks the path so clear,
A note preserved, the story's near! 📝✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix AudioEngine NSException crash on Begin tap' directly and clearly describes the main bug fix in the changeset—preventing an NSException crash in AudioEngine initialization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-262-audio-engine-crash

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift Outdated
After the parent commit's fix, resumeAfterInterruptionIfNeeded() no
longer calls engine.start(), so the pendingResumeAfterConfigurationChange
flag and its AVAudioEngineConfigurationChange-driven retry handler are
dead code. Removing all three (the field, the observer, and
handleEngineConfigurationChange) keeps the state machine honest.

The interruption-end path stays correct: handleInterruption(.ended)
checks shouldResume && wasRunningBeforeInterruption directly and calls
resumeAfterInterruptionIfNeeded() (which reactivates the session and
returns; engine restart still happens lazily on next playSynthesized).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@auerbachb
Copy link
Copy Markdown
Owner Author

@cursor review

Comment thread ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift
After the prior dead-code removal, wasRunningBeforeBackground is only
set + consumed locally in handleDidEnterBackground (the if-pause check)
and reset to false in handleWillEnterForeground without ever being
read. Replace with a direct engine.isRunning check in
handleDidEnterBackground; drop the property and the foreground reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI is reviewing your PR.

@codeant-ai codeant-ai Bot added the size:M This PR changes 30-99 lines, ignoring generated files label Apr 27, 2026
Comment on lines +217 to 218
if shouldResume && self.wasRunningBeforeInterruption {
self.resumeAfterInterruptionIfNeeded()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The interruption-ended path now reactivates the audio session only when the engine was already running before the interruption. That is too strict after this PR, because the engine is intentionally lazy-started and is often not running yet when an interruption occurs; in that case session reactivation is skipped and subsequent playback can fail/silently not resume. Reconfigure the session on interruption end whenever resume is allowed, independent of prior engine running state. [logic error]

Severity Level: Major ⚠️
- ❌ Session tick/chime/completion sounds fail after specific interruptions.
- ⚠️ Meditation sessions lose audio cues until app restart.
- ⚠️ Interruption handling skips session reactivation when engine idle.
Steps of Reproduction ✅
1. Start a meditation session so that `SessionViewModel.start()` runs
(`ios/StillPointApp/ViewModels/SessionViewModel.swift:3-29`), which calls
`AudioEngine.shared.warmUp()` at line 25 when any sound preference is enabled.

2. `AudioEngine.warmUp()`
(`ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift:95-101`) enqueues
`configureAudioSession()`, which activates the `AVAudioSession`, but the `AVAudioEngine`
is still not running because no sound has yet been played (no call to
`playSynthesized(...)`).

3. Before the first tick or chime plays (i.e., within the first second of the session,
before `SessionViewModel.tick()` at lines 183-225 reaches `AudioEngine.shared.playTick()`
at line 210), trigger a system audio interruption such as an incoming phone call or Siri,
which posts `AVAudioSession.interruptionNotification`.

4. The notification is observed by `installLifecycleObservers()`
(`AudioEngine.swift:59-88`), which forwards it into `handleInterruption(_:)` at
`AudioEngine.swift:198-224`; in the `.began` case at lines 207-212, `engine.isRunning` is
`false`, so `wasRunningBeforeInterruption` stays `false` and `engine.pause()` is not
called.

5. When the interruption ends, the `.ended` case at `AudioEngine.swift:213-220` executes:
`shouldResume` becomes `true` if the system indicates playback should resume, but because
`wasRunningBeforeInterruption` is still `false`, the condition `if shouldResume &&
self.wasRunningBeforeInterruption {` at line 217 fails and
`self.resumeAfterInterruptionIfNeeded()` at line 218 is never called, so
`configureAudioSession()` is not re-run and the audio session remains inactive.

6. After the call/Siri ends, when the session reaches 1s elapsed,
`SessionViewModel.tick()` calls `AudioEngine.shared.playTick()`
(`SessionViewModel.swift:207-210`), which flows into
`playSynthesized(...)->ensureEngineRunning()` (`AudioEngine.swift:189-196`);
`ensureEngineRunning()` sees `engine.isRunning == false` and calls `engine.start()` while
the `AVAudioSession` is still inactive, so `start()` can fail and log `AudioEngine: Failed
to start engine: ...`, resulting in silent failure of tick/chime/completion sounds for the
remainder of the session until the user restarts the session/app.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** ios/StillPointShared/Sources/StillPointShared/AudioEngine.swift
**Line:** 217:218
**Comment:**
	*Logic Error: The interruption-ended path now reactivates the audio session only when the engine was already running before the interruption. That is too strict after this PR, because the engine is intentionally lazy-started and is often not running yet when an interruption occurs; in that case session reactivation is skipped and subsequent playback can fail/silently not resume. Reconfigure the session on interruption end whenever resume is allowed, independent of prior engine running state.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 27, 2026

CodeAnt AI finished reviewing your PR.

@auerbachb
Copy link
Copy Markdown
Owner Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 1079f3d. Configure here.

@auerbachb auerbachb merged commit 7796bfc into main Apr 27, 2026
15 checks passed
@auerbachb auerbachb deleted the issue-262-audio-engine-crash branch April 27, 2026 22:02
auerbachb added a commit that referenced this pull request Apr 27, 2026
* Bump iOS to 1.0.3 (build 10) to ship AudioEngine fix (#264)

Build 9 (currently shipping on TestFlight) crashes on Begin tap because
of the AVAudioEngine NSException bug. The fix landed in main at 7796bfc
(PR #263, closes #262) but won't reach users until a new build is uploaded.

- ios/project.yml: CURRENT_PROJECT_VERSION 9 -> 10
- ios/QA_CHECKLIST.md: header + line 34 referencing build 10
- ios/RELEASING.md: example commands updated to ios-v1.0.3-build10

Closes #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Genericize TestFlight smoke-test target wording (CR Minor)

The previous wording referenced 'build 9' specifically; replace with
'this build' so the line stays correct across future build bumps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:M This PR changes 30-99 lines, ignoring generated files and removed size:M This PR changes 30-99 lines, ignoring generated files labels May 5, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

Sequence Diagram

This PR defers AVAudioEngine start until a source node is attached to prevent an NSException crash on Begin, and adds an end-of-session note save flow with testable accessibility hooks.

sequenceDiagram
    participant User
    participant App
    participant AudioEngine
    participant AudioSession
    participant AVAudioEngine
    participant UITest

    %% Audio engine safe start on Begin
    User->>App: Tap Begin
    App->>AudioEngine: Request session tick playback
    AudioEngine->>AudioSession: Configure and activate session if needed
    AudioEngine->>AVAudioEngine: Attach source node and start engine
    AVAudioEngine-->>App: Audio playback running without crash

    %% Completion note save with E2E coverage
    UITest->>App: Navigate to completion view
    UITest->>App: Type note and tap Save note
    App->>App: Save end note and mark as saved
    App-->>UITest: Show Saved indicator
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

CodeAnt AI finished running the review.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:M This PR changes 30-99 lines, ignoring generated files and removed size:M This PR changes 30-99 lines, ignoring generated files labels May 5, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

Sequence Diagram

This PR changes the audio playback flow so the shared AudioEngine only starts AVAudioEngine when a source node is attached for an actual sound, while lifecycle events merely reconfigure the audio session. This prevents the iOS 26 NSException crash from starting a bare engine and is covered end-to-end by updated tests.

sequenceDiagram
    participant User
    participant App
    participant AudioEngine
    participant AVAudioEngine
    participant AVAudioSession

    User->>App: Begin session / trigger sound
    App->>AudioEngine: Play synthesized sound
    AudioEngine->>AVAudioEngine: Attach and connect source node
    AudioEngine->>AVAudioEngine: Start engine if not running
    AVAudioEngine-->>AudioEngine: Play synthesized audio frames

    AVAudioSession->>AudioEngine: Interruption or background notification
    AudioEngine->>AVAudioEngine: Pause engine if running
    App->>AudioEngine: Warm up or resume after interruption
    AudioEngine->>AVAudioSession: Reactivate audio session only
    note over AudioEngine,AVAudioEngine: Engine restarts lazily on next play call when a source node is attached
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 5, 2026

CodeAnt AI finished running the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: AudioEngine.start() raises uncaught NSException → crash on Begin (build 9) iOS: saving a note at end of meditation session errors out

1 participant