From 4ac97c8a0e8e04625f716ffdceceb28db1f8868f Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 6 Nov 2026 10:00:29 +0100 Subject: [PATCH 1/2] fix(react-native): re-activate a refused iOS audio session on route change A join while another app holds non-mixable audio (FaceTime, phone call) is refused with 561017449 and the diagnostics tracer postpones the recovery until a signal says the holder is gone. It only listened for an interruption end, the app becoming active or a callingx activation. iOS delivers no interruption to a session that was never active, and the app stays in the foreground when the FaceTime call runs in its iPad window or the other party hangs up: only audio route changes reach it, and the tracer skipped those because the refusal reused the interruption latch. The call stayed silent until the app was backgrounded. The refusal now has a latch of its own, cleared by a route change as well. A refused retry costs no budget, so the extra attempts are harmless. Found in the device QA of the v1 backport (#2593). --- packages/react-native-sdk/CLAUDE.md | 2 +- .../AudioSessionDiagnosticsTracer.test.tsx | 55 ++++++++++++++++++- .../AudioSessionDiagnosticsTracer.tsx | 30 +++++++--- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/react-native-sdk/CLAUDE.md b/packages/react-native-sdk/CLAUDE.md index ecc2cb4d98..53148f07de 100644 --- a/packages/react-native-sdk/CLAUDE.md +++ b/packages/react-native-sdk/CLAUDE.md @@ -47,7 +47,7 @@ The public `callManager` (`src/modules/call-manager/CallManager.ts`) is a **conf - Sync engine hooks (`AudioDeviceModule.addEngineHooks`, WebRTC worker thread): the SDK registers willEnable (re-applies the configuration only, between `start()` and `stop()`; never `setActive`) and didCreateModule; no didDisable: the activation is held from `start()` to `stop()` because a REJOIN reconnect fully disables and rebuilds the engine mid-call; callingx registers all four (willEnable re-applies category/mode and sets the startup-mute flag, willStart/didDisable clear it, didCreateModule replays the desired availability). Hooks: no ADM calls, no `audioSessionQueue.sync`/`main.sync`, all `RTCAudioSession` locking via each package's `AudioSessionConfigLock` (lock, body, unlock like stream-video-swift; never nest: re-locking on the same thread crashes; other threads just wait). `setEngineAvailability` is a blocking call onto the worker thread: only from `audioSessionQueue`/`engineAvailabilityQueue` or a `didCreateModule` hook, never from worker-thread hooks nor inside `AudioSessionConfigLock.run`. - Interruptions: the ADM stops/restarts the engine itself and `RTCAudioSession` re-activates once; a failed re-activation is repaired on interruption-end by `StreamInCallManagerImpl` (`sessionReactivated` diagnostic, `trigger: interruptionEnded`) and by the tracer. - Recovery: `AudioSessionDiagnosticsTracer` samples `getEngineState()`, calls `recoverEngineIfNeeded` (≤3/episode, ≤6/call, ≥3 s apart), then one `call.rejoinForAudioEngineRecovery()` per call; traces `ios.audioEngine.*` and `audio.session.timeout`. -- Recovery is skipped while an audio interruption is active. With an inactive session the tracer first calls `callManager.ios.reactivateAudioSession` (the ADM cannot restart against an inactive session); when iOS refuses (`561017449`, another app owns the audio) it waits for the next interruption end / session activation / app foreground without spending its budget or rejoining; any other re-activation failure is re-checked on a budget of its own (same size/spacing, no rejoin). The audio rejoin is skipped (call stays joined) when the rejoin rate limiter has no room. +- Recovery is skipped while an audio interruption is active. With an inactive session the tracer first calls `callManager.ios.reactivateAudioSession` (the ADM cannot restart against an inactive session); when iOS refuses (`561017449`, another app owns the audio) it waits for the next interruption end / session activation / app foreground / audio route change (the only signal when the holder, e.g. an iPad FaceTime window, ends while the app stays in the foreground) without spending its budget or rejoining; any other re-activation failure is re-checked on a budget of its own (same size/spacing, no rejoin). The audio rejoin is skipped (call stays joined) when the rejoin rate limiter has no room. ## Per-Call PeerConnectionFactory (Media Engine) diff --git a/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx b/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx index 01278b106b..5bada4d455 100644 --- a/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx +++ b/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx @@ -909,8 +909,7 @@ describe('AudioSessionDiagnosticsTracer', () => { }, ]); - // no checks until a signal - routeListener(); + // the mic toggle is not a signal act(() => { call.status$.next('disabled'); }); @@ -921,6 +920,14 @@ describe('AudioSessionDiagnosticsTracer', () => { expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1); expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled(); + // a route change is one (the holder may have released the hardware): + // tried again, still refused, no budget spent + routeListener(); + await advance(1000); + expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2); + expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled(); + expect(recoveryTraces(call)).toHaveLength(1); + // the real interruption end re-activated the session: full budget left adm.getEngineState.mockResolvedValue(deadEngine); interruptionListener({ source: 'callmanager', phase: 'ended' }); @@ -934,6 +941,50 @@ describe('AudioSessionDiagnosticsTracer', () => { expect(call.rejoinForAudioEngineRecovery).not.toHaveBeenCalled(); }); + it('re-activates a refused session on a route change, when the holder released the hardware', async () => { + const call = await renderJoined(); + adm.getEngineState.mockResolvedValue({ + ...deadEngine, + sessionActive: false, + }); + iosCallManager.reactivateAudioSession.mockResolvedValue(true); + iosCallManager.reactivateAudioSession.mockRejectedValueOnce( + Object.assign(new Error('!int'), { code: '561017449' }), + ); + adm.recoverEngineIfNeeded.mockResolvedValue({ + result: 'recovered', + before: deadEngine, + after: healthyEngine, + codes: { stopRecording: 0, startRecording: 0 }, + }); + + appStateListener('active'); + await advance(1000); + expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1); + expect(adm.recoverEngineIfNeeded).not.toHaveBeenCalled(); + + // FaceTime ended in its own window or by the other party: the app stays + // in the foreground and only a route change reaches it + await advance(3000); + routeListener(); + await advance(1000); + expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2); + expect(adm.recoverEngineIfNeeded).toHaveBeenCalledTimes(1); + expect(recoveryTraces(call)).toEqual([ + { + trigger: 'appActive', + result: 'skipped', + reason: 'sessionRefused', + attempt: 1, + }, + expect.objectContaining({ + trigger: 'audioRouteChanged', + result: 'recovered', + attempt: 1, + }), + ]); + }); + it('recovers after re-activating the inactive session itself', async () => { const call = await renderJoined(); adm.getEngineState.mockResolvedValue({ diff --git a/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx b/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx index 1a09d6400c..288c0c1046 100644 --- a/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx +++ b/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx @@ -133,11 +133,16 @@ export const AudioSessionDiagnosticsTracer = () => { // arriving meanwhile is kept (the latest one) and checked once afterwards let inFlight: Promise | undefined; let pendingTrigger: string | undefined; - // iOS owns the audio during an interruption (phone call, Siri, alarm) and - // while it refuses to re-activate the session afterwards: no checks until - // the interruption ends, the session is re-activated or the app is active - // again (WebRTC treats the latter as an interruption end too) + // iOS owns the audio during an interruption (phone call, Siri, alarm): no + // checks until the interruption ends or the app is active again (WebRTC + // treats the latter as an interruption end too) let interrupted = false; + // iOS refused to activate the session (another app holds non-mixable + // audio, e.g. a FaceTime or phone call): no checks until a signal says the + // holder is gone. A route change is the only signal when the app stays in + // the foreground (iPad FaceTime window, the other party hanging up): no + // interruption is delivered to a session that was never active. + let sessionRefused = false; const runCheck = (trigger: string) => { if (inFlight) { @@ -159,7 +164,7 @@ export const AudioSessionDiagnosticsTracer = () => { const checkEngine = async (trigger: string) => { if (call.state.callingState !== CallingState.JOINED) return; - if (interrupted) { + if (interrupted || sessionRefused) { logger.debug( `check '${trigger}' skipped, the audio is interrupted or the session is inactive`, ); @@ -239,7 +244,7 @@ export const AudioSessionDiagnosticsTracer = () => { } logger.debug(`recovery '${trigger}' postponed, ${reason}`); if (reason === 'sessionRefused') { - interrupted = true; + sessionRefused = true; return; } // bounded re-check (no rejoin against an inactive session); later @@ -309,6 +314,7 @@ export const AudioSessionDiagnosticsTracer = () => { // intent would unmute a muted ADM (the mic toggle checks on its own) if ( interrupted || + sessionRefused || call.state.callingState !== CallingState.JOINED || (call.microphone.state.status === 'enabled') !== wantsRecording ) { @@ -353,6 +359,7 @@ export const AudioSessionDiagnosticsTracer = () => { const canRejoin = !rejoined && !interrupted && + !sessionRefused && call.state.callingState === CallingState.JOINED && AppState.currentState === 'active' && call.microphone.state.status === 'enabled'; @@ -459,14 +466,19 @@ export const AudioSessionDiagnosticsTracer = () => { interrupted = true; } else if (event.phase === 'ended') { interrupted = false; + sessionRefused = false; scheduleCheck('interruptionEnded', 1000); } }; const removeInterruptionListener = callManager.ios.addAudioInterruptionListener(onInterruption); - const removeRouteListener = callManager.audioDevices.addChangeListener(() => - scheduleCheck('audioRouteChanged', 1000), + const removeRouteListener = callManager.audioDevices.addChangeListener( + () => { + // the holder of a refused session released the hardware + sessionRefused = false; + scheduleCheck('audioRouteChanged', 1000); + }, ); const appStateSubscription = AppState.addEventListener( @@ -474,6 +486,7 @@ export const AudioSessionDiagnosticsTracer = () => { (appState) => { if (appState !== 'active') return; interrupted = false; + sessionRefused = false; scheduleCheck('appActive', 1000); }, ); @@ -486,6 +499,7 @@ export const AudioSessionDiagnosticsTracer = () => { // delayed so the activation-driven engine start can run first () => { interrupted = false; + sessionRefused = false; scheduleCheck('audioSessionActivated', 1000); }, ), From 1e5d700700ffbbd1011634e8df822f700767ce75 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 6 Nov 2026 10:15:29 +0100 Subject: [PATCH 2/2] fix(react-native): keep a release signal received during a pending re-activation The refused-session latch is the only one set from an attempt result: a route change (or any other release signal) arriving while the re-activation is still pending was undone by that attempt's refusal, and the check the signal had scheduled skipped. Release signals are counted now and a refusal only latches when none arrived during the attempt. --- .../AudioSessionDiagnosticsTracer.test.tsx | 35 +++++++++++++++++++ .../AudioSessionDiagnosticsTracer.tsx | 19 +++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx b/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx index 5bada4d455..87d376e807 100644 --- a/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx +++ b/packages/react-native-sdk/__tests__/providers/AudioSessionDiagnosticsTracer.test.tsx @@ -985,6 +985,41 @@ describe('AudioSessionDiagnosticsTracer', () => { ]); }); + it('keeps a release signal that arrives while a re-activation is pending', async () => { + await renderJoined(); + adm.getEngineState.mockResolvedValue({ + ...deadEngine, + sessionActive: false, + }); + let rejectPending: (error: unknown) => void = () => {}; + iosCallManager.reactivateAudioSession.mockResolvedValue(true); + iosCallManager.reactivateAudioSession.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectPending = reject; + }), + ); + adm.recoverEngineIfNeeded.mockResolvedValue({ + result: 'recovered', + before: deadEngine, + after: healthyEngine, + codes: { stopRecording: 0, startRecording: 0 }, + }); + + appStateListener('active'); + await advance(1000); + expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(1); + + // the holder released the hardware while the attempt was pending; the + // attempt's refusal must not latch over that signal + routeListener(); + rejectPending(Object.assign(new Error('!int'), { code: '561017449' })); + // the route-change check (1 s) waits out the attempt spacing (3 s) + await advance(3000); + expect(iosCallManager.reactivateAudioSession).toHaveBeenCalledTimes(2); + expect(adm.recoverEngineIfNeeded).toHaveBeenCalledTimes(1); + }); + it('recovers after re-activating the inactive session itself', async () => { const call = await renderJoined(); adm.getEngineState.mockResolvedValue({ diff --git a/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx b/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx index 288c0c1046..8bfa33cef1 100644 --- a/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx +++ b/packages/react-native-sdk/src/providers/StreamCall/AudioSessionDiagnosticsTracer.tsx @@ -143,6 +143,14 @@ export const AudioSessionDiagnosticsTracer = () => { // the foreground (iPad FaceTime window, the other party hanging up): no // interruption is delivered to a session that was never active. let sessionRefused = false; + // release signals are counted: one arriving while a re-activation is + // pending must not be undone by that attempt's refusal (the check it + // scheduled would skip and no further signal may come) + let releaseSignals = 0; + const onReleaseSignal = () => { + sessionRefused = false; + releaseSignals += 1; + }; const runCheck = (trigger: string) => { if (inFlight) { @@ -231,6 +239,7 @@ export const AudioSessionDiagnosticsTracer = () => { return; } lastActivationAt = Date.now(); + const signalsBefore = releaseSignals; const reason = await reactivateSession(); if (disposed) return; if (reason) { @@ -244,7 +253,7 @@ export const AudioSessionDiagnosticsTracer = () => { } logger.debug(`recovery '${trigger}' postponed, ${reason}`); if (reason === 'sessionRefused') { - sessionRefused = true; + if (releaseSignals === signalsBefore) sessionRefused = true; return; } // bounded re-check (no rejoin against an inactive session); later @@ -466,7 +475,7 @@ export const AudioSessionDiagnosticsTracer = () => { interrupted = true; } else if (event.phase === 'ended') { interrupted = false; - sessionRefused = false; + onReleaseSignal(); scheduleCheck('interruptionEnded', 1000); } }; @@ -476,7 +485,7 @@ export const AudioSessionDiagnosticsTracer = () => { const removeRouteListener = callManager.audioDevices.addChangeListener( () => { // the holder of a refused session released the hardware - sessionRefused = false; + onReleaseSignal(); scheduleCheck('audioRouteChanged', 1000); }, ); @@ -486,7 +495,7 @@ export const AudioSessionDiagnosticsTracer = () => { (appState) => { if (appState !== 'active') return; interrupted = false; - sessionRefused = false; + onReleaseSignal(); scheduleCheck('appActive', 1000); }, ); @@ -499,7 +508,7 @@ export const AudioSessionDiagnosticsTracer = () => { // delayed so the activation-driven engine start can run first () => { interrupted = false; - sessionRefused = false; + onReleaseSignal(); scheduleCheck('audioSessionActivated', 1000); }, ),