Support mdns when attaching to proxied devices. (#146021)

Also move the vm service discovery logic into platform-specific implementation of `Device`s. This is to avoid having platform-specific code in attach.dart.
diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart
index 68f98a6..1715f65 100644
--- a/packages/flutter_tools/lib/src/android/android_device.dart
+++ b/packages/flutter_tools/lib/src/android/android_device.dart
@@ -18,6 +18,7 @@
 import '../convert.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../project.dart';
 import '../protocol_discovery.dart';
 import 'android.dart';
@@ -801,6 +802,26 @@
   }
 
   @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) =>
+      LogScanningVMServiceDiscoveryForAttach(
+        // If it's an Android device, attaching relies on past log searching
+        // to find the service protocol.
+        Future<DeviceLogReader>.value(getLogReader(includePastLogs: true)),
+        portForwarder: portForwarder,
+        ipv6: ipv6,
+        devicePort: filterDevicePort,
+        hostPort: expectedHostPort,
+        logger: logger,
+      );
+
+  @override
   late final DevicePortForwarder? portForwarder = () {
     final String? adbPath = _androidSdk.adbPath;
     if (adbPath == null) {
diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart
index 9dd507b..404b116 100644
--- a/packages/flutter_tools/lib/src/commands/attach.dart
+++ b/packages/flutter_tools/lib/src/commands/attach.dart
@@ -21,13 +21,11 @@
 import '../daemon.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
-import '../fuchsia/fuchsia_device.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../ios/devices.dart';
-import '../ios/simulators.dart';
 import '../macos/macos_ipad_device.dart';
 import '../mdns_discovery.dart';
 import '../project.dart';
-import '../protocol_discovery.dart';
 import '../resident_runner.dart';
 import '../run_cold.dart';
 import '../run_hot.dart';
@@ -286,116 +284,48 @@
       : null;
 
     Stream<Uri>? vmServiceUri;
-    bool usesIpv6 = ipv6!;
+    final bool usesIpv6 = ipv6!;
     final String ipv6Loopback = InternetAddress.loopbackIPv6.address;
     final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
     final String hostname = usesIpv6 ? ipv6Loopback : ipv4Loopback;
     final bool isWirelessIOSDevice = (device is IOSDevice) && device.isWirelesslyConnected;
 
     if ((debugPort == null && debugUri == null) || isWirelessIOSDevice) {
-      if (device is FuchsiaDevice) {
-        final String? module = stringArg('module');
-        if (module == null) {
-          throwToolExit("'--module' is required for attaching to a Fuchsia device");
-        }
-        usesIpv6 = device.ipv6;
-        FuchsiaIsolateDiscoveryProtocol? isolateDiscoveryProtocol;
-        try {
-          isolateDiscoveryProtocol = device.getIsolateDiscoveryProtocol(module);
-          vmServiceUri = Stream<Uri>.value(await isolateDiscoveryProtocol.uri).asBroadcastStream();
-        } on Exception {
-          isolateDiscoveryProtocol?.dispose();
-          final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList();
-          for (final ForwardedPort port in ports) {
-            await device.portForwarder.unforward(port);
+      // The device port we expect to have the debug port be listening
+      final int? devicePort = debugPort ?? debugUri?.port ?? deviceVmservicePort;
+
+      final VMServiceDiscoveryForAttach vmServiceDiscovery = device.getVMServiceDiscoveryForAttach(
+        appId: appId,
+        fuchsiaModule: stringArg('module'),
+        filterDevicePort: devicePort,
+        expectedHostPort: hostVmservicePort,
+        ipv6: usesIpv6,
+        logger: _logger,
+      );
+
+      _logger.printStatus('Waiting for a connection from Flutter on ${device.name}...');
+      final Status discoveryStatus = _logger.startSpinner(
+        timeout: const Duration(seconds: 30),
+        slowWarningCallback: () {
+          // On iOS we rely on mDNS to find Dart VM Service. Remind the user to allow local network permissions on the device.
+          if (_isIOSDevice(device)) {
+            return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n\n'
+              'Click "Allow" to the prompt on your device asking if you would like to find and connect devices on your local network. '
+              'If you selected "Don\'t Allow", you can turn it on in Settings > Your App Name > Local Network. '
+              "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.\n";
           }
-          rethrow;
-        }
-      } else if (_isIOSDevice(device)) {
-        // Protocol Discovery relies on logging. On iOS earlier than 13, logging is gathered using syslog.
-        // syslog is not available for iOS 13+. For iOS 13+, Protocol Discovery gathers logs from the VMService.
-        // Since we don't have access to the VMService yet, Protocol Discovery cannot be used for iOS 13+.
-        // Also, wireless devices must be found using mDNS and cannot use Protocol Discovery.
-        final bool compatibleWithProtocolDiscovery = (device is IOSDevice) &&
-          device.majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion &&
-          !isWirelessIOSDevice;
 
-        _logger.printStatus('Waiting for a connection from Flutter on ${device.name}...');
-        final Status discoveryStatus = _logger.startSpinner(
-          timeout: const Duration(seconds: 30),
-          slowWarningCallback: () {
-            // If relying on mDNS to find Dart VM Service, remind the user to allow local network permissions.
-            if (!compatibleWithProtocolDiscovery) {
-              return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n\n'
-                'Click "Allow" to the prompt asking if you would like to find and connect devices on your local network. '
-                'If you selected "Don\'t Allow", you can turn it on in Settings > Your App Name > Local Network. '
-                "If you don't see your app in the Settings, uninstall the app and rerun to see the prompt again.\n";
-            }
+          return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n';
+        },
+      );
 
-            return 'The Dart VM Service was not discovered after 30 seconds. This is taking much longer than expected...\n';
-          },
-        );
+      vmServiceUri = vmServiceDiscovery.uris;
 
-        int? devicePort;
-        if (debugPort != null) {
-          devicePort = debugPort;
-        } else if (debugUri != null) {
-          devicePort = debugUri?.port;
-        } else if (deviceVmservicePort != null) {
-          devicePort = deviceVmservicePort;
-        }
-
-        final Future<Uri?> mDNSDiscoveryFuture = MDnsVmServiceDiscovery.instance!.getVMServiceUriForAttach(
-          appId,
-          device,
-          usesIpv6: usesIpv6,
-          useDeviceIPAsHost: isWirelessIOSDevice,
-          deviceVmservicePort: devicePort,
-        );
-
-        Future<Uri?>? protocolDiscoveryFuture;
-        if (compatibleWithProtocolDiscovery) {
-          final ProtocolDiscovery vmServiceDiscovery = ProtocolDiscovery.vmService(
-            device.getLogReader(),
-            portForwarder: device.portForwarder,
-            ipv6: ipv6!,
-            devicePort: devicePort,
-            hostPort: hostVmservicePort,
-            logger: _logger,
-          );
-          protocolDiscoveryFuture = vmServiceDiscovery.uri;
-        }
-
-        final Uri? foundUrl;
-        if (protocolDiscoveryFuture == null) {
-          foundUrl = await mDNSDiscoveryFuture;
-        } else {
-          foundUrl = await Future.any(
-            <Future<Uri?>>[mDNSDiscoveryFuture, protocolDiscoveryFuture]
-          );
-        }
+      // Stop the timer once we receive the first uri.
+      vmServiceUri = vmServiceUri.map((Uri uri) {
         discoveryStatus.stop();
-
-        vmServiceUri = foundUrl == null
-          ? null
-          : Stream<Uri>.value(foundUrl).asBroadcastStream();
-      }
-      // If MDNS discovery fails or we're not on iOS, fallback to ProtocolDiscovery.
-      if (vmServiceUri == null) {
-        final ProtocolDiscovery vmServiceDiscovery =
-          ProtocolDiscovery.vmService(
-            // If it's an Android device, attaching relies on past log searching
-            // to find the service protocol.
-            await device.getLogReader(includePastLogs: device is AndroidDevice),
-            portForwarder: device.portForwarder,
-            ipv6: ipv6!,
-            devicePort: deviceVmservicePort,
-            hostPort: hostVmservicePort,
-            logger: _logger,
-          );
-        _logger.printStatus('Waiting for a connection from Flutter on ${device.name}...');
-        vmServiceUri = vmServiceDiscovery.uris;
-      }
+        return uri;
+      });
     } else {
       vmServiceUri = Stream<Uri>
         .fromFuture(
@@ -559,8 +489,7 @@
   Future<void> _validateArguments() async { }
 
   bool _isIOSDevice(Device device) {
-    return (device is IOSDevice) ||
-        (device is IOSSimulator) ||
+    return (device.platformType == PlatformType.ios) ||
         (device is MacOSDesignedForIPadDevice);
   }
 }
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index d74f0ae..e020b11 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -21,6 +21,7 @@
 import '../daemon.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../emulator.dart';
 import '../features.dart';
 import '../globals.dart' as globals;
@@ -1009,6 +1010,8 @@
     registerHandler('shutdownDartDevelopmentService', shutdownDartDevelopmentService);
     registerHandler('setExternalDevToolsUriForDartDevelopmentService', setExternalDevToolsUriForDartDevelopmentService);
     registerHandler('getDiagnostics', getDiagnostics);
+    registerHandler('startVMServiceDiscoveryForAttach', startVMServiceDiscoveryForAttach);
+    registerHandler('stopVMServiceDiscoveryForAttach', stopVMServiceDiscoveryForAttach);
 
     // Use the device manager discovery so that client provided device types
     // are usable via the daemon protocol.
@@ -1325,6 +1328,41 @@
         ...diagnostics,
     ];
   }
+
+  final Map<String, StreamSubscription<Uri>> _vmServiceDiscoverySubscriptions = <String, StreamSubscription<Uri>>{};
+
+  Future<String> startVMServiceDiscoveryForAttach(Map<String, Object?> args) async {
+    final String? deviceId = _getStringArg(args, 'deviceId', required: true);
+    final String? appId = _getStringArg(args, 'appId');
+    final String? fuchsiaModule = _getStringArg(args, 'fuchsiaModule');
+    final int? filterDevicePort = _getIntArg(args, 'filterDevicePort');
+    final bool? ipv6 = _getBoolArg(args, 'ipv6');
+
+    final Device? device = await daemon.deviceDomain._getDevice(deviceId);
+    if (device == null) {
+      throw DaemonException("device '$deviceId' not found");
+    }
+
+    final String id = '${_id++}';
+
+    final VMServiceDiscoveryForAttach discovery = device.getVMServiceDiscoveryForAttach(
+      appId: appId,
+      fuchsiaModule: fuchsiaModule,
+      filterDevicePort: filterDevicePort,
+      ipv6: ipv6 ?? false,
+      logger: globals.logger
+    );
+    _vmServiceDiscoverySubscriptions[id] = discovery.uris.listen(
+      (Uri uri) => sendEvent('device.VMServiceDiscoveryForAttach.$id', uri.toString()),
+    );
+
+    return id;
+  }
+
+  Future<void> stopVMServiceDiscoveryForAttach(Map<String, Object?> args) async {
+    final String? id = _getStringArg(args, 'id', required: true);
+    await _vmServiceDiscoverySubscriptions.remove(id)?.cancel();
+  }
 }
 
 class DevToolsDomain extends Domain {
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index c3469bb..96006cd 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -16,6 +16,7 @@
 import 'build_info.dart';
 import 'devfs.dart';
 import 'device_port_forwarder.dart';
+import 'device_vm_service_discovery_for_attach.dart';
 import 'project.dart';
 import 'vmservice.dart';
 import 'web/compile.dart';
@@ -737,6 +738,35 @@
   /// Clear the device's logs.
   void clearLogs();
 
+  /// Get the [VMServiceDiscoveryForAttach] instance for this device, which
+  /// discovers, and forwards any necessary ports to the vm service uri of a
+  /// running app on the device.
+  ///
+  /// If `appId` is specified, on supported platforms, the service discovery
+  /// will only return the VM service URI from the given app.
+  ///
+  /// If `fuchsiaModule` is specified, this will only return the VM service uri
+  /// from the specified Fuchsia module.
+  ///
+  /// If `filterDevicePort` is specified, this will only return the VM service
+  /// uri that matches the given port on the device.
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) =>
+      LogScanningVMServiceDiscoveryForAttach(
+        Future<DeviceLogReader>.value(getLogReader()),
+        portForwarder: portForwarder,
+        devicePort: filterDevicePort,
+        hostPort: expectedHostPort,
+        ipv6: ipv6,
+        logger: logger,
+      );
+
   /// Start an app package on the current device.
   ///
   /// [platformArgs] allows callers to pass platform-specific arguments to the
diff --git a/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart
new file mode 100644
index 0000000..5b4920b
--- /dev/null
+++ b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart
@@ -0,0 +1,110 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:async/async.dart';
+
+import 'base/logger.dart';
+import 'device.dart';
+import 'device_port_forwarder.dart';
+import 'mdns_discovery.dart';
+import 'protocol_discovery.dart';
+
+/// Discovers the VM service uri on a device, and forwards the port to the host.
+///
+/// This is mainly used during a `flutter attach`.
+abstract class VMServiceDiscoveryForAttach {
+  VMServiceDiscoveryForAttach();
+
+  /// The discovered VM service URis.
+  ///
+  /// Port forwarding is only attempted when this is invoked, for each VM
+  /// Service URI in the stream.
+  Stream<Uri> get uris;
+}
+
+/// An implementation of [VMServiceDiscoveryForAttach] that uses log scanning
+/// for the discovery.
+class LogScanningVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach {
+  LogScanningVMServiceDiscoveryForAttach(
+    Future<DeviceLogReader> logReader, {
+    DevicePortForwarder? portForwarder,
+    int? hostPort,
+    int? devicePort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    _protocolDiscovery = (() async => ProtocolDiscovery.vmService(
+      await logReader,
+      portForwarder: portForwarder,
+      ipv6: ipv6,
+      devicePort: devicePort,
+      hostPort: hostPort,
+      logger: logger,
+    ))();
+  }
+
+  late final Future<ProtocolDiscovery> _protocolDiscovery;
+
+  @override
+  Stream<Uri> get uris {
+    final StreamController<Uri> controller = StreamController<Uri>();
+    _protocolDiscovery.then(
+      (ProtocolDiscovery protocolDiscovery) async {
+        await controller.addStream(protocolDiscovery.uris);
+        await controller.close();
+      },
+      onError: (Object error) => controller.addError(error),
+    );
+    return controller.stream;
+  }
+}
+
+/// An implementation of [VMServiceDiscoveryForAttach] that uses mdns for the
+/// discovery.
+class MdnsVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach {
+  MdnsVMServiceDiscoveryForAttach({
+    required this.device,
+    this.appId,
+    required this.usesIpv6,
+    required this.useDeviceIPAsHost,
+    this.deviceVmservicePort,
+    this.hostVmservicePort,
+  });
+
+  final Device device;
+  final String? appId;
+  final bool usesIpv6;
+  final bool useDeviceIPAsHost;
+  final int? deviceVmservicePort;
+  final int? hostVmservicePort;
+
+  @override
+  Stream<Uri> get uris {
+    final Future<Uri?> mDNSDiscoveryFuture = MDnsVmServiceDiscovery.instance!.getVMServiceUriForAttach(
+      appId,
+      device,
+      usesIpv6: usesIpv6,
+      useDeviceIPAsHost: useDeviceIPAsHost,
+      deviceVmservicePort: deviceVmservicePort,
+      hostVmservicePort: hostVmservicePort,
+    );
+
+    return Stream<Uri?>.fromFuture(mDNSDiscoveryFuture).where((Uri? uri) => uri != null).cast<Uri>().asBroadcastStream();
+  }
+}
+
+/// An implementation of [VMServiceDiscoveryForAttach] that delegates to other
+/// [VMServiceDiscoveryForAttach] instances for discovery.
+class DelegateVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach {
+  DelegateVMServiceDiscoveryForAttach(this.delegates);
+
+  final List<VMServiceDiscoveryForAttach> delegates;
+
+  @override
+  Stream<Uri> get uris =>
+      StreamGroup.merge<Uri>(
+        delegates.map((VMServiceDiscoveryForAttach delegate) => delegate.uris));
+}
diff --git a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart
index cddf8bd..5da81c7 100644
--- a/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart
+++ b/packages/flutter_tools/lib/src/fuchsia/fuchsia_device.dart
@@ -21,6 +21,7 @@
 import '../build_info.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../globals.dart' as globals;
 import '../project.dart';
 import '../runner/flutter_command.dart';
@@ -595,6 +596,24 @@
   @override
   void clearLogs() {}
 
+  @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    if (fuchsiaModule == null) {
+      throwToolExit("'--module' is required for attaching to a Fuchsia device");
+    }
+    if (expectedHostPort != null) {
+      throwToolExit("'--host-vmservice-port' is not supported when attaching to a Fuchsia device");
+    }
+    return FuchsiaIsolateVMServiceDiscoveryForAttach(getIsolateDiscoveryProtocol(fuchsiaModule));
+  }
+
   /// [true] if the current host address is IPv6.
   late final bool ipv6 = isIPv6Address(id);
 
@@ -739,6 +758,30 @@
   }
 }
 
