[flutter_tools] Prevent interactive device selection in machine mode (#188267)
Fixes a crash when running `flutter run --machine` (or other commands in
machine mode) when multiple devices are connected and no device is
specified.
Previously, if multiple devices were connected, the tool would attempt
to prompt the user interactively to select a device, even in machine
mode. This interactive prompt listens to `stdin` (via a broadcast stream
wrapper). Since `stdin` is a single-subscription stream, once it has
been listened to by the prompt, it cannot be listened to again. When the
machine daemon later attempts to listen to `stdin` to receive JSON-RPC
commands, it throws a `StateError: Bad state: Stream has already been
listened to.` and crashes.
This fix introduces a `canPrompt` flag to device discovery and selection
methods (defaulting to `!outputMachineFormat` in `FlutterCommand`),
which disables interactive prompting when running in machine mode.
Instead, the tool will print the available devices and exit gracefully,
which is the correct behavior for machine-to-machine communication when
a required parameter is missing.
Fixes #175948
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart
index 2173f9e..95b5f39 100644
--- a/packages/flutter_tools/lib/src/runner/flutter_command.dart
+++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart
@@ -242,7 +242,9 @@
bool get shouldRunPub => _usesPubOption && boolArg('pub');
- bool get outputMachineFormat => boolArg('machine');
+ bool get outputMachineFormat =>
+ argParser.options.containsKey(FlutterGlobalOptions.kMachineFlag) &&
+ boolArg(FlutterGlobalOptions.kMachineFlag);
bool get shouldUpdateCache => true;
@@ -2052,10 +2054,17 @@
/// devices and criteria entered by the user on the command line.
/// If no device can be found that meets specified criteria,
/// then print an error message and return null.
+ ///
+ /// If [canPrompt] is true, the tool will interactively prompt the user to
+ /// select a device when multiple devices are found and a terminal is
+ /// attached. If [canPrompt] is false, the interactive prompt is bypassed.
+ /// If not specified, [canPrompt] defaults to `!outputMachineFormat`.
Future<List<Device>?> findAllTargetDevices({
+ bool? canPrompt,
bool includeDevicesUnsupportedByProject = false,
}) async {
return _targetDevices.findAllTargetDevices(
+ canPrompt: canPrompt ?? !outputMachineFormat,
deviceDiscoveryTimeout: deviceDiscoveryTimeout,
includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
);
diff --git a/packages/flutter_tools/lib/src/runner/target_devices.dart b/packages/flutter_tools/lib/src/runner/target_devices.dart
index 1f49b8e..235a341 100644
--- a/packages/flutter_tools/lib/src/runner/target_devices.dart
+++ b/packages/flutter_tools/lib/src/runner/target_devices.dart
@@ -141,14 +141,15 @@
/// partial match. If an exact match or a single partial match is found,
/// return it immediately.
///
- /// When multiple devices are found and there is a terminal attached to
- /// stdin, allow the user to select which device to use. When a terminal
- /// with stdin is not available, print a list of available devices and
- /// return null.
+ /// When multiple devices are found, [canPrompt] is true, and there is a
+ /// terminal attached to stdin, allow the user to select which device to use.
+ /// When a terminal with stdin is not available or [canPrompt] is false, print
+ /// a list of available devices and return null.
///
/// When no devices meet user specifications, print a list of unsupported
/// devices and return null.
Future<List<Device>?> findAllTargetDevices({
+ bool canPrompt = true,
Duration? deviceDiscoveryTimeout,
bool includeDevicesUnsupportedByProject = false,
}) async {
@@ -188,7 +189,7 @@
} else if (_deviceManager.hasSpecifiedAllDevices) {
return allDevices;
} else if (allDevices.length > 1) {
- return _handleMultipleDevices(attachedDevices, wirelessDevices);
+ return _handleMultipleDevices(attachedDevices, wirelessDevices, canPrompt: canPrompt);
}
return allDevices;
}
@@ -226,13 +227,15 @@
/// ephemeral devices. If a single ephemeral device is found, return it
/// immediately.
///
- /// Otherwise, prompt the user to select a device if there is a terminal
- /// with stdin. If there is not a terminal, display the list of devices with
- /// instructions to use a device selection flag.
+ /// Otherwise, prompt the user to select a device if [canPrompt] is true and
+ /// there is a terminal with stdin. If [canPrompt] is false or there is not a
+ /// terminal, display the list of devices with instructions to use a device
+ /// selection flag.
Future<List<Device>?> _handleMultipleDevices(
List<Device> attachedDevices,
- List<Device> wirelessDevices,
- ) async {
+ List<Device> wirelessDevices, {
+ bool canPrompt = true,
+ }) async {
final List<Device> allDevices = attachedDevices + wirelessDevices;
final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(allDevices);
@@ -240,7 +243,7 @@
return <Device>[ephemeralDevice];
}
- if (globals.terminal.stdinHasTerminal) {
+ if (canPrompt && globals.terminal.stdinHasTerminal) {
return _selectFromMultipleDevices(attachedDevices, wirelessDevices);
} else {
return _printMultipleDevices(attachedDevices, wirelessDevices);
@@ -456,15 +459,16 @@
/// single partial match is found and the device is not connected and it's
/// an iOS device, wait for it to connect.
///
- /// When multiple devices are found and there is a terminal attached to
- /// stdin, allow the user to select which device to use. When a terminal
- /// with stdin is not available, print a list of available devices and
- /// return null.
+ /// When multiple devices are found, [canPrompt] is true, and there is a
+ /// terminal attached to stdin, allow the user to select which device to use.
+ /// When a terminal with stdin is not available or [canPrompt] is false, print
+ /// a list of available devices and return null.
///
/// When no devices meet user specifications, print a list of unsupported
/// devices and return null.
@override
Future<List<Device>?> findAllTargetDevices({
+ bool canPrompt = true,
Duration? deviceDiscoveryTimeout,
bool includeDevicesUnsupportedByProject = false,
}) async {
@@ -480,6 +484,7 @@
if (deviceDiscoveryTimeout != null ||
deviceConnectionInterface == DeviceConnectionInterface.attached) {
return await super.findAllTargetDevices(
+ canPrompt: canPrompt,
deviceDiscoveryTimeout: deviceDiscoveryTimeout,
includeDevicesUnsupportedByProject: includeDevicesUnsupportedByProject,
);
@@ -553,14 +558,22 @@
);
if (attachedDevices.isEmpty) {
- return await _handleNoAttachedDevices(attachedDevices, futureWirelessDevices);
+ return await _handleNoAttachedDevices(
+ attachedDevices,
+ futureWirelessDevices,
+ canPrompt: canPrompt,
+ );
} else if (_deviceManager.hasSpecifiedAllDevices) {
return await _handleAllDevices(attachedDevices, futureWirelessDevices);
}
// Even if there's only a single attached device, continue to
// `_handleRemainingDevices` since there might be wireless devices
// that are not loaded yet.
- return await _handleRemainingDevices(attachedDevices, futureWirelessDevices);
+ return await _handleRemainingDevices(
+ attachedDevices,
+ futureWirelessDevices,
+ canPrompt: canPrompt,
+ );
} finally {
stopExtendedWirelessDeviceDiscovery();
}
@@ -574,8 +587,9 @@
/// If wireless devices are found, continue to `_handleMultipleDevices`.
Future<List<Device>?> _handleNoAttachedDevices(
List<Device> attachedDevices,
- Future<List<Device>> futureWirelessDevices,
- ) async {
+ Future<List<Device>> futureWirelessDevices, {
+ bool canPrompt = true,
+ }) async {
if (_includeAttachedDevices) {
_logger.printStatus(_noAttachedCheckForWirelessMessage);
} else {
@@ -592,7 +606,7 @@
return allDevices;
} else if (allDevices.length > 1) {
_logger.printStatus('');
- return _handleMultipleDevices(attachedDevices, wirelessDevices);
+ return _handleMultipleDevices(attachedDevices, wirelessDevices, canPrompt: canPrompt);
}
return allDevices;
}
@@ -614,19 +628,21 @@
/// ephemeral devices. If a single ephemeral device is found, return it
/// immediately.
///
- /// Otherwise, prompt the user to select a device if there is a terminal
- /// with stdin. If there is not a terminal, display the list of devices with
- /// instructions to use a device selection flag.
+ /// Otherwise, prompt the user to select a device if [canPrompt] is true and
+ /// there is a terminal with stdin. If [canPrompt] is false or there is not a
+ /// terminal, display the list of devices with instructions to use a device
+ /// selection flag.
Future<List<Device>?> _handleRemainingDevices(
List<Device> attachedDevices,
- Future<List<Device>> futureWirelessDevices,
- ) async {
+ Future<List<Device>> futureWirelessDevices, {
+ bool canPrompt = true,
+ }) async {
final Device? ephemeralDevice = _deviceManager.getSingleEphemeralDevice(attachedDevices);
if (ephemeralDevice != null) {
return <Device>[ephemeralDevice];
}
- if (!globals.terminal.stdinHasTerminal || !_logger.supportsColor) {
+ if (!canPrompt || !globals.terminal.stdinHasTerminal || !_logger.supportsColor) {
_logger.printStatus(_checkingForWirelessDevicesMessage);
final List<Device> wirelessDevices = await futureWirelessDevices;
if (attachedDevices.length + wirelessDevices.length == 1) {
@@ -635,8 +651,8 @@
_logger.printStatus('');
// If the terminal has stdin but does not support color/ANSI (which is
// needed to clear lines), fallback to standard selection of device.
- if (globals.terminal.stdinHasTerminal && !_logger.supportsColor) {
- return _handleMultipleDevices(attachedDevices, wirelessDevices);
+ if (canPrompt && globals.terminal.stdinHasTerminal && !_logger.supportsColor) {
+ return _handleMultipleDevices(attachedDevices, wirelessDevices, canPrompt: canPrompt);
}
// If terminal does not have stdin, print out device list.
final List<Device>? devices = await _printMultipleDevices(attachedDevices, wirelessDevices);
diff --git a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
index 5dbf8c4..c5b62ab 100644
--- a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart
@@ -16,6 +16,7 @@
import 'package:flutter_tools/src/base/os.dart' show OperatingSystemUtils;
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/signals.dart';
+import 'package:flutter_tools/src/base/terminal.dart';
import 'package:flutter_tools/src/base/time.dart';
import 'package:flutter_tools/src/base/user_messages.dart';
import 'package:flutter_tools/src/build_info.dart';
@@ -825,6 +826,77 @@
});
});
+ group('findAllTargetDevices canPrompt/machine mode interaction', () {
+ final device1 = FakeDevice('device1', 'device-1');
+ final device2 = FakeDevice('device2', 'device-2');
+ late FakeTerminal terminal;
+
+ setUp(() {
+ terminal = FakeTerminal();
+ });
+
+ testUsingContext('defaults to prompting when machine mode is false', () async {
+ testDeviceManager.addAttachedDevice(device1);
+ testDeviceManager.addAttachedDevice(device2);
+
+ final flutterCommand = DummyFlutterCommand();
+ final List<Device>? devices = await flutterCommand.findAllTargetDevices();
+
+ // Should prompt the user and print prompt options (so status contains "Connected devices")
+ expect(testLogger.statusText, contains('Connected devices:'));
+ expect(devices, <Device>[device1]);
+ }, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+
+ testUsingContext('defaults to not prompting when machine mode is true', () async {
+ testDeviceManager.addAttachedDevice(device1);
+ testDeviceManager.addAttachedDevice(device2);
+
+ final flutterCommand = DummyMachineFlutterCommand();
+ final CommandRunner<void> runner = createTestCommandRunner(flutterCommand);
+ await runner.run(<String>['dummy', '--machine']);
+
+ final List<Device>? devices = await flutterCommand.findAllTargetDevices();
+
+ // Should NOT prompt, should print specify device help, and return null
+ expect(
+ testLogger.statusText,
+ contains('More than one device connected; please specify a device'),
+ );
+ expect(devices, isNull);
+ }, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+
+ testUsingContext('can override default canPrompt when machine mode is true', () async {
+ testDeviceManager.addAttachedDevice(device1);
+ testDeviceManager.addAttachedDevice(device2);
+
+ final flutterCommand = DummyMachineFlutterCommand();
+ final CommandRunner<void> runner = createTestCommandRunner(flutterCommand);
+ await runner.run(<String>['dummy', '--machine']);
+
+ // Explicitly set canPrompt to true, overriding outputMachineFormat default of false.
+ final List<Device>? devices = await flutterCommand.findAllTargetDevices(canPrompt: true);
+
+ // Should prompt the user even in machine mode.
+ expect(testLogger.statusText, contains('Connected devices:'));
+ expect(devices, <Device>[device1]);
+ }, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+
+ testUsingContext('can override default canPrompt when machine mode is false', () async {
+ testDeviceManager.addAttachedDevice(device1);
+ testDeviceManager.addAttachedDevice(device2);
+
+ final flutterCommand = DummyFlutterCommand();
+ final List<Device>? devices = await flutterCommand.findAllTargetDevices(canPrompt: false);
+
+ // Should NOT prompt the user even if machine mode is false.
+ expect(
+ testLogger.statusText,
+ contains('More than one device connected; please specify a device'),
+ );
+ expect(devices, isNull);
+ }, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+ });
+
group('--dart-define-from-file', () {
late FlutterCommand dummyCommand;
late CommandRunner<void> dummyCommandRunner;
@@ -1851,3 +1923,42 @@
@override
Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+
+class FakeTerminal extends Fake implements AnsiTerminal {
+ FakeTerminal({this.stdinHasTerminal = true, this.supportsColor = false});
+
+ @override
+ final bool stdinHasTerminal;
+
+ @override
+ final bool supportsColor;
+
+ @override
+ bool get isCliAnimationEnabled => supportsColor;
+
+ @override
+ bool usesTerminalUi = true;
+
+ @override
+ bool singleCharMode = false;
+
+ @override
+ Stream<String> get keystrokes => const Stream<String>.empty();
+
+ @override
+ Future<String> promptForCharInput(
+ List<String> acceptedCharacters, {
+ Logger? logger,
+ String? prompt,
+ int? defaultChoiceIndex,
+ bool displayAcceptedCharacters = true,
+ }) async {
+ return '1';
+ }
+}
+
+class DummyMachineFlutterCommand extends DummyFlutterCommand {
+ DummyMachineFlutterCommand() : super(name: 'dummy') {
+ addMachineOutputFlag(verboseHelp: false);
+ }
+}
diff --git a/packages/flutter_tools/test/general.shard/runner/target_devices_test.dart b/packages/flutter_tools/test/general.shard/runner/target_devices_test.dart
index 003795c..0d2919b 100644
--- a/packages/flutter_tools/test/general.shard/runner/target_devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/target_devices_test.dart
@@ -677,6 +677,31 @@
expect(deviceManager.androidDiscoverer.numberOfTimesPolled, 1);
}, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+ testUsingContext('does not prompt if canPrompt is false', () async {
+ deviceManager.androidDiscoverer.deviceList = <Device>[
+ attachedAndroidDevice1,
+ attachedAndroidDevice2,
+ ];
+
+ final List<Device>? devices = await targetDevices.findAllTargetDevices(
+ canPrompt: false,
+ );
+
+ expect(
+ logger.statusText,
+ equals('''
+More than one device connected; please specify a device with the '-d <deviceId>' flag, or use '-d all' to act on all devices.
+
+target-device-1 (mobile) • xxx • android • Android 10
+target-device-2 (mobile) • xxx • android • Android 10
+'''),
+ );
+ expect(devices, isNull);
+ expect(deviceManager.androidDiscoverer.devicesCalled, 4);
+ expect(deviceManager.androidDiscoverer.discoverDevicesCalled, 0);
+ expect(deviceManager.androidDiscoverer.numberOfTimesPolled, 1);
+ }, overrides: <Type, Generator>{AnsiTerminal: () => terminal});
+
testUsingContext('including only attached devices', () async {
deviceManager.androidDiscoverer.deviceList = <Device>[
attachedAndroidDevice1,