From bdcb57ee488788d0a61d8d8aea312e9d713b4873 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:59:13 +0800 Subject: [PATCH 1/4] Request microphone permission before audio capture starts webrtc-sdk/webrtc#265 (m144.7559.12 and later) removed the blocking mic permission request from the AudioEngine device. It now only checks the status and fails with kAudioEngineErrorInsufficientDevicePermission, so requesting permission is the SDK's job. The native plugin gains ensureMicrophoneAccess: authorized passes, denied or restricted fails, and notDetermined requests access, on iOS only while the app is active. An inactive or backgrounded app has the alert deferred by the system, and waiting on it would suspend getUserMedia and the publish queue behind it, so it fails fast instead and the next foreground attempt prompts normally. LocalTrack.createStream calls it for audio on Apple platforms before getUserMedia, which covers publishing, restartTrack on unmute, and pre-connect audio. Failures surface as TrackCreateException through the existing audio engine error mapping. --- .changes/mic-permission-before-capture | 1 + lib/src/core/room_preconnect.dart | 8 ++- .../preconnect/pre_connect_audio_buffer.dart | 5 +- lib/src/support/native.dart | 19 +++++++ lib/src/track/local/local.dart | 17 +++++++ shared_swift/LiveKitPlugin.swift | 51 ++++++++++++++++++- test/audio/audio_session_test.dart | 31 +++++++++++ 7 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .changes/mic-permission-before-capture diff --git a/.changes/mic-permission-before-capture b/.changes/mic-permission-before-capture new file mode 100644 index 000000000..5c53625c7 --- /dev/null +++ b/.changes/mic-permission-before-capture @@ -0,0 +1 @@ +patch type="fixed" "iOS/macOS: request microphone permission before audio capture starts, failing fast with TrackCreateException while the app is not in the foreground" diff --git a/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index 41671e891..a1165c93b 100644 --- a/lib/src/core/room_preconnect.dart +++ b/lib/src/core/room_preconnect.dart @@ -41,8 +41,12 @@ extension RoomPreConnect on Room { /// ); /// ``` /// - /// - Note: Ensure microphone permissions are granted early in your app - /// lifecycle so pre-connect can start without additional prompts. + /// - Note: Requires microphone permission. On iOS/macOS the SDK requests it + /// when recording starts, but only while the app is in the foreground, so + /// call this from a foreground context (for example a user tap) where the + /// system prompt can appear. Otherwise it throws a [TrackCreateException]. + /// Requesting permission earlier in the app lifecycle avoids the prompt + /// delaying the first recording. /// - SeeAlso: [PreConnectAudioBuffer] Future withPreConnectAudio( Future Function() operation, { diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index 4427c90d1..2e6434fe4 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -105,8 +105,9 @@ class PreConnectAudioBuffer { /// [agentReadyFuture] completes with an error and callers should [reset] the /// buffer. /// - /// Ensure microphone permissions are granted before calling this. - /// Audio capture may fail without permissions. + /// Requires microphone permission. On iOS/macOS it is requested here while + /// the app is in the foreground. Throws a [TrackCreateException] when it is + /// denied or cannot be requested (app not in the foreground). Future startRecording({ Duration timeout = const Duration(seconds: 20), }) async { diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index ff5f75802..4e6ae36f5 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -272,6 +272,25 @@ class Native { /// platform errors: a failed availability change means the engine may run /// outside the window the caller intended (e.g. CallKit's /// didActivate/didDeactivate), so the error must reach the caller. + /// Requests microphone permission before audio capture starts (iOS/macOS). + /// + /// The WebRTC audio device only checks the permission and fails when it is + /// missing, so the SDK requests it here. On iOS the prompt is only shown while + /// the app is active. Throws a [PlatformException] with code + /// `deviceAccessDenied` when permission is denied, restricted, or could not be + /// requested. A no-op where the platform does not implement it. + @internal + static Future ensureMicrophoneAccess() async { + try { + await channel.invokeMethod('ensureMicrophoneAccess', {}); + } on PlatformException catch (error) { + if (error.code == 'Unimplemented') return; + rethrow; + } on MissingPluginException { + return; + } + } + @internal static Future setEngineAvailability({ required bool isInputAvailable, diff --git a/lib/src/track/local/local.dart b/lib/src/track/local/local.dart index 33dd4d4de..7252cb045 100644 --- a/lib/src/track/local/local.dart +++ b/lib/src/track/local/local.dart @@ -16,10 +16,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; +import '../../audio/audio_engine_error.dart'; import '../../audio/audio_frame_capture.dart'; import '../../events.dart'; import '../../exceptions.dart'; @@ -27,6 +29,7 @@ import '../../extensions.dart'; import '../../internal/events.dart'; import '../../logger.dart'; import '../../participant/remote.dart'; +import '../../support/native.dart'; import '../../support/platform.dart'; import '../../types/other.dart'; import '../options.dart'; @@ -254,6 +257,20 @@ abstract class LocalTrack extends Track { 'video': options is VideoCaptureOptions ? options.toMediaConstraintsMap() : false, }; + if (options is AudioCaptureOptions && lkPlatformIsApple()) { + // The WebRTC audio device only checks microphone permission and fails + // when it is missing, so the SDK requests it before opening the mic. On + // iOS this fails fast while the app is not in the foreground instead of + // suspending getUserMedia (and the publish queue behind it) on a prompt + // the system cannot show yet. + try { + await Native.ensureMicrophoneAccess(); + } on PlatformException catch (error) { + throw audioEngineExceptionFrom(error) ?? + TrackCreateException(error.message ?? 'Microphone permission is not granted'); + } + } + final rtc.MediaStream stream; if (options is ScreenShareCaptureOptions) { if (kIsWeb) { diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index cc09b4d78..e3263001f 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -542,6 +542,49 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } } + // MARK: - Microphone permission + + /// Ensures microphone access is granted before audio capture starts. + /// + /// The WebRTC audio device does not request microphone permission itself. It + /// only checks it and fails with kAudioEngineErrorInsufficientDevicePermission, + /// so requesting is the SDK's job. On iOS the request is only made while the + /// app is active: while inactive or in the background the system defers the + /// alert, and waiting on it would suspend the caller, and the publish queue + /// behind it, for as long as the app stays there. Failing fast lets the next + /// attempt prompt normally. macOS can present the prompt regardless. + /// + /// Method channel handlers run on the main thread, which UIApplication needs. + public func handleEnsureMicrophoneAccess(result: @escaping FlutterResult) { + let denied = { (message: String) in + result(FlutterError(code: LiveKitPlugin.deviceAccessDeniedErrorCode, message: message, details: nil)) + } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + result(nil) + case .notDetermined: + #if !os(macOS) + guard UIApplication.shared.applicationState == .active else { + denied("Microphone permission could not be requested because the app is not in the foreground. Request it while the app is active before enabling recording.") + return + } + #endif + AVCaptureDevice.requestAccess(for: .audio) { granted in + DispatchQueue.main.async { + if granted { + result(nil) + } else { + denied("Microphone permission was denied.") + } + } + } + case .denied, .restricted: + denied("Microphone permission is not granted.") + @unknown default: + denied("Microphone permission is not granted.") + } + } + // MARK: - Microphone mute mode static func muteModeString(_ mode: RTCAudioEngineMuteMode) -> String { @@ -768,6 +811,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleStopLocalRecording(result: result) case "setEngineAvailability": handleSetEngineAvailability(args: args, result: result) + case "ensureMicrophoneAccess": + handleEnsureMicrophoneAccess(result: result) case "setAudioProcessingOptions": handleSetAudioProcessingOptions(args: args, result: result) case "getAudioProcessingState": @@ -800,6 +845,10 @@ extension LiveKitPlugin { static let kAudioEngineErrorInsufficientDevicePermission = -9000 static let kAudioEngineErrorAudioSessionInvalidCategory = -9001 + /// FlutterError code for missing microphone permission. Dart maps it to + /// TrackCreateException (see audio_engine_error.dart). + static let deviceAccessDeniedErrorCode = "deviceAccessDenied" + /// Maps a non-zero audio device module result to a `FlutterError` whose code /// the Dart side can act on. Codes with a known cause get their own error /// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls @@ -807,7 +856,7 @@ extension LiveKitPlugin { static func flutterError(forAudioEngineResult result: Int, fallbackCode: String) -> FlutterError { switch result { case kAudioEngineErrorInsufficientDevicePermission: - return FlutterError(code: "deviceAccessDenied", + return FlutterError(code: deviceAccessDeniedErrorCode, message: "Microphone permission is not granted (audio engine error \(result))", details: result) case kAudioEngineErrorAudioSessionInvalidCategory: diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 286a69ce3..bdda926e1 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -876,6 +876,37 @@ void main() { }); }); + group('Native.ensureMicrophoneAccess', () { + test('is a no-op when the platform does not implement it', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async => throw PlatformException(code: 'Unimplemented'), + ); + await expectLater(Native.ensureMicrophoneAccess(), completes); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + null, + ); + await expectLater(Native.ensureMicrophoneAccess(), completes); + }); + + test('propagates a denied permission so callers can map it', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + Native.channel, + (call) async { + expect(call.method, 'ensureMicrophoneAccess'); + throw PlatformException(code: audioEngineErrorCodeDeviceAccessDenied, message: 'denied'); + }, + ); + + await expectLater( + Native.ensureMicrophoneAccess(), + throwsA(isA().having((error) => error.code, 'code', audioEngineErrorCodeDeviceAccessDenied)), + ); + }); + }); + group('audioEngineExceptionFrom', () { test('maps missing microphone permission to TrackCreateException', () { final error = audioEngineExceptionFrom( From 0a0e30c9345e4cd1119594590d32181871255016 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:41:52 +0800 Subject: [PATCH 2/4] Skip the mic permission gate while engine input availability is disabled With input availability disabled (the CallKit flow from setEngineAvailability) the audio device module defers opening input entirely and runs no permission check, so gating track creation there turned a working background connect into a deviceAccessDenied failure. The plugin already tracks the last-set availability for both the channel and native static paths, so the handler reads that instead of a module getter. Also restores the setEngineAvailability doc comment that the previous commit accidentally split from its declaration, adds the note that permission must be granted before input availability is restored, and aligns the withPreConnectAudio guidance with client-sdk-swift: request permission up front when calling it before the app becomes active. --- lib/src/core/room_preconnect.dart | 10 +++++----- lib/src/support/native.dart | 22 +++++++++++++++------- shared_swift/LiveKitPlugin.swift | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index a1165c93b..f891d6eff 100644 --- a/lib/src/core/room_preconnect.dart +++ b/lib/src/core/room_preconnect.dart @@ -42,11 +42,11 @@ extension RoomPreConnect on Room { /// ``` /// /// - Note: Requires microphone permission. On iOS/macOS the SDK requests it - /// when recording starts, but only while the app is in the foreground, so - /// call this from a foreground context (for example a user tap) where the - /// system prompt can appear. Otherwise it throws a [TrackCreateException]. - /// Requesting permission earlier in the app lifecycle avoids the prompt - /// delaying the first recording. + /// when recording starts, but only while the app is active, so call this + /// once the app has become active and the system prompt can appear. At app + /// launch the app may not be active yet, so when calling this early, + /// request permission up front (for example with the permission_handler + /// package). Otherwise it throws a [TrackCreateException]. /// - SeeAlso: [PreConnectAudioBuffer] Future withPreConnectAudio( Future Function() operation, { diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 4e6ae36f5..d604e078c 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -266,19 +266,15 @@ class Native { } } - /// Sets whether the WebRTC audio engine is allowed to run (iOS/macOS). - /// - /// Unlike most methods in this class this deliberately does not swallow - /// platform errors: a failed availability change means the engine may run - /// outside the window the caller intended (e.g. CallKit's - /// didActivate/didDeactivate), so the error must reach the caller. /// Requests microphone permission before audio capture starts (iOS/macOS). /// /// The WebRTC audio device only checks the permission and fails when it is /// missing, so the SDK requests it here. On iOS the prompt is only shown while /// the app is active. Throws a [PlatformException] with code /// `deviceAccessDenied` when permission is denied, restricted, or could not be - /// requested. A no-op where the platform does not implement it. + /// requested. A no-op where the platform does not implement it, or while the + /// engine's input availability is disabled via [setEngineAvailability] (the + /// audio device defers opening input there, so no permission is needed yet). @internal static Future ensureMicrophoneAccess() async { try { @@ -291,6 +287,18 @@ class Native { } } + /// Sets whether the WebRTC audio engine is allowed to run (iOS/macOS). + /// + /// Unlike most methods in this class this deliberately does not swallow + /// platform errors: a failed availability change means the engine may run + /// outside the window the caller intended (e.g. CallKit's + /// didActivate/didDeactivate), so the error must reach the caller. + /// + /// Microphone permission is not requested here. A recording requested while + /// input was unavailable is honored on re-enable, but the audio device only + /// passively checks permission at that point, so it must be granted before + /// input availability is restored. Otherwise this throws a + /// [PlatformException] with code `deviceAccessDenied`. @internal static Future setEngineAvailability({ required bool isInputAvailable, diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index e3263001f..d268a6abc 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -556,6 +556,20 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { /// /// Method channel handlers run on the main thread, which UIApplication needs. public func handleEnsureMicrophoneAccess(result: @escaping FlutterResult) { + // With engine input availability disabled (the CallKit flow, see + // setEngineAvailability) the audio device module defers opening input + // entirely and runs no permission check, so gating here would turn a + // working background connect into a deviceAccessDenied failure. The + // last-set value is tracked by both the channel and native static + // paths, so it covers gating done before the Flutter engine exists. + LiveKitPlugin.engineAvailabilityLock.lock() + let pendingAvailability = LiveKitPlugin.pendingEngineAvailability + LiveKitPlugin.engineAvailabilityLock.unlock() + if let pendingAvailability, !pendingAvailability.isInputAvailable.boolValue { + result(nil) + return + } + let denied = { (message: String) in result(FlutterError(code: LiveKitPlugin.deviceAccessDeniedErrorCode, message: message, details: nil)) } From 5c123a1d7009db8c88b719a0bc02c9098c04a061 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:39:47 +0800 Subject: [PATCH 3/4] Recover the pre-connect buffer when track creation fails startRecording set isRecording and armed the agent-ready timeout before creating the track, so a creation failure (now a designed outcome when the mic permission gate rejects) left the buffer latched: retries were silently ignored while capturing nothing, and the stale timeout later completed the unobserved agentReadyFuture as an unhandled error. Route the failure through the same stopRecording cleanup the later start steps already use. ReusableCompleter.completeError also delivered errors nobody can observe, since future() hands out a fresh completer once completed. Complete silently in that case, matching what reset() and dispose() already do. --- .changes/preconnect-buffer-cleanup | 1 + .../preconnect/pre_connect_audio_buffer.dart | 9 ++- lib/src/support/reusable_completer.dart | 8 +++ .../pre_connect_audio_buffer_test.dart | 66 +++++++++++++++++++ test/support/reusable_completer_test.dart | 19 ++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .changes/preconnect-buffer-cleanup create mode 100644 test/preconnect/pre_connect_audio_buffer_test.dart diff --git a/.changes/preconnect-buffer-cleanup b/.changes/preconnect-buffer-cleanup new file mode 100644 index 000000000..53a6596af --- /dev/null +++ b/.changes/preconnect-buffer-cleanup @@ -0,0 +1 @@ +patch type="fixed" "Pre-connect audio buffer returns to a reusable state when recording fails to start, instead of ignoring retries and leaking the agent timeout" diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index 2e6434fe4..e67f74a6e 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -120,7 +120,14 @@ class PreConnectAudioBuffer { // Set up timeout for agent readiness _agentReadyManager.setTimer(timeout, timeoutReason: 'Agent did not become ready within timeout'); - _localTrack = await LocalAudioTrack.create(); + try { + _localTrack = await LocalAudioTrack.create(); + } catch (error) { + logger.severe('[Preconnect audio] failed to create local track: $error'); + _onError?.call(error); + await stopRecording(withError: error); + rethrow; + } logger.fine('[Preconnect audio] created local track ${_localTrack!.mediaStreamTrack.id}'); final rendererId = Uuid().v4(); diff --git a/lib/src/support/reusable_completer.dart b/lib/src/support/reusable_completer.dart index c48fa0b88..3f01dccc7 100644 --- a/lib/src/support/reusable_completer.dart +++ b/lib/src/support/reusable_completer.dart @@ -72,6 +72,14 @@ class ReusableCompleter { return false; } + if (!_hasPendingListener) { + // No one can observe this error: [future] creates a fresh completer once + // completed, so delivering it would only surface an unhandled async + // error. Mark completed silently, like reset() and dispose() do. + _markCompletedWithoutNotify(); + return true; + } + _completeCurrent((completer) => completer.completeError(error, stackTrace)); return true; } diff --git a/test/preconnect/pre_connect_audio_buffer_test.dart b/test/preconnect/pre_connect_audio_buffer_test.dart new file mode 100644 index 000000000..0c472dde3 --- /dev/null +++ b/test/preconnect/pre_connect_audio_buffer_test.dart @@ -0,0 +1,66 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter_test/flutter_test.dart'; + +import '../mock/e2e_container.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PreConnectAudioBuffer.startRecording', () { + late E2EContainer container; + + setUp(() { + container = E2EContainer(); + }); + + tearDown(() async { + await container.dispose(); + }); + + // In the test environment LocalAudioTrack.create() always fails (no + // platform channels), which stands in for a real create failure such as + // a denied microphone permission. + test('stays reusable after track creation fails', () async { + final buffer = container.room.preConnectAudioBuffer; + final errors = []; + buffer.setErrorHandler(errors.add); + + await expectLater( + buffer.startRecording(timeout: const Duration(milliseconds: 50)), + throwsA(anything), + ); + + // The buffer must return to an idle state, not stay latched on + // _isRecording so that retries are silently ignored. + expect(buffer.isRecording, isFalse); + expect(errors, hasLength(1)); + + // A retry reaches track creation again and reports its own failure + // instead of returning early as "already recording". + await expectLater( + buffer.startRecording(timeout: const Duration(milliseconds: 50)), + throwsA(anything), + ); + expect(buffer.isRecording, isFalse); + expect(errors, hasLength(2)); + + // The agent-ready timeout was cancelled by the cleanup. If it were + // still armed, it would complete the unobserved agentReadyFuture with + // a TimeoutException and fail this test as an unhandled error. + await Future.delayed(const Duration(milliseconds: 100)); + }); + }); +} diff --git a/test/support/reusable_completer_test.dart b/test/support/reusable_completer_test.dart index 9b68d5bdc..60c8838ec 100644 --- a/test/support/reusable_completer_test.dart +++ b/test/support/reusable_completer_test.dart @@ -93,6 +93,25 @@ void main() { } }); + test('should complete an unobserved error silently', () async { + // No call to `future` before the error: nothing can ever observe it + // (accessing `future` afterwards returns a fresh completer), so it + // must not surface as an unhandled async error. + final result = completer.completeError(Exception('unobserved')); + + expect(result, isTrue); + expect(completer.isCompleted, isTrue); + + // Let any (incorrect) unhandled error surface and fail the test. + await Future.delayed(Duration.zero); + + // The completer stays reusable. + final future = completer.future; + expect(completer.isActive, isTrue); + completer.complete('next'); + await expectLater(future, completion('next')); + }); + test('should return false when completing already completed completer', () { completer.complete('first'); final result1 = completer.complete('second'); From 4385e77ae692c0b8ca492dfa498c43a48c747af7 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:50:28 +0800 Subject: [PATCH 4/4] Guard the pre-connect error callback so a throwing app callback cannot skip cleanup All failure paths invoked the app-provided onError callback before their own cleanup, so a callback that throws left the buffer latched as recording and replaced the original failure with the callback's own error. Route every call site through a helper that logs and swallows callback errors. --- .../preconnect/pre_connect_audio_buffer.dart | 19 ++++++++++++++---- .../pre_connect_audio_buffer_test.dart | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index e67f74a6e..b2994b170 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -124,7 +124,7 @@ class PreConnectAudioBuffer { _localTrack = await LocalAudioTrack.create(); } catch (error) { logger.severe('[Preconnect audio] failed to create local track: $error'); - _onError?.call(error); + _notifyError(error); await stopRecording(withError: error); rethrow; } @@ -145,7 +145,7 @@ class PreConnectAudioBuffer { if (!result) { final error = StateError('Failed to start audio renderer ($result)'); logger.severe('[Preconnect audio] $error'); - _onError?.call(error); + _notifyError(error); await stopRecording(withError: error); await _localTrack?.stop(); _localTrack = null; @@ -157,7 +157,7 @@ class PreConnectAudioBuffer { _nativeRecordingStarted = lkPlatformSupportsExplicitAudioRecordingStart(); } catch (error) { logger.severe('[Preconnect audio] failed to start local recording: $error'); - _onError?.call(error); + _notifyError(error); await stopRecording(withError: error); await _localTrack?.stop(); _localTrack = null; @@ -191,7 +191,7 @@ class PreConnectAudioBuffer { _agentReadyManager.complete(); } catch (error) { _agentReadyManager.completeError(error); - _onError?.call(error); + _notifyError(error); } }, ); @@ -353,4 +353,15 @@ class PreConnectAudioBuffer { void setErrorHandler(PreConnectOnError? onError) { _onError = onError; } + + /// Invokes the app-provided error callback without letting a throwing + /// callback derail the failure path it is called from: cleanup must still + /// run and the original error must stay the one callers see. + void _notifyError(Object error) { + try { + _onError?.call(error); + } catch (callbackError) { + logger.warning('[Preconnect audio] onError callback threw: $callbackError'); + } + } } diff --git a/test/preconnect/pre_connect_audio_buffer_test.dart b/test/preconnect/pre_connect_audio_buffer_test.dart index 0c472dde3..63e1fc2e9 100644 --- a/test/preconnect/pre_connect_audio_buffer_test.dart +++ b/test/preconnect/pre_connect_audio_buffer_test.dart @@ -62,5 +62,25 @@ void main() { // a TimeoutException and fail this test as an unhandled error. await Future.delayed(const Duration(milliseconds: 100)); }); + + test('cleans up even when the error handler throws', () async { + final buffer = container.room.preConnectAudioBuffer; + var callbackCalls = 0; + buffer.setErrorHandler((error) { + callbackCalls++; + throw StateError('app callback bug'); + }); + + // The original track creation failure must reach the caller, not the + // callback's own error, and cleanup must still run. + await expectLater( + buffer.startRecording(timeout: const Duration(milliseconds: 50)), + throwsA(isNot(isA())), + ); + + expect(callbackCalls, 1); + expect(buffer.isRecording, isFalse); + await Future.delayed(const Duration(milliseconds: 100)); + }); }); }