+class FuchsiaIsolateVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach {
+  FuchsiaIsolateVMServiceDiscoveryForAttach(this.isolateDiscoveryProtocol);
+  final FuchsiaIsolateDiscoveryProtocol isolateDiscoveryProtocol;
+
+  @override
+  Stream<Uri> get uris {
+    final Future<Uri> uriFuture = (() async {
+      // Wrapping the call in an anonymous async function for easier error handling.
+      try {
+        return await isolateDiscoveryProtocol.uri;
+      } on Exception {
+        final FuchsiaDevice device = isolateDiscoveryProtocol._device;
+        isolateDiscoveryProtocol.dispose();
+        final List<ForwardedPort> ports = device.portForwarder.forwardedPorts.toList();
+        for (final ForwardedPort port in ports) {
+          await device.portForwarder.unforward(port);
+        }
+        rethrow;
+      }
+    })();
+    return Stream<Uri>.fromFuture(uriFuture).asBroadcastStream();
+  }
+}
+
 class FuchsiaIsolateDiscoveryProtocol {
   FuchsiaIsolateDiscoveryProtocol(
     this._device,
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index bdf55e1..ad462a7 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -22,6 +22,7 @@
 import '../convert.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../globals.dart' as globals;
 import '../macos/xcdevice.dart';
 import '../mdns_discovery.dart';
@@ -1027,6 +1028,43 @@
   void clearLogs() { }
 
   @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    final bool compatibleWithProtocolDiscovery = majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion &&
+          !isWirelesslyConnected;
+    final MdnsVMServiceDiscoveryForAttach mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
+      device: this,
+      appId: appId,
+      deviceVmservicePort: filterDevicePort,
+      hostVmservicePort: expectedHostPort,
+      usesIpv6: ipv6,
+      useDeviceIPAsHost: isWirelesslyConnected,
+    );
+
+    if (compatibleWithProtocolDiscovery) {
+      return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
+        mdnsVMServiceDiscoveryForAttach,
+        super.getVMServiceDiscoveryForAttach(
+          appId: appId,
+          fuchsiaModule: fuchsiaModule,
+          filterDevicePort: filterDevicePort,
+          expectedHostPort: expectedHostPort,
+          ipv6: ipv6,
+          logger: logger,
+        ),
+      ]);
+    } else {
+      return mdnsVMServiceDiscoveryForAttach;
+    }
+  }
+
+  @override
   bool get supportsScreenshot {
     if (isCoreDevice) {
       // `idevicescreenshot` stopped working with iOS 17 / Xcode 15
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 2ee83a3..9a1d0cf 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -21,6 +21,7 @@
 import '../devfs.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../globals.dart' as globals;
 import '../macos/xcode.dart';
 import '../project.dart';
@@ -655,6 +656,37 @@
   }
 
   @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    final MdnsVMServiceDiscoveryForAttach mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
+      device: this,
+      appId: appId,
+      deviceVmservicePort: filterDevicePort,
+      hostVmservicePort: expectedHostPort,
+      usesIpv6: ipv6,
+      useDeviceIPAsHost: false,
+    );
+
+    return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
+      mdnsVMServiceDiscoveryForAttach,
+      super.getVMServiceDiscoveryForAttach(
+        appId: appId,
+        fuchsiaModule: fuchsiaModule,
+        filterDevicePort: filterDevicePort,
+        expectedHostPort: expectedHostPort,
+        ipv6: ipv6,
+        logger: logger,
+      ),
+    ]);
+  }
+
+  @override
   bool get supportsScreenshot => true;
 
   @override
