Skip to content

[web] startStream() never completes when getUserMedia rejects (awaits close() on an unlistened StreamController) #624

Closed

Description

@adaamsaa

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
  • record 6.2.1 / record_web 1.3.0, and record 7.1.1 / record_web 2.1.2
  • Chrome (headless via flutter test --platform chrome, and regular Chrome with the site's microphone set to Block)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions