Skip to content

fix(ios): dispatch AVAudioEngine access to main thread#27

Open
txbrown wants to merge 7 commits into
mainfrom
feature/engine-lifecycle
Open

fix(ios): dispatch AVAudioEngine access to main thread#27
txbrown wants to merge 7 commits into
mainfrom
feature/engine-lifecycle

Conversation

@txbrown

@txbrown txbrown commented May 27, 2026

Copy link
Copy Markdown
Collaborator

What

Prevents AURemoteIO RPC timeouts when AVAudioEngine is accessed off the main thread in iOS.

Changes

  • Main-thread dispatch: getAudioInfo, getSampleRate, loadAudioResource, and unloadAudioResource now dispatch engine access to the main queue
  • applyInstructions / setProperty: Dual-path — synchronous fast path when engine is warm, dispatches to main on first call (cold engine)
  • pruneSharedResources mutex: Added _runtimeMutex guard matching addSharedResources — fixes data race with audio render callback
  • unloadAudioResource: No longer requires engine to be initialized. Resolves false instead of rejecting when engine was never started
  • Helper extraction: Repeated dispatch + init-guard pattern extracted into dispatchMainWithEngineInit:reject:
  • Documentation: File-level threading policy added

Manual testing

  • Load and play samples in the example app on a physical iOS device
  • Plug/unplug headphones while audio is playing — engine should restart without crashing
  • Call unloadAudioResource before any audio playback — should resolve false, not reject
  • Rapid loadAudioResource calls during cold start — no RPC timeout in logs
  • Test on iOS simulator with audio route changes (AirPlay, Bluetooth)

Copilot AI 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.

Pull request overview

This PR introduces an explicit audio engine lifecycle API (start/stop/pause/resume) across the JS surface and native iOS/Android implementations, aiming to let callers release or pause audio resources more deliberately while preserving backward compatibility.

Changes:

  • Added JS/TS TurboModule spec + JS wrappers for startEngine, stopEngine, pauseEngine, resumeEngine.
  • Implemented engine lifecycle methods in iOS (Elementary.mm/.h) and Android (ElementaryModule.kt) and wired new-arch Android routing (ElementaryTurboModule.java).
  • Bumped package version to 0.4.1-beta.2 and updated the changelog.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/NativeElementary.ts Extends TurboModule spec with engine lifecycle methods.
src/index.tsx Exposes JS wrapper functions for engine lifecycle methods.
ios/Elementary.h Introduces an engine state enum/property for lifecycle tracking.
ios/Elementary.mm Adds lifecycle methods and updates engine state on config change.
android/src/main/java/com/elementary/ElementaryModule.kt Adds lifecycle methods and changes host resume/pause behavior.
android/src/newarch/com/elementary/ElementaryTurboModule.java Adds new-arch forwarding stubs for lifecycle methods.
CHANGELOG.md Adds 0.4.1-beta.2 entry (currently missing lifecycle mention).
package.json Version bump to 0.4.1-beta.2.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ios/Elementary.mm Outdated
Comment thread ios/Elementary.mm Outdated
Comment thread ios/Elementary.h Outdated
Comment thread android/src/main/java/com/elementary/ElementaryModule.kt Outdated
Comment thread android/src/main/java/com/elementary/ElementaryModule.kt Outdated
Comment thread android/src/main/java/com/elementary/ElementaryModule.kt Outdated
Comment thread android/src/newarch/com/elementary/ElementaryTurboModule.java Outdated
Comment thread CHANGELOG.md
Comment thread src/NativeElementary.ts Outdated
@txbrown txbrown force-pushed the feature/engine-lifecycle branch 2 times, most recently from 47e0736 to 5f92c9a Compare May 29, 2026 06:45
txbrown added a commit that referenced this pull request Jun 2, 2026
- iOS: set engineState on audio interruption begin (Interrupted) and end
  (Running/Idle) so the state machine accurately reflects engine status
- Android: replace phantom acquireAudioEngine() references with startEngine()
- Android: remove reference to non-existent ElementaryAudioEngine in
  onHostPause comment
- Android TurboModule: correct misleading routing comment
- NativeElementary.ts: clarify that auto-start is iOS-only; Android requires
  explicit startEngine()
- CHANGELOG: add engine lifecycle API entry to 0.4.1-beta.2
AVAudioEngine.outputNode must be accessed on the main thread to avoid
an RPC timeout in AURemoteIO::Cleanup when called from a background
queue. Wrap getAudioInfo, getSampleRate, loadAudioResource, and
unloadAudioResource in dispatch_async(dispatch_get_main_queue()).

applyInstructions and setProperty are left as-is since they are
performance-critical and only hit the AV engine on first call
(subsequent calls take the audioEngineInitialized fast path).

Patch insight from midicircuit-rn.
@txbrown txbrown force-pushed the feature/engine-lifecycle branch from 64867ff to 56feb87 Compare June 5, 2026 10:41
@txbrown txbrown changed the title More engine lifecycle improvements fix(ios): dispatch AVAudioEngine access to main thread Jun 5, 2026
@txbrown txbrown requested a review from Copilot June 5, 2026 10:45

Copilot AI 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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 4 comments.

Comment thread ios/Elementary.mm Outdated
Comment thread ios/Elementary.mm Outdated
Comment thread ios/Elementary.mm Outdated
Comment thread ios/Elementary.mm Outdated
txbrown added 6 commits June 5, 2026 19:58
pruneSharedResources() in unloadAudioResource was called without the
_runtimeMutex, while addSharedResource() in loadAudioResource acquires
it. This is a data race — pruneSharedResources may mutate internal state
that the audio render callback reads concurrently under the same mutex.
If the engine was never initialized, there are no resources to unload.
Resolve NO immediately without touching AVAudioEngine, skipping the
main-thread dispatch entirely.

Previously this would reject with E_RUNTIME_NOT_INITIALIZED, which is
semantically wrong — the caller asked to remove a resource that doesn't
exist, not to start the engine.
Both methods now have a dual-path pattern:
- Fast path: engine already initialized → process synchronously (zero overhead)
- Cold path: engine not yet initialized → dispatch_async(main) for safe
  AVAudioEngine access, avoiding AURemoteIO::Cleanup RPC timeout

Added NSAssert in initializeAudioEngineIfNeeded to catch any future
caller that reaches the cold path from a background thread.
…ing docs

- Extract repeated dispatch_async(main) + engine-init-guard pattern into
  dispatchMainWithEngineInit:reject: helper method
- Add file-level dispatch policy documentation covering:
  - Main-thread requirement for AVAudioEngine
  - dispatch_async vs dispatch_sync vs dispatch_after rationale
  - Self-capture policy (strong for one-shot blocks, weak/strongSelf for
    long-lived blocks)
- Add NSAssert in initializeAudioEngineIfNeeded to catch off-main-thread
  callers during development builds
- Refactor getAudioInfo, getSampleRate, loadAudioResource to use helper
17 tests covering all exported functions: getSampleRate,
loadAudioResource, unloadAudioResource, setProperty, event polling,
audio session controls, and useRenderer. Key regression: unload
resolves false without engine init (never rejects).

TDD foundation for the engine-lifecycle hardening work.
@txbrown txbrown marked this pull request as ready for review June 6, 2026 05:32
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.

2 participants