diff --git a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart
index cb34310..e61cc88 100644
--- a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart
+++ b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart
@@ -14,6 +14,7 @@
 import '../build_info.dart';
 import '../desktop_device.dart';
 import '../device.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../ios/ios_workflow.dart';
 import '../project.dart';
 
@@ -60,6 +61,37 @@
   String? executablePathForDevice(ApplicationPackage package, BuildInfo buildInfo) => null;
 
   @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    final MdnsVMServiceDiscoveryForAttach mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
+      device: this,
+      appId: appId,
+      deviceVmservicePort: filterDevicePort,
+      hostVmservicePort: expectedHostPort,
+      usesIpv6: ipv6,
+      useDeviceIPAsHost: false,
+    );
+
+    return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
+      mdnsVMServiceDiscoveryForAttach,
+      super.getVMServiceDiscoveryForAttach(
+        appId: appId,
+        fuchsiaModule: fuchsiaModule,
+        filterDevicePort: filterDevicePort,
+        expectedHostPort: expectedHostPort,
+        ipv6: ipv6,
+        logger: logger,
+      ),
+    ]);
+  }
+
+  @override
   Future<LaunchResult> startApp(
     ApplicationPackage? package, {
     String? mainPath,
diff --git a/packages/flutter_tools/lib/src/proxied_devices/devices.dart b/packages/flutter_tools/lib/src/proxied_devices/devices.dart
index 25f2cf4..4b4a9403 100644
--- a/packages/flutter_tools/lib/src/proxied_devices/devices.dart
+++ b/packages/flutter_tools/lib/src/proxied_devices/devices.dart
@@ -17,6 +17,7 @@
 import '../daemon.dart';
 import '../device.dart';
 import '../device_port_forwarder.dart';
+import '../device_vm_service_discovery_for_attach.dart';
 import '../project.dart';
 import 'debounce_data_stream.dart';
 import 'file_transfer.dart';
@@ -297,6 +298,35 @@
   void clearLogs() => throw UnimplementedError();
 
   @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) =>
+      ProxiedVMServiceDiscoveryForAttach(
+        connection,
+        id,
+        proxiedPortForwarder: proxiedPortForwarder,
+        appId: appId,
+        fuchsiaModule: fuchsiaModule,
+        filterDevicePort: filterDevicePort,
+        expectedHostPort: expectedHostPort,
+        ipv6: ipv6,
+        logger: logger,
+        fallbackDiscovery: () => super.getVMServiceDiscoveryForAttach(
+          appId: appId,
+          fuchsiaModule: fuchsiaModule,
+          filterDevicePort: filterDevicePort,
+          expectedHostPort: expectedHostPort,
+          ipv6: ipv6,
+          logger: logger,
+        ),
+      );
+
+  @override
   Future<LaunchResult> startApp(
     PrebuiltApplicationPackage package, {
     String? mainPath,
@@ -863,3 +893,84 @@
     });
   }
 }
