diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 63316afdfa4..bf42f805699 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -7,7 +7,6 @@ import 'dart:async'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; -import '../base/logger.dart'; import '../base/utils.dart'; import '../cache.dart'; import '../commands/daemon.dart'; @@ -138,34 +137,18 @@ class AttachCommand extends FlutterCommand { if (module == null) { throwToolExit('\'--module\' is requried for attaching to a Fuchsia device'); } - usesIpv6 = _isIpv6(device.id); - final List ports = await device.servicePorts(); - if (ports.isEmpty) { - throwToolExit('No active service ports on ${device.name}'); - } - final List localPorts = []; - for (int port in ports) { - localPorts.add(await device.portForwarder.forward(port)); - } - final Status status = logger.startProgress( - 'Waiting for a connection from Flutter on ${device.name}...', - expectSlowOperation: true, - ); + usesIpv6 = device.ipv6; + FuchsiaIsolateDiscoveryProtocol isolateDiscoveryProtocol; try { - final int localPort = await device.findIsolatePort(module, localPorts); - if (localPort == null) { - throwToolExit('No active Observatory running module \'$module\' on ${device.name}'); - } - observatoryUri = usesIpv6 - ? Uri.parse('http://[$ipv6Loopback]:$localPort/') - : Uri.parse('http://$ipv4Loopback:$localPort/'); - status.stop(); + isolateDiscoveryProtocol = device.getIsolateDiscoveryProtocol(module); + observatoryUri = await isolateDiscoveryProtocol.uri; + printStatus('Done.'); } catch (_) { + isolateDiscoveryProtocol?.dispose(); final List ports = device.portForwarder.forwardedPorts.toList(); for (ForwardedPort port in ports) { await device.portForwarder.unforward(port); } - status.cancel(); rethrow; } } else { @@ -241,17 +224,6 @@ class AttachCommand extends FlutterCommand { } Future _validateArguments() async {} - - bool _isIpv6(String address) { - // Workaround for https://github.com/dart-lang/sdk/issues/29456 - final String fragment = address.split('%').first; - try { - Uri.parseIPv6Address(fragment); - return true; - } on FormatException { - return false; - } - } } class HotRunnerFactory { diff --git a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart index ed48b22fe5a..c93261081b0 100644 --- a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart +++ b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart @@ -9,6 +9,7 @@ import 'package:meta/meta.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/io.dart'; +import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/process_manager.dart'; @@ -24,6 +25,11 @@ import 'fuchsia_workflow.dart'; final String _ipv4Loopback = InternetAddress.loopbackIPv4.address; final String _ipv6Loopback = InternetAddress.loopbackIPv6.address; +// Enables testing the fuchsia isolate discovery +Future _kDefaultFuchsiaIsolateDiscoveryConnector(Uri uri) { + return VMService.connect(uri); +} + /// Read the log for a particular device. class _FuchsiaLogReader extends DeviceLogReader { _FuchsiaLogReader(this._device, [this._app]); @@ -207,6 +213,17 @@ class FuchsiaDevice extends Device { @override bool get supportsScreenshot => false; + bool get ipv6 { + // Workaround for https://github.com/dart-lang/sdk/issues/29456 + final String fragment = id.split('%').first; + try { + Uri.parseIPv6Address(fragment); + return true; + } on FormatException { + return false; + } + } + /// List the ports currently running a dart observatory. Future> servicePorts() async { final String findOutput = await shell('find /hub -name vmservice-port'); @@ -278,6 +295,93 @@ class FuchsiaDevice extends Device { throwToolExit('No ports found running $isolateName'); return null; } + + FuchsiaIsolateDiscoveryProtocol getIsolateDiscoveryProtocol(String isolateName) => FuchsiaIsolateDiscoveryProtocol(this, isolateName); +} + +class FuchsiaIsolateDiscoveryProtocol { + FuchsiaIsolateDiscoveryProtocol(this._device, this._isolateName, [ + this._vmServiceConnector = _kDefaultFuchsiaIsolateDiscoveryConnector, + this._pollOnce = false, + ]); + + static const Duration _pollDuration = Duration(seconds: 10); + final Map _ports = {}; + final FuchsiaDevice _device; + final String _isolateName; + final Completer _foundUri = Completer(); + final Future Function(Uri) _vmServiceConnector; + // whether to only poll once. + final bool _pollOnce; + Timer _pollingTimer; + Status _status; + + FutureOr get uri { + if (_uri != null) { + return _uri; + } + _status ??= logger.startProgress( + 'Waiting for a connection from $_isolateName on ${_device.name}...', + expectSlowOperation: true, + ); + _pollingTimer ??= Timer(_pollDuration, _findIsolate); + return _foundUri.future.then((Uri uri) { + _uri = uri; + return uri; + }); + } + Uri _uri; + + void dispose() { + if (!_foundUri.isCompleted) { + _status?.cancel(); + _status = null; + _pollingTimer?.cancel(); + _pollingTimer = null; + _foundUri.completeError(Exception('Did not complete')); + } + } + + Future _findIsolate() async { + final List ports = await _device.servicePorts(); + for (int port in ports) { + VMService service; + if (_ports.containsKey(port)) { + service = _ports[port]; + } else { + final int localPort = await _device.portForwarder.forward(port); + try { + final Uri uri = Uri.parse('http://[$_ipv6Loopback]:$localPort'); + service = await _vmServiceConnector(uri); + _ports[port] = service; + } on SocketException catch (err) { + printTrace('Failed to connect to $localPort: $err'); + continue; + } + } + await service.getVM(); + await service.refreshViews(); + for (FlutterView flutterView in service.vm.views) { + if (flutterView.uiIsolate == null) { + continue; + } + final Uri address = flutterView.owner.vmService.httpAddress; + if (flutterView.uiIsolate.name.contains(_isolateName)) { + _foundUri.complete(_device.ipv6 + ? Uri.parse('http://[$_ipv6Loopback]:${address.port}/') + : Uri.parse('http://$_ipv4Loopback:${address.port}/')); + _status.stop(); + return; + } + } + } + if (_pollOnce) { + _foundUri.completeError(Exception('Max iterations exceeded')); + _status.stop(); + return; + } + _pollingTimer = Timer(_pollDuration, _findIsolate); + } } class _FuchsiaPortForwarder extends DevicePortForwarder { diff --git a/packages/flutter_tools/test/fuchsia/fuchsa_device_test.dart b/packages/flutter_tools/test/fuchsia/fuchsa_device_test.dart index 51521920422..87ab363118a 100644 --- a/packages/flutter_tools/test/fuchsia/fuchsa_device_test.dart +++ b/packages/flutter_tools/test/fuchsia/fuchsa_device_test.dart @@ -5,6 +5,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/vmservice.dart'; import 'package:mockito/mockito.dart'; import 'package:process/process.dart'; @@ -198,6 +200,65 @@ void main() { }); }); }); + + group(FuchsiaIsolateDiscoveryProtocol, () { + Future findUri(List views, String expectedIsolateName) { + final MockPortForwarder portForwarder = MockPortForwarder(); + final MockVMService vmService = MockVMService(); + final MockVM vm = MockVM(); + vm.vmService = vmService; + vmService.vm = vm; + vm.views = views; + for (MockFlutterView view in views) { + view.owner = vm; + } + final MockFuchsiaDevice fuchsiaDevice = MockFuchsiaDevice('123', portForwarder, false); + final FuchsiaIsolateDiscoveryProtocol discoveryProtocol = FuchsiaIsolateDiscoveryProtocol( + fuchsiaDevice, + expectedIsolateName, + (Uri uri) async => vmService, + true // only poll once. + ); + when(fuchsiaDevice.servicePorts()).thenAnswer((Invocation invocation) async => [1]); + when(portForwarder.forward(1)).thenAnswer((Invocation invocation) async => 2); + when(vmService.getVM()).thenAnswer((Invocation invocation) => Future.value(null)); + when(vmService.refreshViews()).thenAnswer((Invocation invocation) => Future.value(null)); + when(vmService.httpAddress).thenReturn(Uri.parse('example')); + return discoveryProtocol.uri; + } + testUsingContext('can find flutter view with matching isolate name', () async { + const String expectedIsolateName = 'foobar'; + final Uri uri = await findUri([ + MockFlutterView(null), // no ui isolate. + MockFlutterView(MockIsolate('wrong name')), // wrong name. + MockFlutterView(MockIsolate(expectedIsolateName)), // matching name. + ], expectedIsolateName); + expect(uri.toString(), 'http://${InternetAddress.loopbackIPv4.address}:0/'); + }, overrides: { + Logger: () => StdoutLogger(), + }); + + testUsingContext('can handle flutter view without matching isolate name', () async { + const String expectedIsolateName = 'foobar'; + final Future uri = findUri([ + MockFlutterView(null), // no ui isolate. + MockFlutterView(MockIsolate('wrong name')), // wrong name. + ], expectedIsolateName); + expect(uri, throwsException); + }, overrides: { + Logger: () => StdoutLogger(), + }); + + testUsingContext('can handle non flutter view', () async { + const String expectedIsolateName = 'foobar'; + final Future uri = findUri([ + MockFlutterView(null), // no ui isolate. + ], expectedIsolateName); + expect(uri, throwsException); + }, overrides: { + Logger: () => StdoutLogger(), + }); + }); } class MockProcessManager extends Mock implements ProcessManager {} @@ -207,3 +268,46 @@ class MockProcessResult extends Mock implements ProcessResult {} class MockFile extends Mock implements File {} class MockProcess extends Mock implements Process {} + +class MockFuchsiaDevice extends Mock implements FuchsiaDevice { + MockFuchsiaDevice(this.id, this.portForwarder, this.ipv6); + + @override + final bool ipv6; + @override + final String id; + @override + final DevicePortForwarder portForwarder; +} + +class MockPortForwarder extends Mock implements DevicePortForwarder {} + +class MockVMService extends Mock implements VMService { + @override + VM vm; +} + +class MockVM extends Mock implements VM { + @override + VMService vmService; + + @override + List views; +} + +class MockFlutterView extends Mock implements FlutterView { + MockFlutterView(this.uiIsolate); + + @override + final Isolate uiIsolate; + + @override + ServiceObjectOwner owner; +} + +class MockIsolate extends Mock implements Isolate { + MockIsolate(this.name); + + @override + final String name; +}