Repository navigation
Description
Summary
On web, AudioRecorder.startStream() never completes (neither resolves nor throws) when getUserMedia rejects, for example when the site's microphone permission is blocked, the prompt is dismissed, or no input device exists. Callers that await startStream(...) are stuck forever, and dispose() afterwards hangs the same way.
Reproduced on record 6.2.1 / record_web 1.3.0 and on the latest record 7.1.1 / record_web 2.1.2.
Cause
MicRecorderDelegate.startStream (record_web/lib/src/recorder/delegate/mic_recorder_delegate.dart):
Future<Stream<Uint8List>> startStream(RecordConfig config) async {
await _recordStreamCtrl?.close();
_recordStreamCtrl = StreamController<Uint8List>();
try {
await _start(config, isStream: true);
} catch (err) {
debugPrint(err.toString());
await _recordStreamCtrl?.close(); // <-- never completes
_recordStreamCtrl = null;
rethrow; // never reached
}
return _recordStreamCtrl!.stream;
}_recordStreamCtrl is a single-subscription StreamController that has not been returned to the caller yet, so nobody has listened to its stream. StreamController.close() returns the done future, which for a single-subscription stream only completes once a listener has received the done event (or cancelled). With no listener it never completes, so the await blocks forever and the rethrow is never reached.
The same pattern is in the first line (await _recordStreamCtrl?.close()) when a previous startStream failed and left an unlistened controller behind, and in the _reset path used by dispose(), which is why dispose() hangs too after a failed start.
Standalone proof of the Dart semantics:
import 'dart:async';
Future<void> main() async {
final c = StreamController<List<int>>();
final r = await Future.any([
c.close().then((_) => 'completed'),
Future.delayed(const Duration(seconds: 2), () => 'never completes'),
]);
print(r); // never completes
}Reproduction
Headless Chrome cannot grant the microphone, so flutter test --platform chrome reproduces the blocked-mic case exactly:
@TestOn('browser')
library;
import 'dart:js_interop';
import 'package:flutter_test/flutter_test.dart';
import 'package:record/record.dart';
import 'package:record_web/record_web.dart';
import 'package:web/web.dart' as web;
const _grace = Duration(seconds: 3);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// `flutter test --platform chrome` does not run the web plugin registrant,
// so register the web implementation by hand. (Without this the default
// method-channel implementation also never answers on web, which would
// reproduce a hang for the wrong reason.)
RecordPlatform.instance = RecordPluginWebWrapper();
test('getUserMedia rejects promptly', () async {
final request = web.window.navigator.mediaDevices
.getUserMedia(web.MediaStreamConstraints(audio: true.toJS))
.toDart;
await expectLater(request.timeout(_grace), throwsA(anything));
});
test('startStream never completes when the mic is rejected', () async {
final recorder = AudioRecorder();
addTearDown(() => recorder.dispose().timeout(_grace, onTimeout: () {}));
final start = recorder.startStream(const RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 16000,
numChannels: 1,
));
final outcome = await Future.any([
start.then((_) => 'completed', onError: (e) => 'threw: $e'),
Future.delayed(_grace, () => 'still pending'),
]);
expect(outcome, 'still pending'); // passes = bug present
});
}Run with flutter test --platform chrome <file> (add record_web as a dev dependency for the import). Both tests pass on 6.2.1 / record_web 1.3.0 and on 7.1.1 / record_web 2.1.2: getUserMedia rejects within milliseconds, the plugin's own debugPrint shows NotAllowedError: Permission denied being caught, yet startStream is still pending 3 s later and stays pending.
Expected
startStream should rethrow the getUserMedia error (NotAllowedError, NotFoundError, ...) so the app can show the user something. Right now the only way to detect a blocked microphone is to call getUserMedia yourself before calling the plugin.
Suggested fix
Do not await closing a controller that has no listener. For example:
} catch (err) {
debugPrint(err.toString());
unawaited(_recordStreamCtrl?.close());
_recordStreamCtrl = null;
rethrow;
}or only await the close when _recordStreamCtrl!.hasListener is true. The same applies to the close() at the top of startStream and in _reset.
Environment
- Flutter 3.47.1 (stable), Dart 3.12
record6.2.1 /record_web1.3.0, andrecord7.1.1 /record_web2.1.2- Chrome (headless via
flutter test --platform chrome, and regular Chrome with the site's microphone set to Block)
Activity