+
+class ProxiedVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach {
+  ProxiedVMServiceDiscoveryForAttach(
+    this.connection,
+    this.deviceId, {
+    required this.proxiedPortForwarder,
+    required this.fallbackDiscovery,
+    this.appId,
+    this.fuchsiaModule,
+    this.filterDevicePort,
+    this.expectedHostPort,
+    required this.ipv6,
+    required this.logger,
+  });
+
+  /// [DaemonConnection] used to communicate with the daemon.
+  final DaemonConnection connection;
+
+  final String deviceId;
+
+  final String? appId;
+  final String? fuchsiaModule;
+  final int? filterDevicePort;
+  final int? expectedHostPort;
+  final bool ipv6;
+  final Logger logger;
+
+  final ProxiedPortForwarder proxiedPortForwarder;
+
+  VMServiceDiscoveryForAttach Function() fallbackDiscovery;
+
+  Stream<Uri>? _uris;
+
+  @override
+  Stream<Uri> get uris {
+    if (_uris == null) {
+      String? requestId;
+      final StreamController<Uri> controller = StreamController<Uri>();
+
+      controller.onListen = () {
+        connection.sendRequest('device.startVMServiceDiscoveryForAttach', <String, Object?>{
+          'deviceId': deviceId,
+          'appId': appId,
+          'fuchsiaModule': fuchsiaModule,
+          'filterDevicePort': filterDevicePort,
+          'ipv6': ipv6,
+        }).then(
+          (Object? response) async {
+            requestId = _cast<String>(response);
+            final Stream<Uri> vmService = connection
+                .listenToEvent('device.VMServiceDiscoveryForAttach.$requestId')
+                .asyncMap((DaemonEventData event) async {
+              // Forward the port.
+              final Uri remoteUri = Uri.parse(_cast<String>(event.data));
+              final int port = remoteUri.port;
+              final int localPort = await proxiedPortForwarder.forward(port, hostPort: expectedHostPort, ipv6: ipv6);
+              return remoteUri.replace(port: localPort);
+            });
+            await controller.addStream(vmService);
+          },
+          onError: (Object e) {
+            // Daemon throws string types.
+            if (e is String && e.contains('command not understood')) {
+              // Use a fallback if the daemon does not support VM service discovery.
+              controller.addStream(fallbackDiscovery().uris);
+            } else {
+              controller.addError(e);
+            }
+          },
+        );
+      };
+      controller.onCancel = () {
+        if (requestId != null) {
+          connection.sendRequest('device.stopVMServiceDiscoveryForAttach', <String, Object?>{'id': requestId});
+        }
+      };
+      _uris = controller.stream;
+    }
+    return _uris!;
+  }
+}
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
index ca9c45d..dfd23e7 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
@@ -22,6 +22,7 @@
 import 'package:flutter_tools/src/compile.dart';
 import 'package:flutter_tools/src/device.dart';
 import 'package:flutter_tools/src/device_port_forwarder.dart';
