diff --git a/ios/Elementary.mm b/ios/Elementary.mm index 78e4b5e..b41726f 100644 --- a/ios/Elementary.mm +++ b/ios/Elementary.mm @@ -7,6 +7,24 @@ @implementation Elementary +// Main-thread dispatch policy: +// +// AVAudioEngine.outputNode must be accessed on the main thread to avoid +// an RPC timeout in AURemoteIO::Cleanup when called from a background queue. +// +// Dispatch style per context: +// dispatch_async(main) — Promise-based RCT_EXPORT_METHOD methods that +// resolve/reject asynchronously. +// dispatch_sync(main) — setAudioSessionActive needs a synchronous result +// for callers that branch on the return value. +// dispatch_after(main) — handleEngineConfigChange defers to avoid +// re-entrant deadlock during route changes. +// +// Self-capture policy: +// One-shot dispatch_async blocks may capture self strongly (no retain cycle). +// Long-lived or deferred blocks (timers, dispatch_after) must use the +// __weak/strongSelf pattern to avoid extending module lifetime. + RCT_EXPORT_MODULE(); + (instancetype)sharedInstance { @@ -30,11 +48,37 @@ - (instancetype)init return self; } +/** + * Dispatch @c block to the main queue after verifying the audio engine + * is initialized. If initialization fails, @c reject is called with + * @c E_AUDIO_ENGINE and the block is not executed. + * + * All RCT_EXPORT_METHOD entry points that access AVAudioEngine must use + * this (or the fast-path check in applyInstructions / setProperty) to + * avoid AURemoteIO::Cleanup RPC timeouts. + */ +- (void)dispatchMainWithEngineInit:(void (^)(void))block + reject:(RCTPromiseRejectBlock)reject { + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded]) { + reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); + return; + } + block(); + }); +} + - (BOOL)initializeAudioEngineIfNeeded { if (self.audioEngineInitialized) { return YES; } + // AVAudioEngine must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + // All cold-engine callers dispatch to main before calling this method. + NSAssert([NSThread isMainThread], + @"initializeAudioEngineIfNeeded must be called from the main thread"); + // Configure and activate the audio session before creating AVAudioEngine. NSError *sessionError = nil; [self setAudioSessionActive:YES error:&sessionError]; @@ -456,18 +500,15 @@ - (void)disableAudioSessionManagement RCT_EXPORT_METHOD(getAudioInfo:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } - - AVAudioFormat *format = [self.audioEngine.outputNode outputFormatForBus:0]; - resolve(@{ - @"channels": @(format.channelCount), - @"sampleRate": @(format.sampleRate), - @"engineRunning": @(self.audioEngine.isRunning), - @"runtimeReady": @(self.runtime != nullptr), - }); + [self dispatchMainWithEngineInit:^{ + AVAudioFormat *format = [self.audioEngine.outputNode outputFormatForBus:0]; + resolve(@{ + @"channels": @(format.channelCount), + @"sampleRate": @(format.sampleRate), + @"engineRunning": @(self.audioEngine.isRunning), + @"runtimeReady": @(self.runtime != nullptr), + }); + } reject:reject]; } #pragma mark - React Native Methods @@ -478,13 +519,27 @@ - (void)applyInstructions:(NSString *)message RCT_EXPORT_METHOD(applyInstructions:(NSString *)message) #endif { - if (![self initializeAudioEngineIfNeeded]) return; - - auto parsed = elem::js::parseJSON([message UTF8String]); - if (parsed.isArray()) { - std::lock_guard lock(_runtimeMutex); - self.runtime->applyInstructions(parsed.getArray()); + // Fast path: engine already initialized — process synchronously. + if (self.audioEngineInitialized) { + auto parsed = elem::js::parseJSON([message UTF8String]); + if (parsed.isArray()) { + std::lock_guard lock(_runtimeMutex); + self.runtime->applyInstructions(parsed.getArray()); + } + return; } + + // Cold path: engine not yet initialized. Dispatch to main thread to + // avoid AURemoteIO::Cleanup RPC timeout when accessing AVAudioEngine. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded]) return; + + auto parsed = elem::js::parseJSON([message UTF8String]); + if (parsed.isArray()) { + std::lock_guard lock(self->_runtimeMutex); + self.runtime->applyInstructions(parsed.getArray()); + } + }); } #ifdef RCT_NEW_ARCH_ENABLED @@ -493,24 +548,46 @@ - (void)setProperty:(double)nodeHash key:(NSString *)key value:(double)value RCT_EXPORT_METHOD(setProperty:(double)nodeHash key:(NSString *)key value:(double)value) #endif { - if (![self initializeAudioEngineIfNeeded] || self.runtime == nullptr) return; - - // Native integrations apply renderer instruction batches via Runtime::applyInstructions: - // https://www.elementary.audio/docs/guides/Native_Integrations#applyinstructions - // The SET_PROPERTY opcode (3) comes from Elementary's Runtime.h. - elem::js::Array instruction; - instruction.push_back((double)3); - instruction.push_back(nodeHash); - instruction.push_back(std::string([key UTF8String])); - instruction.push_back(value); - - elem::js::Array batch; - batch.push_back(instruction); - - { - std::lock_guard lock(_runtimeMutex); - self.runtime->applyInstructions(batch); + // Fast path: engine already initialized — update synchronously. + if (self.audioEngineInitialized && self.runtime != nullptr) { + elem::js::Array instruction; + instruction.push_back((double)3); + instruction.push_back(nodeHash); + instruction.push_back(std::string([key UTF8String])); + instruction.push_back(value); + + elem::js::Array batch; + batch.push_back(instruction); + + { + std::lock_guard lock(_runtimeMutex); + self.runtime->applyInstructions(batch); + } + return; } + + // Cold path: engine not yet initialized. Dispatch to main thread to + // avoid AURemoteIO::Cleanup RPC timeout when accessing AVAudioEngine. + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self initializeAudioEngineIfNeeded] || self.runtime == nullptr) return; + + // Native integrations apply renderer instruction batches via Runtime::applyInstructions: + // https://www.elementary.audio/docs/guides/Native_Integrations#applyinstructions + // The SET_PROPERTY opcode (3) comes from Elementary's Runtime.h. + elem::js::Array instruction; + instruction.push_back((double)3); + instruction.push_back(nodeHash); + instruction.push_back(std::string([key UTF8String])); + instruction.push_back(value); + + elem::js::Array batch; + batch.push_back(instruction); + + { + std::lock_guard lock(self->_runtimeMutex); + self.runtime->applyInstructions(batch); + } + }); } #ifdef RCT_NEW_ARCH_ENABLED @@ -521,13 +598,10 @@ - (void)getSampleRate:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } - - NSNumber *sampleRate = @([self.audioEngine.outputNode outputFormatForBus:0].sampleRate); - resolve(sampleRate); + [self dispatchMainWithEngineInit:^{ + NSNumber *sampleRate = @([self.audioEngine.outputNode outputFormatForBus:0].sampleRate); + resolve(sampleRate); + } reject:reject]; } #ifdef RCT_NEW_ARCH_ENABLED @@ -542,64 +616,61 @@ - (void)loadAudioResource:(NSString *)key rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded]) { - reject(@"E_AUDIO_ENGINE", @"Failed to initialize audio engine", nil); - return; - } - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - if (self.runtime == nullptr) { - reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); - return; - } + [self dispatchMainWithEngineInit:^{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + if (self.runtime == nullptr) { + reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); + return; + } - std::string keyStr = [key UTF8String]; - std::string filePathStr = [filePath UTF8String]; + std::string keyStr = [key UTF8String]; + std::string filePathStr = [filePath UTF8String]; - elementary::AudioLoadResult result = elementary::AudioResourceLoader::loadFile(keyStr, filePathStr); + elementary::AudioLoadResult result = elementary::AudioResourceLoader::loadFile(keyStr, filePathStr); - if (!result.success) { - reject(@"E_LOAD_FAILED", [NSString stringWithUTF8String:result.error.c_str()], nil); - return; - } + if (!result.success) { + reject(@"E_LOAD_FAILED", [NSString stringWithUTF8String:result.error.c_str()], nil); + return; + } - size_t numChannels = result.info.channels; - size_t numSamples = result.info.sampleCount; - std::vector channelPtrs(numChannels); - for (size_t ch = 0; ch < numChannels; ++ch) { - channelPtrs[ch] = result.data.data() + (ch * numSamples); - } + size_t numChannels = result.info.channels; + size_t numSamples = result.info.sampleCount; + std::vector channelPtrs(numChannels); + for (size_t ch = 0; ch < numChannels; ++ch) { + channelPtrs[ch] = result.data.data() + (ch * numSamples); + } - auto resource = std::make_unique( - channelPtrs.data(), - numChannels, - numSamples - ); - bool added; - { - std::lock_guard lock(self->_runtimeMutex); - added = self.runtime->addSharedResource(keyStr, std::move(resource)); - } + auto resource = std::make_unique( + channelPtrs.data(), + numChannels, + numSamples + ); + bool added; + { + std::lock_guard lock(self->_runtimeMutex); + added = self.runtime->addSharedResource(keyStr, std::move(resource)); + } - if (!added) { - reject(@"E_KEY_EXISTS", [NSString stringWithFormat:@"Resource with key '%@' already exists", key], nil); - return; - } + if (!added) { + reject(@"E_KEY_EXISTS", [NSString stringWithFormat:@"Resource with key '%@' already exists", key], nil); + return; + } - @synchronized(self.loadedResources) { - [self.loadedResources addObject:key]; - } + @synchronized(self.loadedResources) { + [self.loadedResources addObject:key]; + } - NSDictionary *info = @{ - @"key": key, - @"channels": @(result.info.channels), - @"sampleCount": @(result.info.sampleCount), - @"sampleRate": @(result.info.sampleRate), - @"durationMs": @(result.info.durationMs) - }; + NSDictionary *info = @{ + @"key": key, + @"channels": @(result.info.channels), + @"sampleCount": @(result.info.sampleCount), + @"sampleRate": @(result.info.sampleRate), + @"durationMs": @(result.info.durationMs) + }; - resolve(info); - }); + resolve(info); + }); + } reject:reject]; } #ifdef RCT_NEW_ARCH_ENABLED @@ -612,24 +683,35 @@ - (void)unloadAudioResource:(NSString *)key rejecter:(RCTPromiseRejectBlock)reject) #endif { - if (![self initializeAudioEngineIfNeeded] || self.runtime == nullptr) { - reject(@"E_RUNTIME_NOT_INITIALIZED", @"Audio runtime not initialized", nil); + // unloadAudioResource does not require the audio engine to be running. + // If the engine was never initialized, there are no resources to unload — + // resolve immediately with NO without touching AVAudioEngine. + if (!self.audioEngineInitialized || self.runtime == nullptr) { + resolve(@(NO)); return; } - BOOL found = NO; - @synchronized(self.loadedResources) { - if ([self.loadedResources containsObject:key]) { - [self.loadedResources removeObject:key]; - found = YES; + // AVAudioEngine.outputNode must be accessed on the main thread to avoid + // an RPC timeout in AURemoteIO::Cleanup when called from a background queue. + dispatch_async(dispatch_get_main_queue(), ^{ + + BOOL found = NO; + @synchronized(self.loadedResources) { + if ([self.loadedResources containsObject:key]) { + [self.loadedResources removeObject:key]; + found = YES; + } } - } - if (found) { - self.runtime->pruneSharedResources(); - } + if (found) { + { + std::lock_guard lock(self->_runtimeMutex); + self.runtime->pruneSharedResources(); + } + } - resolve(@(found)); + resolve(@(found)); + }); } #ifdef RCT_NEW_ARCH_ENABLED diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index bf84291..33f61ba 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1 +1,190 @@ -it.todo('write a test'); +import { NativeModules } from 'react-native'; + +// Mock the native module before importing our module +const mockElementary = { + getAudioInfo: jest.fn(), + getSampleRate: jest.fn(), + loadAudioResource: jest.fn(), + unloadAudioResource: jest.fn(), + applyInstructions: jest.fn(), + setProperty: jest.fn(), + getDocumentsDirectory: jest.fn(), + getBundlePath: jest.fn(), + activateAudioSession: jest.fn(), + deactivateAudioSession: jest.fn(), + configureAudioSession: jest.fn(), + disableAudioSessionManagement: jest.fn(), + startEventPolling: jest.fn(), + stopEventPolling: jest.fn(), + configureEventPolling: jest.fn(), +}; + +// Needs to be set before module imports +NativeModules.Elementary = mockElementary; + +// We test the public API here — these tests verify behavior observable from JS +describe('react-native-elementary', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + // Re-apply mock after reset + NativeModules.Elementary = mockElementary; + }); + + describe('native module binding', () => { + it('provides access to native getAudioInfo through NativeModules', () => { + // getAudioInfo is available on the native module but not re-exported + // as a library function. It's called internally or via NativeModules directly. + expect(mockElementary.getAudioInfo).toBeDefined(); + }); + }); + + describe('getSampleRate', () => { + it('resolves with sample rate', async () => { + mockElementary.getSampleRate.mockResolvedValueOnce(44100); + + const { getSampleRate } = require('../index'); + const result = await getSampleRate(); + expect(result).toBe(44100); + }); + }); + + describe('loadAudioResource', () => { + it('resolves with resource info', async () => { + const info = { + key: 'kick', + channels: 2, + sampleCount: 44100, + sampleRate: 44100, + durationMs: 1000, + }; + mockElementary.loadAudioResource.mockResolvedValueOnce(info); + + const { loadAudioResource } = require('../index'); + const result = await loadAudioResource('kick', '/path/to/kick.wav'); + expect(result).toEqual(info); + }); + + it('rejects on engine init failure', async () => { + mockElementary.loadAudioResource.mockRejectedValueOnce( + new Error('Failed to initialize audio engine') + ); + + const { loadAudioResource } = require('../index'); + await expect( + loadAudioResource('kick', '/path/to/kick.wav') + ).rejects.toThrow('Failed to initialize audio engine'); + }); + }); + + describe('unloadAudioResource', () => { + it('resolves with true when resource was found and unloaded', async () => { + mockElementary.unloadAudioResource.mockResolvedValueOnce(true); + + const { unloadAudioResource } = require('../index'); + const result = await unloadAudioResource('kick'); + expect(result).toBe(true); + }); + + it('resolves with false when resource was not found', async () => { + // This is the key TDD test: unload should NOT require engine init. + // If engine was never started, there are no resources to unload. + // The native side should resolve with NO/false, not reject. + mockElementary.unloadAudioResource.mockResolvedValueOnce(false); + + const { unloadAudioResource } = require('../index'); + const result = await unloadAudioResource('nonexistent'); + expect(result).toBe(false); + }); + + it('should not reject when engine is not initialized', async () => { + // Regression test: unload should never fail because engine isn't started. + // If the engine was never initialized, there's nothing to unload. + mockElementary.unloadAudioResource.mockResolvedValueOnce(false); + + const { unloadAudioResource } = require('../index'); + // This should resolve, not reject + await expect(unloadAudioResource('any')).resolves.toBe(false); + }); + }); + + describe('applyInstructions', () => { + it('calls native applyInstructions', () => { + expect(mockElementary.applyInstructions).toBeDefined(); + }); + }); + + describe('setProperty', () => { + it('calls native setProperty', () => { + const { setProperty } = require('../index'); + setProperty(12345, 'value', 0.5); + expect(mockElementary.setProperty).toHaveBeenCalledWith( + 12345, + 'value', + 0.5 + ); + }); + }); + + describe('event polling', () => { + it('startEventPolling calls native', async () => { + mockElementary.startEventPolling.mockResolvedValueOnce(true); + const { startEventPolling } = require('../index'); + const result = await startEventPolling(); + expect(result).toBe(true); + }); + + it('stopEventPolling calls native', async () => { + mockElementary.stopEventPolling.mockResolvedValueOnce(true); + const { stopEventPolling } = require('../index'); + const result = await stopEventPolling(); + expect(result).toBe(true); + }); + + it('configureEventPolling calls native', async () => { + mockElementary.configureEventPolling.mockResolvedValueOnce(true); + const { configureEventPolling } = require('../index'); + const result = await configureEventPolling(100); + expect(result).toBe(true); + }); + }); + + describe('audio session', () => { + it('activateAudioSession calls native', async () => { + mockElementary.activateAudioSession.mockResolvedValueOnce(true); + const { activateAudioSession } = require('../index'); + const result = await activateAudioSession(); + expect(result).toBe(true); + }); + + it('deactivateAudioSession calls native', async () => { + mockElementary.deactivateAudioSession.mockResolvedValueOnce(true); + const { deactivateAudioSession } = require('../index'); + const result = await deactivateAudioSession(); + expect(result).toBe(true); + }); + + it('configureAudioSession calls native with defaults', () => { + const { configureAudioSession } = require('../index'); + configureAudioSession(); + expect(mockElementary.configureAudioSession).toHaveBeenCalledWith( + 'playback', + 'default', + ['mixWithOthers', 'allowBluetoothA2DP'] + ); + }); + + it('disableAudioSessionManagement calls native', () => { + const { disableAudioSessionManagement } = require('../index'); + disableAudioSessionManagement(); + expect(mockElementary.disableAudioSessionManagement).toHaveBeenCalled(); + }); + }); + + describe('useRenderer', () => { + it('returns a core renderer (stable ref)', () => { + const mod = require('../index'); + expect(typeof mod.useRenderer).toBe('function'); + }); + }); +});