Change iOS device discovery from polling to long-running observation (#58137)
diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index 8c63b66..1e7f0db 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart
@@ -104,6 +104,12 @@ removedItems.forEach(_removedController.add); } + void removeItem(T item) { + if (_items.remove(item)) { + _removedController.add(item); + } + } + /// Close the streams. void dispose() { _addedController.close();
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index 2e6ddc4..6147d96 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -790,18 +790,20 @@ /// Enable device events. Future<void> enable(Map<String, dynamic> args) { + final List<Future<void>> calls = <Future<void>>[]; for (final PollingDeviceDiscovery discoverer in _discoverers) { - discoverer.startPolling(); + calls.add(discoverer.startPolling()); } - return Future<void>.value(); + return Future.wait<void>(calls); } /// Disable device events. - Future<void> disable(Map<String, dynamic> args) { + Future<void> disable(Map<String, dynamic> args) async { + final List<Future<void>> calls = <Future<void>>[]; for (final PollingDeviceDiscovery discoverer in _discoverers) { - discoverer.stopPolling(); + calls.add(discoverer.stopPolling()); } - return Future<void>.value(); + return Future.wait<void>(calls); } /// Forward a host port to a device port.
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index cef0855..6c19930 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart
@@ -82,6 +82,7 @@ platform: globals.platform, xcdevice: globals.xcdevice, iosWorkflow: globals.iosWorkflow, + logger: globals.logger, ), IOSSimulators(iosSimulatorUtils: globals.iosSimulatorUtils), FuchsiaDevices( @@ -289,14 +290,18 @@ static const Duration _pollingTimeout = Duration(seconds: 30); final String name; - ItemListNotifier<Device> _items; + + @protected + @visibleForTesting + ItemListNotifier<Device> deviceNotifier; + Timer _timer; Future<List<Device>> pollingGetDevices({ Duration timeout }); - void startPolling() { + Future<void> startPolling() async { if (_timer == null) { - _items ??= ItemListNotifier<Device>(); + deviceNotifier ??= ItemListNotifier<Device>(); // Make initial population the default, fast polling timeout. _timer = _initTimer(null); } @@ -306,7 +311,7 @@ return Timer(_pollingInterval, () async { try { final List<Device> devices = await pollingGetDevices(timeout: pollingTimeout); - _items.updateWithNewList(devices); + deviceNotifier.updateWithNewList(devices); } on TimeoutException { globals.printTrace('Device poll timed out. Will retry.'); } @@ -315,7 +320,7 @@ }); } - void stopPolling() { + Future<void> stopPolling() async { _timer?.cancel(); _timer = null; } @@ -327,23 +332,23 @@ @override Future<List<Device>> discoverDevices({ Duration timeout }) async { - _items = null; + deviceNotifier = null; return _populateDevices(timeout: timeout); } Future<List<Device>> _populateDevices({ Duration timeout }) async { - _items ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout)); - return _items.items; + deviceNotifier ??= ItemListNotifier<Device>.from(await pollingGetDevices(timeout: timeout)); + return deviceNotifier.items; } Stream<Device> get onAdded { - _items ??= ItemListNotifier<Device>(); - return _items.onAdded; + deviceNotifier ??= ItemListNotifier<Device>(); + return deviceNotifier.onAdded; } Stream<Device> get onRemoved { - _items ??= ItemListNotifier<Device>(); - return _items.onRemoved; + deviceNotifier ??= ItemListNotifier<Device>(); + return deviceNotifier.onRemoved; } void dispose() => stopPolling();
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 51e354d..2d052cd 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -16,6 +16,7 @@ import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; +import '../base/utils.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; @@ -35,14 +36,22 @@ Platform platform, XCDevice xcdevice, IOSWorkflow iosWorkflow, + Logger logger, }) : _platform = platform ?? globals.platform, _xcdevice = xcdevice ?? globals.xcdevice, _iosWorkflow = iosWorkflow ?? globals.iosWorkflow, + _logger = logger ?? globals.logger, super('iOS devices'); + @override + void dispose() { + _observedDeviceEventsSubscription?.cancel(); + } + final Platform _platform; final XCDevice _xcdevice; final IOSWorkflow _iosWorkflow; + final Logger _logger; @override bool get supportsPlatform => _platform.isMacOS; @@ -50,6 +59,60 @@ @override bool get canListAnything => _iosWorkflow.canListDevices; + StreamSubscription<Map<XCDeviceEvent, String>> _observedDeviceEventsSubscription; + + @override + Future<void> startPolling() async { + if (!_platform.isMacOS) { + throw UnsupportedError( + 'Control of iOS devices or simulators only supported on macOS.' + ); + } + + deviceNotifier ??= ItemListNotifier<Device>(); + + // Start by populating all currently attached devices. + deviceNotifier.updateWithNewList(await pollingGetDevices()); + + // cancel any outstanding subscriptions. + await _observedDeviceEventsSubscription?.cancel(); + _observedDeviceEventsSubscription = _xcdevice.observedDeviceEvents().listen( + _onDeviceEvent, + onError: (dynamic error, StackTrace stack) { + _logger.printTrace('Process exception running xcdevice observe:\n$error\n$stack'); + }, onDone: () { + // If xcdevice is killed or otherwise dies, polling will be stopped. + // No retry is attempted and the polling client will have to restart polling + // (restart the IDE). Avoid hammering on a process that is + // continuously failing. + _logger.printTrace('xcdevice observe stopped'); + }, + cancelOnError: true, + ); + } + + Future<void> _onDeviceEvent(Map<XCDeviceEvent, String> event) async { + final XCDeviceEvent eventType = event.containsKey(XCDeviceEvent.attach) ? XCDeviceEvent.attach : XCDeviceEvent.detach; + final String deviceIdentifier = event[eventType]; + final Device knownDevice = deviceNotifier.items + .firstWhere((Device device) => device.id == deviceIdentifier, orElse: () => null); + + // Ignore already discovered devices (maybe populated at the beginning). + if (eventType == XCDeviceEvent.attach && knownDevice == null) { + // There's no way to get details for an individual attached device, + // so repopulate them all. + final List<Device> devices = await pollingGetDevices(); + deviceNotifier.updateWithNewList(devices); + } else if (eventType == XCDeviceEvent.detach && knownDevice != null) { + deviceNotifier.removeItem(knownDevice); + } + } + + @override + Future<void> stopPolling() async { + await _observedDeviceEventsSubscription?.cancel(); + } + @override Future<List<Device>> pollingGetDevices({ Duration timeout }) async { if (!_platform.isMacOS) {
diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index ceb0eb5..191fff8 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart
@@ -194,6 +194,11 @@ } } +enum XCDeviceEvent { + attach, + detach, +} + /// A utility class for interacting with Xcode xcdevice command line tools. class XCDevice { XCDevice({ @@ -218,7 +223,14 @@ platform: platform, processManager: processManager, ), - _xcode = xcode; + _xcode = xcode { + + _setupDeviceIdentifierByEventStream(); + } + + void dispose() { + _deviceObservationProcess?.kill(); + } final ProcessUtils _processUtils; final Logger _logger; @@ -226,6 +238,19 @@ final IOSDeploy _iosDeploy; final Xcode _xcode; + List<dynamic> _cachedListResults; + Process _deviceObservationProcess; + StreamController<Map<XCDeviceEvent, String>> _deviceIdentifierByEvent; + + void _setupDeviceIdentifierByEventStream() { + // _deviceIdentifierByEvent Should always be available for listeners + // in case polling needs to be stopped and restarted. + _deviceIdentifierByEvent = StreamController<Map<XCDeviceEvent, String>>.broadcast( + onListen: _startObservingTetheredIOSDevices, + onCancel: _stopObservingTetheredIOSDevices, + ); + } + bool get isInstalled => _xcode.isInstalledAndMeetsVersionCheck && xcdevicePath != null; String _xcdevicePath; @@ -287,7 +312,99 @@ return null; } - List<dynamic> _cachedListResults; + /// Observe identifiers (UDIDs) of devices as they attach and detach. + /// + /// Each attach and detach event is a tuple of one event type + /// and identifier. + Stream<Map<XCDeviceEvent, String>> observedDeviceEvents() { + if (!isInstalled) { + _logger.printTrace("Xcode not found. Run 'flutter doctor' for more information."); + return null; + } + return _deviceIdentifierByEvent.stream; + } + + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + final RegExp _observationIdentifierPattern = RegExp(r'^(\w*): (\w*)$'); + + Future<void> _startObservingTetheredIOSDevices() async { + try { + if (_deviceObservationProcess != null) { + throw Exception('xcdevice observe restart failed'); + } + + // Run in interactive mode (via script) to convince + // xcdevice it has a terminal attached in order to redirect stdout. + _deviceObservationProcess = await _processUtils.start( + <String>[ + 'script', + '-t', + '0', + '/dev/null', + 'xcrun', + 'xcdevice', + 'observe', + '--both', + ], + ); + + final StreamSubscription<String> stdoutSubscription = _deviceObservationProcess.stdout + .transform<String>(utf8.decoder) + .transform<String>(const LineSplitter()) + .listen((String line) { + + // xcdevice observe example output of UDIDs: + // + // Listening for all devices, on both interfaces. + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Detach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + // Attach: d83d5bc53967baa0ee18626ba87b6254b2ab5418 + final RegExpMatch match = _observationIdentifierPattern.firstMatch(line); + if (match != null && match.groupCount == 2) { + final String verb = match.group(1).toLowerCase(); + final String identifier = match.group(2); + if (verb.startsWith('attach')) { + _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{ + XCDeviceEvent.attach: identifier + }); + } else if (verb.startsWith('detach')) { + _deviceIdentifierByEvent.add(<XCDeviceEvent, String>{ + XCDeviceEvent.detach: identifier + }); + } + } + }); + final StreamSubscription<String> stderrSubscription = _deviceObservationProcess.stderr + .transform<String>(utf8.decoder) + .transform<String>(const LineSplitter()) + .listen((String line) { + _logger.printTrace('xcdevice observe error: $line'); + }); + unawaited(_deviceObservationProcess.exitCode.then((int status) { + _logger.printTrace('xcdevice exited with code $exitCode'); + unawaited(stdoutSubscription.cancel()); + unawaited(stderrSubscription.cancel()); + }).whenComplete(() async { + if (_deviceIdentifierByEvent.hasListener) { + // Tell listeners the process died. + await _deviceIdentifierByEvent.close(); + } + _deviceObservationProcess = null; + + // Reopen it so new listeners can resume polling. + _setupDeviceIdentifierByEventStream(); + })); + } on ProcessException catch (exception, stackTrace) { + _deviceIdentifierByEvent.addError(exception, stackTrace); + } on ArgumentError catch (exception, stackTrace) { + _deviceIdentifierByEvent.addError(exception, stackTrace); + } + } + + void _stopObservingTetheredIOSDevices() { + _deviceObservationProcess?.kill(); + } /// [timeout] defaults to 2 seconds. Future<List<IOSDevice>> getAvailableIOSDevices({ Duration timeout }) async {