+import 'package:flutter_tools/src/device_vm_service_discovery_for_attach.dart';
 import 'package:flutter_tools/src/ios/application_package.dart';
 import 'package:flutter_tools/src/ios/devices.dart';
 import 'package:flutter_tools/src/mdns_discovery.dart';
@@ -204,8 +205,8 @@
           processInfo: processInfo,
           fileSystem: testFileSystem,
         )).run(<String>['attach']);
+        await completer.future;
         await Future.wait<void>(<Future<void>>[
-          completer.future,
           fakeLogReader.dispose(),
           loggerSubscription.cancel(),
         ]);
@@ -275,8 +276,8 @@
           processInfo: processInfo,
           fileSystem: testFileSystem,
         )).run(<String>['attach', '--local-engine-src-path=$localEngineSrc', '--local-engine=$localEngineDir', '--local-engine-host=$localEngineDir']);
+        await completer.future;
         await Future.wait<void>(<Future<void>>[
-          completer.future,
           fakeLogReader.dispose(),
           loggerSubscription.cancel(),
         ]);
@@ -331,12 +332,15 @@
         )).run(<String>['attach']);
         await fakeLogReader.dispose();
 
-        expect(portForwarder.devicePort, devicePort);
-        expect(portForwarder.hostPort, hostPort);
-        expect(hotRunnerFactory.devices, hasLength(1));
+        // Listen to the URI before checking port forwarder. Port forwarding
+        // is done as a side effect when generating the uri.
         final FlutterDevice flutterDevice = hotRunnerFactory.devices.first;
         final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first;
         expect(vmServiceUri.toString(), 'http://127.0.0.1:$hostPort/xyz/');
+
+        expect(portForwarder.devicePort, devicePort);
+        expect(portForwarder.hostPort, hostPort);
+        expect(hotRunnerFactory.devices, hasLength(1));
       }, overrides: <Type, Generator>{
         FileSystem: () => testFileSystem,
         ProcessManager: () => FakeProcessManager.any(),
@@ -396,13 +400,15 @@
         )).run(<String>['attach']);
         await fakeLogReader.dispose();
 
-        expect(portForwarder.devicePort, null);
-        expect(portForwarder.hostPort, hostPort);
-        expect(hotRunnerFactory.devices, hasLength(1));
-
+        // Listen to the URI before checking port forwarder. Port forwarding
+        // is done as a side effect when generating the uri.
         final FlutterDevice flutterDevice = hotRunnerFactory.devices.first;
         final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first;
         expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/');
+
+        expect(portForwarder.devicePort, null);
+        expect(portForwarder.hostPort, hostPort);
+        expect(hotRunnerFactory.devices, hasLength(1));
       }, overrides: <Type, Generator>{
         FileSystem: () => testFileSystem,
         ProcessManager: () => FakeProcessManager.any(),
@@ -467,13 +473,15 @@
         )).run(<String>['attach', '--debug-port', '123']);
         await fakeLogReader.dispose();
 
-        expect(portForwarder.devicePort, null);
-        expect(portForwarder.hostPort, hostPort);
-        expect(hotRunnerFactory.devices, hasLength(1));
-
+        // Listen to the URI before checking port forwarder. Port forwarding
+        // is done as a side effect when generating the uri.
         final FlutterDevice flutterDevice = hotRunnerFactory.devices.first;
         final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first;
         expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/');
+
+        expect(portForwarder.devicePort, null);
+        expect(portForwarder.hostPort, hostPort);
+        expect(hotRunnerFactory.devices, hasLength(1));
       }, overrides: <Type, Generator>{
         FileSystem: () => testFileSystem,
         ProcessManager: () => FakeProcessManager.any(),
@@ -542,13 +550,15 @@
         )).run(<String>['attach', '--debug-url', 'https://0.0.0.0:123']);
         await fakeLogReader.dispose();
 
-        expect(portForwarder.devicePort, null);
-        expect(portForwarder.hostPort, hostPort);
-        expect(hotRunnerFactory.devices, hasLength(1));
-
+        // Listen to the URI before checking port forwarder. Port forwarding
+        // is done as a side effect when generating the uri.
         final FlutterDevice flutterDevice = hotRunnerFactory.devices.first;
         final Uri? vmServiceUri = await flutterDevice.vmServiceUris?.first;
         expect(vmServiceUri.toString(), 'http://111.111.111.111:123/xyz/');
