Fix AudioEngine NSException crash on Begin tap (#262)#263
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📝 WalkthroughWalkthroughThis 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 Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
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>
|
@cursor review |
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 is reviewing your PR. |
| if shouldResume && self.wasRunningBeforeInterruption { | ||
| self.resumeAfterInterruptionIfNeeded() |
There was a problem hiding this comment.
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 finished reviewing your PR. |
|
@cursor review |
There was a problem hiding this comment.
✅ 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.
* 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 is running the review. |
Sequence DiagramThis 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
Generated by CodeAnt AI |
|
CodeAnt AI finished running the review. |
|
CodeAnt AI is running the review. |
Sequence DiagramThis 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
Generated by CodeAnt AI |
|
CodeAnt AI finished running the review. |
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`:
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.
Test plan
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
Accessibility
Refactor
CodeAnt-AI Description
Prevent the app from crashing when audio starts or resumes
What Changed
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:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
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:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
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.