diff --git a/.claude/skills/post-work-checks/SKILL.md b/.claude/skills/post-work-checks/SKILL.md index 951f994fc..76f63bc87 100644 --- a/.claude/skills/post-work-checks/SKILL.md +++ b/.claude/skills/post-work-checks/SKILL.md @@ -31,6 +31,21 @@ yarn validate:full # --fast + --android + --ios (skips unavailable platform Equivalent: `./scripts/validate.sh --fast` (etc.) +### iOS unit tests (not covered by any tier) + +`validate:ios` only builds the pod. The Objective-C++ XCTest suite in `apps/fabric-example/ios/FabricExampleTests/` (engine, session manager, notification manager, player, recorder) is run by neither CI nor `validate.sh`, so changes under `ios/audioapi/` must be exercised by hand: + +```bash +cd apps/fabric-example/ios +xcodebuild test -workspace FabricExample.xcworkspace -scheme FabricExampleTests \ + -destination 'platform=iOS Simulator,name=' \ + -only-testing:FabricExampleTests/AudioEngineTests # omit to run everything +``` + +Check `xcrun simctl list devices available` first — an unavailable `-destination` makes xcodebuild print the device list and fail in a way that is easy to mistake for a passing run when its output is piped. Run `pod install` if the build reports the sandbox is out of sync; it can also rewrite prebuilt-pod checksums in the tracked `Podfile.lock`, which should not be committed with unrelated work. + +Because these tests are never run automatically, they rot. Several test files hand-declare mirror copies of C++ classes (`IOSAudioPlayer` in `AudioPlayerTests.mm`, `IOSAudioRecorder` in `IOSAudioRecorderTests.mm`) to reach protected members; adding a pure virtual to a base class makes those mirrors abstract and breaks compilation of the whole target. Add the matching override to the mirror when changing `CommonPlayer` or `AudioRecorder`. + ### Which tier to run | Changed paths | Minimum validation | diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 86d1c4bd6..461c2a7d3 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -164,6 +164,13 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`. +**iOS refused restarts (`AudioEngine`):** iOS can reject an engine start that a route or configuration change triggered — typically `'!int'` / `560557684` (`AVAudioSessionErrorCodeCannotInterruptOthers`) while the device is locked or another app holds the session. Two invariants: + +- **Never leave `state` at `Running` after a refused start.** `IOSAudioPlayer::isPlaying` and `IOSAudioRecorder` gate on `getState() == Running`, so a state that outlives a dead engine hides the failure from every consumer. `handleRefusedRestart` drops to `Paused`, sets `graphNeedsRebuild` (a refused start can leave the graph without an input node), and marks the restart pending. +- **Restart paths must not call each other.** `startEngine` rebuilds the graph inline rather than delegating to `rebuildAudioEngineAndResumeIfNeeded`, which would call `startEngine` back. That mutual recursion caused #1161/#1167 and was previously only muted by a flag. + +Retries are scheduled with `dispatch_after` on the main queue and re-armed by `SystemNotificationManager` on foreground, route change, and interruption end. Because the retry block re-acquires the non-recursive engine mutex, it must be scheduled (never `dispatch_sync`) from under the lock, and each scheduling bumps a generation counter so an already-queued retry recognises itself as stale instead of racing a newer one. + **Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`). **Graph Channel A producer self-drain:** `Graph::setProducerSelfDrain(true)` makes the JS/main producer drain Channel A after each enqueue. Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend). Before disabling for `start`/`resume`/`renderAudio`, call `processEvents()` once (still as sole consumer) so the bounded channel is empty, then disable, then start the audio/render consumer; re-enable if start/resume fails. After enabling, call `processEvents()` once to flush backlog (avoids `WAIT_ON_FULL` deadlock if the channel was already full). diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index e85646121..a10137326 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -637,7 +637,7 @@ - (void)testOnInterruptionEndWithResumeRestartsEngine { XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); } -- (void)testOnInterruptionEndWithResumeFailureEndsIdle { +- (void)testOnInterruptionEndWithResumeFailureLeavesRestartPending { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateInterrupted; @@ -648,7 +648,9 @@ - (void)testOnInterruptionEndWithResumeFailureEndsIdle { [self.audioEngine onInterruptionEnd:true]; - XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertFalse([self.audioEngine isEngineRunning]); + XCTAssertTrue([self.audioEngine isRestartPending]); } - (void)testStartIfNecessaryReturnsFalseWhenGraphEmpty { @@ -936,6 +938,174 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning { XCTAssertFalse(self.audioEngine.graphNeedsRebuild); } +- (NSError *)refusedStartError { + return [NSError errorWithDomain:@"AudioEngineTests" + code:560557684 + userInfo:nil]; +} + +// Drives the engine into the state left behind by a configuration change whose restart +// the system refused, which is what a locked device or a session held by another +// application produces in practice. +- (void)makeRestartPendingWithRefusedStart { + [self attachSourceNodeToAudioEngine]; + self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; + self.audioEngine.state = AudioEngineStateRunning; + self.audioEngine.nextCreatedEngineStartError = [self refusedStartError]; + + [self.audioEngine restartAudioEngine]; +} + +- (void)testRestartAudioEngineReportsStoppedEngineWhenStartIsRefused { + [self makeRestartPendingWithRefusedStart]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertFalse([self.audioEngine isEngineRunning]); + XCTAssertTrue([self.audioEngine isRestartPending]); + XCTAssertTrue(self.audioEngine.graphNeedsRebuild); +} + +- (void)testRetryPendingRestartIfNeededRestartsEngineOnceStartSucceeds { + [self makeRestartPendingWithRefusedStart]; + + [self.audioEngine retryPendingRestartIfNeeded]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); + XCTAssertTrue([self.audioEngine isEngineRunning]); + XCTAssertFalse([self.audioEngine isRestartPending]); + XCTAssertFalse(self.audioEngine.graphNeedsRebuild); +} + +- (void)testRetryPendingRestartIfNeededKeepsPendingWhileStartStaysRefused { + [self makeRestartPendingWithRefusedStart]; + self.audioEngine.nextCreatedEngineStartError = [self refusedStartError]; + + [self.audioEngine retryPendingRestartIfNeeded]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertFalse([self.audioEngine isEngineRunning]); + XCTAssertTrue([self.audioEngine isRestartPending]); +} + +- (void)testRetryPendingRestartIfNeededDoesNothingWithoutPendingRestart { + [self attachSourceNodeToAudioEngine]; + FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; + + [self.audioEngine retryPendingRestartIfNeeded]; + + XCTAssertEqual(fakeEngine.startCallCount, 0); + XCTAssertEqual(self.sessionManager.ensureActiveCallCount, 0); + XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); +} + +- (void)testRetryPendingRestartIfNeededStandsDownWhenGraphWasTornDown { + [self makeRestartPendingWithRefusedStart]; + + NSString *sourceNodeId = self.audioEngine.sourceNodes.allKeys.firstObject; + [self.audioEngine detachSourceNodeWithId:sourceNodeId]; + FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; + NSInteger startCallCountBeforeRetry = fakeEngine.startCallCount; + + [self.audioEngine retryPendingRestartIfNeeded]; + + XCTAssertFalse([self.audioEngine isRestartPending]); + XCTAssertEqual(fakeEngine.startCallCount, startCallCountBeforeRetry); +} + +- (void)testStopIfNecessaryClearsPendingRestart { + [self makeRestartPendingWithRefusedStart]; + + [self.audioEngine stopIfNecessary]; + + XCTAssertFalse([self.audioEngine isRestartPending]); + XCTAssertEqual(self.audioEngine.state, AudioEngineStateIdle); +} + +- (void)testPauseIfNecessaryClearsPendingRestart { + [self makeRestartPendingWithRefusedStart]; + + [self.audioEngine pauseIfNecessary]; + + XCTAssertFalse([self.audioEngine isRestartPending]); + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); +} + +- (void)testRestartAudioEngineResumesEngineWhenRestartWasPending { + [self makeRestartPendingWithRefusedStart]; + + [self.audioEngine restartAudioEngine]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); + XCTAssertTrue([self.audioEngine isEngineRunning]); + XCTAssertFalse([self.audioEngine isRestartPending]); +} + +// A rebuild required while the state already says running used to be reached twice, +// because starting the engine delegated back to the rebuild-and-resume path which +// started the engine again. Counting the calls pins the single pass down. +- (void)testStartIfNecessaryRebuildsAndStartsExactlyOnceWhenStateIsRunning { + [self attachSourceNodeToAudioEngine]; + FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; + self.audioEngine.state = AudioEngineStateRunning; + self.audioEngine.graphNeedsRebuild = YES; + + XCTAssertTrue([self.audioEngine startIfNecessary]); + + FakeAudioEngine *newEngine = self.audioEngine.currentFakeAudioEngine; + XCTAssertNotEqual(newEngine, oldEngine); + XCTAssertEqual(self.audioEngine.createdFakeEngines.count, 2UL); + XCTAssertEqual(self.sessionManager.ensureActiveCallCount, 1); + XCTAssertEqual(newEngine.prepareCallCount, 1); + XCTAssertEqual(newEngine.startCallCount, 1); + XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); +} + +// A configuration change arriving while the engine is interrupted cannot start it, and the +// system does not always follow the interruption with an end notification. The engine has to +// remember that it is still meant to run, or nothing restarts it. +- (void)testRestartWhileInterruptedArmsPendingRestart { + [self attachSourceNodeToAudioEngine]; + self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; + self.audioEngine.state = AudioEngineStateInterrupted; + + [self.audioEngine restartAudioEngine]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStateInterrupted); + XCTAssertFalse([self.audioEngine isEngineRunning]); + XCTAssertTrue([self.audioEngine isRestartPending]); +} + +- (void)testRetryPendingRestartIfNeededResumesEngineInterruptedDuringRestart { + [self attachSourceNodeToAudioEngine]; + self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; + self.audioEngine.state = AudioEngineStateInterrupted; + [self.audioEngine restartAudioEngine]; + + [self.audioEngine retryPendingRestartIfNeeded]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStateRunning); + XCTAssertTrue([self.audioEngine isEngineRunning]); + XCTAssertFalse([self.audioEngine isRestartPending]); +} + +- (void)testRestartWhilePausedDoesNotArmPendingRestart { + [self attachSourceNodeToAudioEngine]; + self.audioEngine.state = AudioEngineStatePaused; + + [self.audioEngine restartAudioEngine]; + + XCTAssertEqual(self.audioEngine.state, AudioEngineStatePaused); + XCTAssertFalse([self.audioEngine isRestartPending]); +} + +- (void)testRestartWhileInterruptedWithoutGraphDoesNotArmPendingRestart { + self.audioEngine.state = AudioEngineStateInterrupted; + + [self.audioEngine restartAudioEngine]; + + XCTAssertFalse([self.audioEngine isRestartPending]); +} + - (void)testConcurrentStartIfNecessaryDoesNotCrash { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateIdle; diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index 9a9b57789..9cc6086ac 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -36,6 +36,9 @@ [[nodiscard]] bool isRunning() const override; + [[nodiscard]] double getBaseLatency() const override; + [[nodiscard]] double getOutputLatency() const override; + protected: std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; diff --git a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm index 1f0fc6dde..c19013b2c 100644 --- a/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/IOSAudioRecorderTests.mm @@ -53,6 +53,8 @@ uint64_t callbackId) override; void clearOnAudioReadyCallback() override; + [[nodiscard]] double getInputLatency() const override; + protected: NativeAudioRecorder *nativeRecorder_; }; diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 0b311863e..55c377cf4 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2523,7 +2523,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e + hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5 RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2532,7 +2532,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921 + React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2596,7 +2596,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c + ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03 RNAudioAPI: 50957b72cc742b9aa1e05349be71b9db73c9cf74 RNAudioWorklets: ff0c53fd3c3bbffacb7dd3beb03ffe0ea9f1fd05 RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h index e4f9fdd39..81a84e054 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.h @@ -45,6 +45,23 @@ typedef NS_ENUM(NSInteger, AudioEngineState) { - (AudioEngineState)getState; - (bool)isEngineRunning; +/// @brief Whether a restart refused by the system is still waiting to be retried. +- (bool)isRestartPending; + +/// @brief Immediately re-attempts a restart that the system previously refused. +/// +/// The operating system can reject an engine restart triggered by a route or +/// configuration change, most notably while the device is locked with an input node +/// attached, or while another application holds the audio session. Such a refusal +/// leaves the engine stopped with a restart pending, retried on a bounded backoff. +/// Callers use this method to retry as soon as conditions are known to have improved +/// (the application returned to the foreground, the route changed again) instead of +/// waiting for the next scheduled attempt, and to grant a fresh retry budget once the +/// scheduled ones are exhausted. +/// +/// Does nothing when no restart is pending. +- (void)retryPendingRestartIfNeeded; + - (bool)startIfNecessary; - (void)pauseIfNecessary; - (void)stopIfNecessary; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm index d91ed60f9..e09f43cf0 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioEngine.mm @@ -25,13 +25,25 @@ @implementation AudioEngineInputRegistration @interface AudioEngine () { std::mutex _engineLock; - BOOL _isRebuildingAudioEngine; + + /// Set when the engine should be running but the system refused to start it. + BOOL _restartPending; + + /// Number of backoff retries already spent on the current pending restart. + NSUInteger _restartRetryCount; + + /// Incremented whenever scheduled retries become obsolete, so that a retry already + /// queued on the main queue can recognise itself as stale and do nothing. + NSUInteger _restartRetryGeneration; } @property (nonatomic, strong) NSMutableDictionary *sourceRegistrations; @property (nonatomic, strong) AudioEngineInputRegistration *inputRegistration; +// Every method below assumes the caller already holds `_engineLock`; the public methods +// declared in the header acquire it. `_engineLock` is not recursive, so these must never +// be reached through a public method. - (void)createAudioEngineIfNeeded; - (void)destroyAudioEnginePreservingSessionDeactivationState:(BOOL)preserveSessionDeactivationState; - (BOOL)hasTrackedGraph; @@ -43,9 +55,27 @@ - (void)materializeTrackedNodesIfNeeded; - (AVAudioFormat *)liveInputFormat; - (void)resetInputNode; - (void)rebuildAudioEngineAndResumeIfNeeded; +- (BOOL)graphRequiresRebuild; +- (void)handleRefusedRestart; +- (void)markRestartPending; +- (void)clearPendingRestart; +- (void)scheduleRestartRetry; + +/// Runs a scheduled retry. Unlike the helpers above this one acquires `_engineLock` +/// itself, because it is invoked from the main queue by `scheduleRestartRetry`. +- (void)retryPendingRestartForGeneration:(NSUInteger)generation; @end +/// Backoff for restarts the system refused, doubling from the initial delay up to the +/// maximum one. The schedule is bounded rather than endless because the conditions that +/// cause a refusal (a locked device, another application holding the session) routinely +/// outlast any reasonable polling window; once it is exhausted the engine stays pending +/// and waits for `retryPendingRestartIfNeeded` to signal that conditions have changed. +static const NSTimeInterval kInitialRestartRetryDelay = 2.0; +static const NSTimeInterval kMaximumRestartRetryDelay = 32.0; +static const NSUInteger kMaximumRestartRetryCount = 6; + @implementation AudioEngine static AudioEngine *_sharedInstance = nil; @@ -122,6 +152,7 @@ - (instancetype)init - (void)cleanup { std::scoped_lock lock(_engineLock); + [self clearPendingRestart]; [self destroyAudioEngine]; self.state = AudioEngineState::AudioEngineStateIdle; self.sourceRegistrations = nil; @@ -366,7 +397,6 @@ - (void)markSessionDeactivationInvalidatedGraph - (void)onInterruptionEnd:(bool)shouldResume { std::scoped_lock lock(_engineLock); - NSError *error = nil; if (self.state != AudioEngineState::AudioEngineStateInterrupted) { return; @@ -374,25 +404,18 @@ - (void)onInterruptionEnd:(bool)shouldResume [self stopEngine]; [self rebuildAudioEngine]; + // The graph is fresh, so the deactivation that invalidated the previous one is settled. + // Leaving the flag raised would make the start below rebuild a second time. + self.sessionDeactivationInvalidatedGraph = false; if (!shouldResume) { self.state = AudioEngineState::AudioEngineStatePaused; return; } - [self.audioEngine prepare]; - [self.audioEngine startAndReturnError:&error]; - - if (error != nil) { - NSLog( - @"Error while restarting the audio engine after interruption: %@", - [error debugDescription]); - self.state = AudioEngineState::AudioEngineStateIdle; - return; + if (![self startEngine]) { + [self handleRefusedRestart]; } - - self.state = AudioEngineState::AudioEngineStateRunning; - self.sessionDeactivationInvalidatedGraph = false; } - (AudioEngineState)getState @@ -409,11 +432,9 @@ - (bool)isEngineRunning - (void)rebuildAudioEngineAndResumeIfNeeded { - if (_isRebuildingAudioEngine) { - return; - } - - _isRebuildingAudioEngine = YES; + // A restart already pending means the engine is meant to be running even though its + // state currently says otherwise, so this rebuild must resume it as well. + BOOL shouldResume = self.state == AudioEngineState::AudioEngineStateRunning || _restartPending; if ([self.audioEngine isRunning]) { [self.audioEngine stop]; @@ -422,11 +443,22 @@ - (void)rebuildAudioEngineAndResumeIfNeeded [self rebuildAudioEngine]; self.sessionDeactivationInvalidatedGraph = false; - if (self.state == AudioEngineState::AudioEngineStateRunning) { - [self startEngine]; + if (!shouldResume) { + // An interruption suspends an engine that is still meant to be running, and the system + // does not always follow one with an end notification. Arming the retry ladder here + // means the next opportunity resumes the engine, rather than leaving it stopped until + // something on the JavaScript side happens to start it again. Only an interruption + // qualifies: an idle or paused engine was stopped deliberately. + if (self.state == AudioEngineState::AudioEngineStateInterrupted) { + [self markRestartPending]; + } + + return; } - _isRebuildingAudioEngine = NO; + if (![self startEngine]) { + [self handleRefusedRestart]; + } } - (void)rebuildAudioEngine @@ -454,9 +486,13 @@ - (bool)startEngine return false; } - if (self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || - self.sessionDeactivationInvalidatedGraph) { - [self rebuildAudioEngineAndResumeIfNeeded]; + if ([self graphRequiresRebuild]) { + if ([self.audioEngine isRunning]) { + [self.audioEngine stop]; + } + + [self rebuildAudioEngine]; + self.sessionDeactivationInvalidatedGraph = false; } else { [self materializeTrackedNodesIfNeeded]; } @@ -476,11 +512,145 @@ - (bool)startEngine self.state = AudioEngineState::AudioEngineStateRunning; self.sessionDeactivationInvalidatedGraph = false; + + if (_restartPending) { + NSLog( + @"[AudioEngine] Audio engine restart succeeded after %lu scheduled retry(-ies).", + (unsigned long)_restartRetryCount); + } + + [self clearPendingRestart]; return true; } +- (BOOL)graphRequiresRebuild +{ + return self.state == AudioEngineState::AudioEngineStateInterrupted || self.graphNeedsRebuild || + self.sessionDeactivationInvalidatedGraph; +} + +/// Records that the system refused to start the engine and queues another attempt. +/// +/// The engine is never left reporting the running state: `getState` and `isEngineRunning` +/// feed player and recorder status, so a running engine the system never started would hide +/// the failure from every consumer. It is left paused when something is still tracked and +/// meant to be running, or idle when nothing is. +- (void)handleRefusedRestart +{ + self.state = [self hasTrackedGraph] ? AudioEngineState::AudioEngineStatePaused + : AudioEngineState::AudioEngineStateIdle; + [self markRestartPending]; +} + +/// Records that the engine should be running even though it is not, and queues an attempt. +/// +/// The graph is marked for rebuild because a graph assembled while the route was unsettled +/// can be missing its input node. An engine with nothing attached has nothing to restart, +/// so the pending state is dropped rather than retried forever. +- (void)markRestartPending +{ + if (![self hasTrackedGraph]) { + [self clearPendingRestart]; + return; + } + + _restartPending = YES; + self.graphNeedsRebuild = YES; + + [self scheduleRestartRetry]; +} + +- (void)clearPendingRestart +{ + _restartPending = NO; + _restartRetryCount = 0; + _restartRetryGeneration += 1; +} + +- (void)scheduleRestartRetry +{ + if (_restartRetryCount >= kMaximumRestartRetryCount) { + NSLog( + @"[AudioEngine] Audio engine restart is still refused after %lu attempts. Waiting for the " + @"application to return to the foreground or for the audio route to change.", + (unsigned long)_restartRetryCount); + return; + } + + NSTimeInterval delay = + MIN(kInitialRestartRetryDelay * static_cast(1u << _restartRetryCount), + kMaximumRestartRetryDelay); + _restartRetryCount += 1; + + NSLog( + @"[AudioEngine] Audio engine restart is pending, retrying in %.0f s (attempt %lu of %lu).", + delay, + (unsigned long)_restartRetryCount, + (unsigned long)kMaximumRestartRetryCount); + + // Bumping the generation invalidates any retry queued earlier, so that an immediate + // attempt through `retryPendingRestartIfNeeded` cannot leave two retries racing. + NSUInteger generation = ++_restartRetryGeneration; + __weak AudioEngine *weakSelf = self; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, static_cast(delay * NSEC_PER_SEC)), + dispatch_get_main_queue(), + ^{ [weakSelf retryPendingRestartForGeneration:generation]; }); +} + +- (void)retryPendingRestartForGeneration:(NSUInteger)generation +{ + std::scoped_lock lock(_engineLock); + + if (!_restartPending || generation != _restartRetryGeneration) { + return; + } + + if (![self hasTrackedGraph]) { + [self clearPendingRestart]; + return; + } + + if (![self startEngine]) { + [self scheduleRestartRetry]; + } +} + +- (void)retryPendingRestartIfNeeded +{ + std::scoped_lock lock(_engineLock); + + if (!_restartPending) { + return; + } + + if (![self hasTrackedGraph]) { + [self clearPendingRestart]; + return; + } + + // An external trigger is evidence that conditions changed, so the backoff starts over + // and a pending restart that already exhausted its schedule becomes retryable again. + _restartRetryCount = 0; + + if (![self startEngine]) { + [self handleRefusedRestart]; + } +} + +- (bool)isRestartPending +{ + std::scoped_lock lock(_engineLock); + return _restartPending; +} + - (void)stopEngine { + // Stopping expresses that the engine is no longer meant to run, which retires any + // restart the system had refused. + [self clearPendingRestart]; + if (self.state == AudioEngineState::AudioEngineStateIdle) { return; } @@ -510,6 +680,8 @@ - (bool)startIfNecessary - (void)pauseIfNecessary { std::scoped_lock lock(_engineLock); + [self clearPendingRestart]; + if (self.state == AudioEngineState::AudioEngineStatePaused) { return; } diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm index 6761a4369..3812638ce 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/SystemNotificationManager.mm @@ -1,3 +1,5 @@ +#import + #import #import #import @@ -88,6 +90,19 @@ - (void)configureNotifications selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:nil]; + // Returning to the foreground is the most reliable moment at which the system stops + // refusing to start the engine, since the refusals happen while the device is locked. + [self.notificationCenter addObserver:self + selector:@selector(handleApplicationDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; +} + +- (void)handleApplicationDidBecomeActive:(NSNotification *)notification +{ + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine retryPendingRestartIfNeeded]; }); } - (void)observeValueForKeyPath:(NSString *)keyPath @@ -143,6 +158,13 @@ - (void)handleInterruption:(NSNotification *)notification } else { dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine onInterruptionEnd:shouldResume]; }); } + + // Whoever owns interruption handling, an ended interruption is an opportunity to finish a + // restart that was refused or deferred earlier. `onInterruptionEnd` above only recovers an + // engine still marked interrupted, and a configuration change arriving during the + // interruption can have moved it out of that state; this covers what it leaves behind. + // Queued after it, so a successful resume clears the pending restart and this does nothing. + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine retryPendingRestartIfNeeded]; }); } - (void)handleSecondaryAudio:(NSNotification *)notification @@ -218,6 +240,11 @@ - (void)handleRouteChange:(NSNotification *)notification invokeHandlerWithEventName:audioapi::AudioEvent::ROUTE_CHANGE payload:audioapi::StringPayload{ .name = "reason", .reason = [reasonStr UTF8String]}]; + + // A new route can lift whatever made the system refuse an earlier restart, and it + // arrives without an engine configuration change whenever the engine is not running. + AudioEngine *audioEngine = self.audioAPIModule.audioEngine; + dispatch_async(dispatch_get_main_queue(), ^{ [audioEngine retryPendingRestartIfNeeded]; }); } - (void)handleMediaServicesReset:(NSNotification *)notification