+
+        expect(portForwarder.devicePort, null);
+        expect(portForwarder.hostPort, hostPort);
+        expect(hotRunnerFactory.devices, hasLength(1));
       }, overrides: <Type, Generator>{
         FileSystem: () => testFileSystem,
         ProcessManager: () => FakeProcessManager.any(),
@@ -1464,6 +1474,24 @@
 
   @override
   bool get ephemeral => true;
+
+  @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) =>
+      LogScanningVMServiceDiscoveryForAttach(
+        Future<DeviceLogReader>.value(getLogReader()),
+        portForwarder: portForwarder,
+        devicePort: filterDevicePort,
+        hostPort: expectedHostPort,
+        ipv6: ipv6,
+        logger: logger,
+      );
 }
 
 class FakeIOSDevice extends Fake implements IOSDevice {
@@ -1527,6 +1555,43 @@
 
   @override
   bool get ephemeral => true;
+
+  @override
+  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
+    String? appId,
+    String? fuchsiaModule,
+    int? filterDevicePort,
+    int? expectedHostPort,
+    required bool ipv6,
+    required Logger logger,
+  }) {
+    final bool compatibleWithProtocolDiscovery = majorSdkVersion < IOSDeviceLogReader.minimumUniversalLoggingSdkVersion &&
+          !isWirelesslyConnected;
+    final MdnsVMServiceDiscoveryForAttach mdnsVMServiceDiscoveryForAttach = MdnsVMServiceDiscoveryForAttach(
+      device: this,
+      appId: appId,
+      deviceVmservicePort: filterDevicePort,
+      hostVmservicePort: expectedHostPort,
+      usesIpv6: ipv6,
+      useDeviceIPAsHost: isWirelesslyConnected,
+    );
+
+    if (compatibleWithProtocolDiscovery) {
+      return DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[
+        mdnsVMServiceDiscoveryForAttach,
+        LogScanningVMServiceDiscoveryForAttach(
+          Future<DeviceLogReader>.value(getLogReader()),
+          portForwarder: portForwarder,
+          devicePort: filterDevicePort,
+          hostPort: expectedHostPort,
+          ipv6: ipv6,
+          logger: logger,
+        ),
+      ]);
+    } else {
+      return mdnsVMServiceDiscoveryForAttach;
+    }
+  }
 }
 
 class FakeMDnsClient extends Fake implements MDnsClient {
diff --git a/packages/flutter_tools/test/general.shard/device_vm_service_discovery_for_attach_test.dart b/packages/flutter_tools/test/general.shard/device_vm_service_discovery_for_attach_test.dart
new file mode 100644
index 0000000..598a660
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/device_vm_service_discovery_for_attach_test.dart
@@ -0,0 +1,132 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_tools/src/base/logger.dart';
+import 'package:flutter_tools/src/device_port_forwarder.dart';
+import 'package:flutter_tools/src/device_vm_service_discovery_for_attach.dart';
+import 'package:test/fake.dart';
+
+import '../src/common.dart';
+import '../src/fake_devices.dart';
+
+void main() {
+  group('LogScanningVMServiceDiscoveryForAttach', () {
+    testWithoutContext('can discover the port', () async {
+      final FakeDeviceLogReader logReader = FakeDeviceLogReader();
+      final LogScanningVMServiceDiscoveryForAttach discovery = LogScanningVMServiceDiscoveryForAttach(
+        Future<FakeDeviceLogReader>.value(logReader),
+        ipv6: false,
+        logger: BufferLogger.test(),
+      );
+
+      logReader.addLine('The Dart VM service is listening on http://127.0.0.1:9999');
+
+      expect(await discovery.uris.first, Uri.parse('http://127.0.0.1:9999'));
+    });
+
+    testWithoutContext('ignores the port that does not match devicePort', () async {
+      final FakeDeviceLogReader logReader = FakeDeviceLogReader();
+      final LogScanningVMServiceDiscoveryForAttach discovery = LogScanningVMServiceDiscoveryForAttach(
+        Future<FakeDeviceLogReader>.value(logReader),
+        devicePort: 9998,
+        ipv6: false,
+        logger: BufferLogger.test(),
+      );
+
+      logReader.addLine('The Dart VM service is listening on http://127.0.0.1:9999');
+      logReader.addLine('The Dart VM service is listening on http://127.0.0.1:9998');
+
+      expect(await discovery.uris.first, Uri.parse('http://127.0.0.1:9998'));
+    });
+
+    testWithoutContext('forwards the port if given a port forwarder', () async {
+      final FakeDeviceLogReader logReader = FakeDeviceLogReader();
+      final FakePortForwarder portForwarder = FakePortForwarder(9900);
+      final LogScanningVMServiceDiscoveryForAttach discovery = LogScanningVMServiceDiscoveryForAttach(
+        Future<FakeDeviceLogReader>.value(logReader),
+        portForwarder: portForwarder,
+        ipv6: false,
+        logger: BufferLogger.test(),
+      );
+
+      logReader.addLine('The Dart VM service is listening on http://127.0.0.1:9999');
+
+      expect(await discovery.uris.first, Uri.parse('http://127.0.0.1:9900'));
+      expect(portForwarder.forwardDevicePort, 9999);
+      expect(portForwarder.forwardHostPort, null);
+    });
+
+    testWithoutContext('uses the host port if given', () async {
+      final FakeDeviceLogReader logReader = FakeDeviceLogReader();
+      final FakePortForwarder portForwarder = FakePortForwarder(9900);
+      final LogScanningVMServiceDiscoveryForAttach discovery = LogScanningVMServiceDiscoveryForAttach(
+        Future<FakeDeviceLogReader>.value(logReader),
+        portForwarder: portForwarder,
+        hostPort: 9901,
+        ipv6: false,
+        logger: BufferLogger.test(),
+      );
+
+      logReader.addLine('The Dart VM service is listening on http://127.0.0.1:9999');
+
+      expect(await discovery.uris.first, Uri.parse('http://127.0.0.1:9900'));
+      expect(portForwarder.forwardDevicePort, 9999);
+      expect(portForwarder.forwardHostPort, 9901);
+    });
+  });
+
+  group('DelegateVMServiceDiscoveryForAttach', () {
+    late List<Uri> uris1;
+    late List<Uri> uris2;
+    late FakeVmServiceDiscoveryForAttach fakeDiscovery1;
+    late FakeVmServiceDiscoveryForAttach fakeDiscovery2;
+    late DelegateVMServiceDiscoveryForAttach delegateDiscovery;
+
+    setUp(() {
+      uris1 = <Uri>[];
+      uris2 = <Uri>[];
+      fakeDiscovery1 = FakeVmServiceDiscoveryForAttach(uris1);
+      fakeDiscovery2 = FakeVmServiceDiscoveryForAttach(uris2);
+      delegateDiscovery = DelegateVMServiceDiscoveryForAttach(<VMServiceDiscoveryForAttach>[fakeDiscovery1, fakeDiscovery2]);
+    });
+
+    testWithoutContext('uris returns from both delegates', () async {
+      uris1.add(Uri.parse('http://127.0.0.1:1'));
+      uris1.add(Uri.parse('http://127.0.0.2:2'));
+      uris2.add(Uri.parse('http://127.0.0.3:3'));
+      uris2.add(Uri.parse('http://127.0.0.4:4'));
+
+      expect(await delegateDiscovery.uris.toList(), unorderedEquals(<Uri>[
+        Uri.parse('http://127.0.0.1:1'),
+        Uri.parse('http://127.0.0.2:2'),
+        Uri.parse('http://127.0.0.3:3'),
+        Uri.parse('http://127.0.0.4:4'),
+      ]));
+    });
+  });
+}
+
+class FakePortForwarder extends Fake implements DevicePortForwarder {
+  FakePortForwarder(this.forwardReturnValue);
+
+  int? forwardDevicePort;
+  int? forwardHostPort;
+  final int forwardReturnValue;
+
+  @override
+  Future<int> forward(int devicePort, { int? hostPort }) async {
+    forwardDevicePort = devicePort;
+    forwardHostPort = hostPort;
+    return forwardReturnValue;
+  }
+}
+
+class FakeVmServiceDiscoveryForAttach extends Fake implements VMServiceDiscoveryForAttach {
+  FakeVmServiceDiscoveryForAttach(this._uris);
+
+  final List<Uri> _uris;
+
+  @override
+  Stream<Uri> get uris => Stream<Uri>.fromIterable(_uris);
+}
diff --git a/packages/flutter_tools/test/general.shard/proxied_devices/proxied_devices_test.dart b/packages/flutter_tools/test/general.shard/proxied_devices/proxied_devices_test.dart
index 4960fe9..4fae899 100644
--- a/packages/flutter_tools/test/general.shard/proxied_devices/proxied_devices_test.dart
+++ b/packages/flutter_tools/test/general.shard/proxied_devices/proxied_devices_test.dart
@@ -14,6 +14,7 @@
 import 'package:flutter_tools/src/base/utils.dart';
 import 'package:flutter_tools/src/daemon.dart';
 import 'package:flutter_tools/src/device.dart';
+import 'package:flutter_tools/src/device_vm_service_discovery_for_attach.dart';
 import 'package:flutter_tools/src/proxied_devices/devices.dart';
 import 'package:flutter_tools/src/proxied_devices/file_transfer.dart';
 import 'package:test/fake.dart';
@@ -803,6 +804,187 @@
       expect(localDds.shutdownCalled, true);
     });
   });
