From edf12cff0dddf15a9e37170ac31de7f6f0f3eb63 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Tue, 1 Sep 2026 15:14:26 +0200 Subject: [PATCH 1/3] fix: inputNode race condition --- .../FabricExampleTests/AudioEngineTests.mm | 68 +++++++++++++++++++ .../ios/audioapi/ios/system/AudioEngine.mm | 10 ++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index ec8ee9389..558992c70 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -936,6 +936,40 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning { XCTAssertFalse(self.audioEngine.graphNeedsRebuild); } +- (void) + testConfigurationChangeCallbackCanReadLiveInputFormatWhileRestartHoldsLock { + __block BOOL callbackRan = NO; + __block AVAudioFormat *formatSeenDuringRebuild = nil; + + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:^{ + callbackRan = YES; + // Invoked from inside restartAudioEngine, so this reads the + // engine back on the thread that already holds the engine lock. + formatSeenDuringRebuild = [self.audioEngine getLiveInputFormat]; + }]; + + self.audioEngine.state = AudioEngineStateRunning; + self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; + + // Restart off the test thread so a non-reentrant lock shows up as a timeout + // rather than wedging the whole test run. + XCTestExpectation *restartFinished = + [self expectationWithDescription:@"restartAudioEngine returned"]; + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + [self.audioEngine restartAudioEngine]; + [restartFinished fulfill]; + }); + + [self waitForExpectations:@[ restartFinished ] timeout:5.0]; + + XCTAssertTrue(callbackRan); + XCTAssertNotNil(formatSeenDuringRebuild); +} + - (void)testConcurrentStartIfNecessaryDoesNotCrash { [self attachSourceNodeToAudioEngine]; self.audioEngine.state = AudioEngineStateIdle; @@ -1035,4 +1069,38 @@ - (void)testConcurrentInterruptionAndStartDoesNotCrash { dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); } +// Guards the input-node use-after-free: one thread resolves the live input +// format while another tears the engine down underneath it. The format read has +// to hold the engine lock across the whole check-then-use, otherwise the format +// query is sent to an input node the rebuild has already released. +- (void)testConcurrentLiveInputFormatReadAndRestartDoesNotCrash { + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; + self.audioEngine.state = AudioEngineStateRunning; + self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; + + dispatch_queue_t queue = + dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0); + dispatch_group_t group = dispatch_group_create(); + + for (NSInteger index = 0; index < 50; index += 1) { + dispatch_group_enter(group); + dispatch_async(queue, ^{ + [self.audioEngine getLiveInputFormat]; + dispatch_group_leave(group); + }); + + dispatch_group_enter(group); + dispatch_async(queue, ^{ + [self.audioEngine restartAudioEngine]; + dispatch_group_leave(group); + }); + } + + dispatch_group_wait(group, + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); +} + @end 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 ff5a0a54d..bb44c7a7c 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 @@ -26,7 +26,7 @@ @implementation AudioEngineInputRegistration @end @interface AudioEngine () { - std::mutex _engineLock; + std::recursive_mutex _engineLock; BOOL _isRebuildingAudioEngine; /// Tracks whether voice processing is currently engaged on the system input /// node of the live engine instance. Reset whenever the engine is recreated. @@ -170,11 +170,15 @@ - (void)materializeSourceNodeWithId:(NSString *)sourceNodeId - (AVAudioFormat *)liveInputFormat { - if (self.audioEngine == nil) { + std::scoped_lock lock(_engineLock); + + AVAudioEngine *engine = self.audioEngine; + + if (engine == nil) { return nil; } - AVAudioInputNode *engineInputNode = self.audioEngine.inputNode; + AVAudioInputNode *engineInputNode = engine.inputNode; if (engineInputNode == nil) { return nil; From 04df238d825c74171a58988e90b42bb18937a4ed Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Tue, 1 Sep 2026 16:17:56 +0200 Subject: [PATCH 2/3] fix: cleanup logging --- .../ios/audioapi/ios/system/AudioEngine.mm | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 bb44c7a7c..519edfcad 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 @@ -172,13 +172,11 @@ - (AVAudioFormat *)liveInputFormat { std::scoped_lock lock(_engineLock); - AVAudioEngine *engine = self.audioEngine; - - if (engine == nil) { + if (self.audioEngine == nil) { return nil; } - AVAudioInputNode *engineInputNode = engine.inputNode; + AVAudioInputNode *engineInputNode = self.audioEngine.inputNode; if (engineInputNode == nil) { return nil; From 5b42b91813d4cc6fe46947ef12e1fc8e481e77db Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Fri, 4 Sep 2026 12:41:25 +0200 Subject: [PATCH 3/3] chore: remove unnecessary comments --- .../FabricExampleTests/AudioEngineTests.mm | 83 +++++++++++-------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm index 558992c70..f9e29f45f 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioEngineTests.mm @@ -253,8 +253,7 @@ @interface AudioEngineTests : XCTestCase @implementation AudioEngineTests -+ (BOOL)testInvocationsAreParallelizable -{ ++ (BOOL)testInvocationsAreParallelizable { return NO; } @@ -421,8 +420,10 @@ - (void)testDetachSourceNodeRemovesTrackedNodeAndClearsGraphWhenEmpty { - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { NSString *sourceNodeId = [self attachSourceNodeToAudioEngine]; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachSourceNodeWithId:sourceNodeId]; @@ -434,8 +435,10 @@ - (void)testDetachSourceNodeKeepsGraphNeedsRebuildWhenInputRemains { - (void)testAttachInputNodeStoresAndConnectsInput { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; AVAudioSinkNode *inputNode = self.audioEngine.inputNode; XCTAssertNotNil(inputNode); @@ -454,8 +457,10 @@ - (void)testAttachInputNodeDefersConnectionUntilLiveInputFormatIsAvailable { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeInputNode.outputFormat = nil; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; XCTAssertNil(self.audioEngine.inputNode); XCTAssertEqual(fakeEngine.attachNodeCallCount, 0); @@ -482,8 +487,10 @@ - (void)testDetachInputNodeWithoutInputDoesNothing { } - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -492,8 +499,10 @@ - (void)testDetachInputNodeClearsGraphOnlyWhenNoSourcesRemain { XCTAssertFalse(self.audioEngine.graphNeedsRebuild); [self attachSourceNodeToAudioEngine]; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; self.audioEngine.graphNeedsRebuild = YES; [self.audioEngine detachInputNode]; @@ -506,8 +515,10 @@ - (void)testDetachInputNodePreservesSessionDeactivationInvalidation { FakeAudioEngine *fakeEngine = self.audioEngine.currentFakeAudioEngine; fakeEngine.fakeRunning = YES; self.audioEngine.state = AudioEngineStateRunning; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; [self.audioEngine onSessionDeactivated]; [self.audioEngine detachInputNode]; @@ -729,8 +740,10 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { - (void) testStartIfNecessaryRebuildsAfterSessionDeactivationEvenWhenTeardownClearsGraph { - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; oldEngine.fakeRunning = YES; @@ -747,8 +760,10 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { AVAudioFormat *recoveredInputFormat = [self testInputFormatWithSampleRate:48000 channelCount:1]; self.audioEngine.nextCreatedEngineInputFormat = recoveredInputFormat; - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; AVAudioSinkNode *recoveredInputNode = self.audioEngine.inputNode; XCTAssertTrue([self.audioEngine startIfNecessary]); @@ -769,8 +784,10 @@ - (void)testStartIfNecessaryRebuildsWhenGraphNeedsRebuild { } - (void)testStartIfNecessaryRebuildsInputNodeWithFreshInstance { - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; FakeAudioEngine *oldEngine = self.audioEngine.currentFakeAudioEngine; AVAudioSinkNode *oldInputNode = self.audioEngine.inputNode; AVAudioFormat *replacementInputFormat = @@ -946,16 +963,12 @@ - (void)testRestartAudioEngineStopsAndRestartsWhenStateRunning { voiceProcessingEnabled:NO onInputConfigurationChange:^{ callbackRan = YES; - // Invoked from inside restartAudioEngine, so this reads the - // engine back on the thread that already holds the engine lock. formatSeenDuringRebuild = [self.audioEngine getLiveInputFormat]; }]; self.audioEngine.state = AudioEngineStateRunning; self.audioEngine.currentFakeAudioEngine.fakeRunning = YES; - // Restart off the test thread so a non-reentrant lock shows up as a timeout - // rather than wedging the whole test run. XCTestExpectation *restartFinished = [self expectationWithDescription:@"restartAudioEngine returned"]; @@ -986,7 +999,8 @@ - (void)testConcurrentStartIfNecessaryDoesNotCrash { }); } - dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + dispatch_group_wait(group, + dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); XCTAssertTrue([self.audioEngine startIfNecessary]); } @@ -1012,7 +1026,8 @@ - (void)testConcurrentAttachDetachAndRestartDoesNotCrash { }); } - dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + dispatch_group_wait(group, + dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); } - (void)testConcurrentRecordAndPlayPathsDoNotCrash { @@ -1025,8 +1040,10 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { for (NSInteger index = 0; index < 10; index += 1) { dispatch_group_enter(group); dispatch_async(queue, ^{ - [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] - voiceProcessingEnabled:NO onInputConfigurationChange:nil]; + [self.audioEngine + attachInputNodeWithReceiverBlock:[self testInputReceiverBlock] + voiceProcessingEnabled:NO + onInputConfigurationChange:nil]; [self.audioEngine startIfNecessary]; dispatch_group_leave(group); }); @@ -1039,7 +1056,8 @@ - (void)testConcurrentRecordAndPlayPathsDoNotCrash { }); } - dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + dispatch_group_wait(group, + dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); } - (void)testConcurrentInterruptionAndStartDoesNotCrash { @@ -1066,13 +1084,10 @@ - (void)testConcurrentInterruptionAndStartDoesNotCrash { }); } - dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); + dispatch_group_wait(group, + dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)); } -// Guards the input-node use-after-free: one thread resolves the live input -// format while another tears the engine down underneath it. The format read has -// to hold the engine lock across the whole check-then-use, otherwise the format -// query is sent to an input node the rebuild has already released. - (void)testConcurrentLiveInputFormatReadAndRestartDoesNotCrash { [self.audioEngine attachInputNodeWithReceiverBlock:[self testInputReceiverBlock]