Add polling module discovery for Fuchsia (#24994)

diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
index 63316af..bf42f80 100644
--- a/packages/flutter_tools/lib/src/commands/attach.dart
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -7,7 +7,6 @@
 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 @@
         if (module == null) {
           throwToolExit('\'--module\' is requried for attaching to a Fuchsia device');
         }
-        usesIpv6 = _isIpv6(device.id);
-        final List<int> ports = await device.servicePorts();
-        if (ports.isEmpty) {
-          throwToolExit('No active service ports on ${device.name}');
-        }
-        final List<int> localPorts = <int>[];
-        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<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList();
           for (ForwardedPort port in ports) {
             await device.portForwarder.unforward(port);
           }
-          status.cancel();
           rethrow;
         }
       } else {
@@ -241,17 +224,6 @@
   }
 
   Future<void> _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 ed48b22..c932610 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 '../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 @@
 final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
 final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;
 
+// Enables testing the fuchsia isolate discovery
+Future<VMService> _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 @@
   @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<List<int>> servicePorts() async {
     final String findOutput = await shell('find /hub -name vmservice-port');
@@ -278,6 +295,93 @@
     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<int, VMService> _ports = <int, VMService>{};
+  final FuchsiaDevice _device;
+  final String _isolateName;
+  final Completer<Uri> _foundUri = Completer<Uri>();
+  final Future<VMService> Function(Uri) _vmServiceConnector;
+  // whether to only poll once.
+  final bool _pollOnce;
+  Timer _pollingTimer;
+  Status _status;
+
+  FutureOr<Uri> 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<void> _findIsolate() async {
+    final List<int> 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 5152192..87ab363 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 @@
       });
     });
   });
+
+  group(FuchsiaIsolateDiscoveryProtocol, () {
+    Future<Uri> findUri(List<MockFlutterView> 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 => <int>[1]);
+      when(portForwarder.forward(1)).thenAnswer((Invocation invocation) async => 2);
+      when(vmService.getVM()).thenAnswer((Invocation invocation) => Future<void>.value(null));
+      when(vmService.refreshViews()).thenAnswer((Invocation invocation) => Future<void>.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>[
+        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: <Type, Generator>{
+      Logger: () => StdoutLogger(),
+    });
+
+    testUsingContext('can handle flutter view without matching isolate name', () async {
+      const String expectedIsolateName = 'foobar';
+      final Future<Uri> uri = findUri(<MockFlutterView>[
+        MockFlutterView(null), // no ui isolate.
+        MockFlutterView(MockIsolate('wrong name')), // wrong name.
+      ], expectedIsolateName);
+      expect(uri, throwsException);
+    }, overrides: <Type, Generator>{
+      Logger: () => StdoutLogger(),
+    });
+
+    testUsingContext('can handle non flutter view', () async {
+      const String expectedIsolateName = 'foobar';
+      final Future<Uri> uri = findUri(<MockFlutterView>[
+        MockFlutterView(null), // no ui isolate.
+      ], expectedIsolateName);
+      expect(uri, throwsException);
+    }, overrides: <Type, Generator>{
+      Logger: () => StdoutLogger(),
+    });
+  });
 }
 
 class MockProcessManager extends Mock implements ProcessManager {}
@@ -207,3 +268,46 @@
 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<FlutterView> 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;
+}