+
+  group('ProxiedVMServiceDiscoveryForAttach', () {
+    testWithoutContext('sends the request and forwards the port', () async {
+      final FakeProxiedPortForwarder portForwarder = FakeProxiedPortForwarder();
+      portForwarder.forwardReturnValue = 400;
+      final ProxiedVMServiceDiscoveryForAttach discovery = ProxiedVMServiceDiscoveryForAttach(
+        clientDaemonConnection,
+        'test_device',
+        proxiedPortForwarder: portForwarder,
+        fallbackDiscovery: () => throw UnimplementedError(),
+        ipv6: false,
+        logger: bufferLogger,
+      );
+
+      final Completer<Uri> uriCompleter = Completer<Uri>();
+
+      // Start listening on the stream to trigger sending the request.
+      discovery.uris.listen(uriCompleter.complete);
+
+      final Stream<DaemonMessage> broadcastOutput = serverDaemonConnection.incomingCommands.asBroadcastStream();
+      final DaemonMessage startMessage = await broadcastOutput.first;
+      expect(startMessage.data['id'], isNotNull);
+      expect(startMessage.data['method'], 'device.startVMServiceDiscoveryForAttach');
+      expect(startMessage.data['params'], <String, Object?>{
+        'deviceId': 'test_device',
+        'appId': null,
+        'fuchsiaModule': null,
+        'filterDevicePort': null,
+        'ipv6': false,
+      });
+
+      serverDaemonConnection.sendResponse(startMessage.data['id']!, 'request_id');
+      serverDaemonConnection.sendEvent('device.VMServiceDiscoveryForAttach.request_id', 'http://127.0.0.1:300/auth_code');
+
+      expect(await uriCompleter.future, Uri.parse('http://127.0.0.1:400/auth_code'));
+      expect(portForwarder.forwardedDevicePort, 300);
+      expect(portForwarder.forwardedHostPort, null);
+    });
+
+    testWithoutContext('sends additional information, and forwards the correct port', () async {
+      final FakeProxiedPortForwarder portForwarder = FakeProxiedPortForwarder();
+      portForwarder.forwardReturnValue = 400;
+      final ProxiedVMServiceDiscoveryForAttach discovery = ProxiedVMServiceDiscoveryForAttach(
+        clientDaemonConnection,
+        'test_device',
+        proxiedPortForwarder: portForwarder,
+        fallbackDiscovery: () => throw UnimplementedError(),
+        appId: 'test_app_id',
+        fuchsiaModule: 'test_fuchsia_module',
+        filterDevicePort: 100,
+        expectedHostPort: 200,
+        ipv6: false,
+        logger: bufferLogger,
+      );
+
+      final Completer<Uri> uriCompleter = Completer<Uri>();
+
+      // Start listening on the stream to trigger sending the request.
+      discovery.uris.listen(uriCompleter.complete);
+
+      final Stream<DaemonMessage> broadcastOutput = serverDaemonConnection.incomingCommands.asBroadcastStream();
+      final DaemonMessage startMessage = await broadcastOutput.first;
+      expect(startMessage.data['id'], isNotNull);
+      expect(startMessage.data['method'], 'device.startVMServiceDiscoveryForAttach');
+      expect(startMessage.data['params'], <String, Object?>{
+        'deviceId': 'test_device',
+        'appId': 'test_app_id',
+        'fuchsiaModule': 'test_fuchsia_module',
+        'filterDevicePort': 100,
+        'ipv6': false,
+      });
+
+      serverDaemonConnection.sendResponse(startMessage.data['id']!, 'request_id');
+      serverDaemonConnection.sendEvent('device.VMServiceDiscoveryForAttach.request_id', 'http://127.0.0.1:300/auth_code');
+
+      expect(await uriCompleter.future, Uri.parse('http://127.0.0.1:400/auth_code'));
+      expect(portForwarder.forwardedDevicePort, 300);
+      expect(portForwarder.forwardedHostPort, 200);
+    });
+
+    testWithoutContext('use the fallback discovery if the remote daemon does not support proxied discovery', () async {
+      final FakeProxiedPortForwarder portForwarder = FakeProxiedPortForwarder();
+      final Stream<Uri> fallbackUri = Stream<Uri>.value(Uri.parse('http://127.0.0.1:500/fallback_auth_code'));
+      final ProxiedVMServiceDiscoveryForAttach discovery = ProxiedVMServiceDiscoveryForAttach(
+        clientDaemonConnection,
+        'test_device',
+        proxiedPortForwarder: portForwarder,
+        fallbackDiscovery: () => FakeVMServiceDiscoveryForAttach(fallbackUri),
+        ipv6: false,
+        logger: bufferLogger,
+      );
+
+      final Completer<Uri> uriCompleter = Completer<Uri>();
+
+      // Start listening on the stream to trigger sending the request.
+      discovery.uris.listen(uriCompleter.complete);
+
+      final Stream<DaemonMessage> broadcastOutput = serverDaemonConnection.incomingCommands.asBroadcastStream();
+      final DaemonMessage startMessage = await broadcastOutput.first;
+      expect(startMessage.data['id'], isNotNull);
+      expect(startMessage.data['method'], 'device.startVMServiceDiscoveryForAttach');
+      expect(startMessage.data['params'], <String, Object?>{
+        'deviceId': 'test_device',
+        'appId': null,
+        'fuchsiaModule': null,
+        'filterDevicePort': null,
+        'ipv6': false,
+      });
+      serverDaemonConnection.sendErrorResponse(startMessage.data['id']!, 'command not understood: device.startDartDevelopmentService', StackTrace.current);
+
+      expect(await uriCompleter.future, Uri.parse('http://127.0.0.1:500/fallback_auth_code'));
+      expect(portForwarder.forwardedDevicePort, null);
+      expect(portForwarder.forwardedHostPort, null);
+    });
+
+    testWithoutContext('forwards other error from the daemon', () async {
+      final FakeProxiedPortForwarder portForwarder = FakeProxiedPortForwarder();
+      final Stream<Uri> fallbackUri = Stream<Uri>.value(Uri.parse('http://127.0.0.1:500/fallback_auth_code'));
+      final ProxiedVMServiceDiscoveryForAttach discovery = ProxiedVMServiceDiscoveryForAttach(
+        clientDaemonConnection,
+        'test_device',
+        proxiedPortForwarder: portForwarder,
+        fallbackDiscovery: () => FakeVMServiceDiscoveryForAttach(fallbackUri),
+        ipv6: false,
+        logger: bufferLogger,
+      );
+
+      // Start listening on the stream to trigger sending the request.
+      final Future<Uri> uriFuture = discovery.uris.first;
+
+      final Stream<DaemonMessage> broadcastOutput = serverDaemonConnection.incomingCommands.asBroadcastStream();
+      final DaemonMessage startMessage = await broadcastOutput.first;
+      expect(startMessage.data['id'], isNotNull);
+      expect(startMessage.data['method'], 'device.startVMServiceDiscoveryForAttach');
+      expect(startMessage.data['params'], <String, Object?>{
+        'deviceId': 'test_device',
+        'appId': null,
+        'fuchsiaModule': null,
+        'filterDevicePort': null,
+        'ipv6': false,
+      });
+      serverDaemonConnection.sendErrorResponse(startMessage.data['id']!, 'other error', StackTrace.current);
+
+      expect(uriFuture, throwsA('other error'));
+      expect(portForwarder.forwardedDevicePort, null);
+      expect(portForwarder.forwardedHostPort, null);
+    });
+
+    testWithoutContext('forwards the port forwarder error', () async {
+      final FakeProxiedPortForwarder portForwarder = FakeProxiedPortForwarder();
+      portForwarder.forwardThrowException = TestException();
+      final ProxiedVMServiceDiscoveryForAttach discovery = ProxiedVMServiceDiscoveryForAttach(
+        clientDaemonConnection,
+        'test_device',
+        proxiedPortForwarder: portForwarder,
+        fallbackDiscovery: () => throw UnimplementedError(),
+        ipv6: false,
+        logger: bufferLogger,
+      );
+
+      // Start listening on the stream to trigger sending the request.
+      final Future<Uri> uriFuture = discovery.uris.first;
+
+      final Stream<DaemonMessage> broadcastOutput = serverDaemonConnection.incomingCommands.asBroadcastStream();
+      final DaemonMessage startMessage = await broadcastOutput.first;
+      expect(startMessage.data['id'], isNotNull);
+      expect(startMessage.data['method'], 'device.startVMServiceDiscoveryForAttach');
+      expect(startMessage.data['params'], <String, Object?>{
+        'deviceId': 'test_device',
+        'appId': null,
+        'fuchsiaModule': null,
+        'filterDevicePort': null,
+        'ipv6': false,
+      });
+
+      serverDaemonConnection.sendResponse(startMessage.data['id']!, 'request_id');
+      serverDaemonConnection.sendEvent('device.VMServiceDiscoveryForAttach.request_id', 'http://127.0.0.1:300/auth_code');
+
+      expect(uriFuture, throwsA(isA<TestException>()));
+    });
+  });
 }
 
 class FakeDaemonStreams implements DaemonStreams {
@@ -934,6 +1116,7 @@
   int? originalRemotePortReturnValue;
   int? receivedLocalForwardedPort;
 
+  Exception? forwardThrowException;
   int? forwardReturnValue;
   int? forwardedDevicePort;
   int? forwardedHostPort;
@@ -950,6 +1133,9 @@
     forwardedDevicePort = devicePort;
     forwardedHostPort = hostPort;
     forwardedIpv6 = ipv6;
+    if (forwardThrowException != null) {
+      throw forwardThrowException!;
+    }
     return forwardReturnValue!;
   }
 }
@@ -999,3 +1185,12 @@
   @override
   Future<Uint8List> binaryForRebuilding(File file, List<FileDeltaBlock> delta) async => binary!;
 }
+
+class FakeVMServiceDiscoveryForAttach extends Fake implements VMServiceDiscoveryForAttach {
+  FakeVMServiceDiscoveryForAttach(this.uris);
+
+  @override
+  Stream<Uri> uris;
+}
+
+class TestException implements Exception {}