fuchsia_remote_connection.dart 25.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
6
import 'dart:convert';
7 8 9 10 11 12 13 14 15
import 'dart:io';

import 'package:process/process.dart';

import 'common/logging.dart';
import 'common/network.dart';
import 'dart/dart_vm.dart';
import 'runners/ssh_command_runner.dart';

16
final String _ipv4Loopback = InternetAddress.loopbackIPv4.address;
17

18
final String _ipv6Loopback = InternetAddress.loopbackIPv6.address;
19

20
const ProcessManager _processManager = LocalProcessManager();
21

22
const Duration _kIsolateFindTimeout = Duration(minutes: 1);
23

24 25
const Duration _kDartVmConnectionTimeout = Duration(seconds: 9);

26
const Duration _kVmPollInterval = Duration(milliseconds: 1500);
27

28
final Logger _log = Logger('FuchsiaRemoteConnection');
29 30 31 32 33 34

/// A function for forwarding ports on the local machine to a remote device.
///
/// Takes a remote `address`, the target device's port, and an optional
/// `interface` and `configFile`. The config file is used primarily for the
/// default SSH port forwarding configuration.
35
typedef PortForwardingFunction = Future<PortForwarder> Function(
36 37
  String address,
  int remotePort, [
38 39
  String? interface,
  String? configFile,
40
]);
41 42 43 44 45 46 47 48 49 50 51 52 53 54

/// The function for forwarding the local machine's ports to a remote Fuchsia
/// device.
///
/// Can be overwritten in the event that a different method is required.
/// Defaults to using SSH port forwarding.
PortForwardingFunction fuchsiaPortForwardingFunction = _SshPortForwarder.start;

/// Sets [fuchsiaPortForwardingFunction] back to the default SSH port forwarding
/// implementation.
void restoreFuchsiaPortForwardingFunction() {
  fuchsiaPortForwardingFunction = _SshPortForwarder.start;
}

55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
/// A general error raised when something fails within a
/// [FuchsiaRemoteConnection].
class FuchsiaRemoteConnectionError extends Error {
  /// Basic constructor outlining the reason for the failure in `message`.
  FuchsiaRemoteConnectionError(this.message);

  /// The reason for the failure.
  final String message;

  @override
  String toString() {
    return '$FuchsiaRemoteConnectionError: $message';
  }
}

70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
/// An enum specifying a Dart VM's state.
enum DartVmEventType {
  /// The Dart VM has started.
  started,

  /// The Dart VM has stopped.
  ///
  /// This can mean either the host machine cannot be connect to, the VM service
  /// has shut down cleanly, or the VM service has crashed.
  stopped,
}

/// An event regarding the Dart VM.
///
/// Specifies the type of the event (whether the VM has started or has stopped),
85
/// and contains the service port of the VM as well as a URL to connect to it.
86
class DartVmEvent {
87
  DartVmEvent._({required this.eventType, required this.servicePort, required this.uri});
88

89
  /// The URL used to connect to the Dart VM.
90 91 92 93 94 95 96 97 98
  final Uri uri;

  /// The type of event regarding this instance of the Dart VM.
  final DartVmEventType eventType;

  /// The port on the host machine that the Dart VM service is/was running on.
  final int servicePort;
}

99 100 101 102 103
/// Manages a remote connection to a Fuchsia Device.
///
/// Provides affordances to observe and connect to Flutter views, isolates, and
/// perform actions on the Fuchsia device's various VM services.
///
Ian Hickson's avatar
Ian Hickson committed
104 105
/// This class can be connected to several instances of the Fuchsia device's
/// Dart VM at any given time.
106
class FuchsiaRemoteConnection {
107
  FuchsiaRemoteConnection._(this._useIpV6, this._sshCommandRunner)
108
    : _pollDartVms = false;
109 110

  bool _pollDartVms;
111 112
  final List<PortForwarder> _forwardedVmServicePorts = <PortForwarder>[];
  final SshCommandRunner _sshCommandRunner;
113
  final bool _useIpV6;
114

115 116 117 118 119 120
  /// A mapping of Dart VM ports (as seen on the target machine), to
  /// [PortForwarder] instances mapping from the local machine to the target
  /// machine.
  final Map<int, PortForwarder> _dartVmPortMap = <int, PortForwarder>{};

  /// Tracks stale ports so as not to reconnect while polling.
121
  final Set<int> _stalePorts = <int>{};
122 123 124

  /// A broadcast stream that emits events relating to Dart VM's as they update.
  Stream<DartVmEvent> get onDartVmEvent => _onDartVmEvent;
125
  late Stream<DartVmEvent> _onDartVmEvent;
126
  final StreamController<DartVmEvent> _dartVmEventController =
127
      StreamController<DartVmEvent>();
128

129
  /// VM service cache to avoid repeating handshakes across function
130
  /// calls. Keys a URI to a DartVm connection instance.
131
  final Map<Uri, DartVm?> _dartVmCache = <Uri, DartVm?>{};
132 133 134

  /// Same as [FuchsiaRemoteConnection.connect] albeit with a provided
  /// [SshCommandRunner] instance.
135
  static Future<FuchsiaRemoteConnection> connectWithSshCommandRunner(SshCommandRunner commandRunner) async {
136
    final FuchsiaRemoteConnection connection = FuchsiaRemoteConnection._(
137
        isIpV6Address(commandRunner.address), commandRunner);
138
    await connection._forwardOpenPortsToDeviceServicePorts();
139 140

    Stream<DartVmEvent> dartVmStream() {
141
      Future<void> listen() async {
142 143
        while (connection._pollDartVms) {
          await connection._pollVms();
144
          await Future<void>.delayed(_kVmPollInterval);
145 146 147 148 149 150 151 152 153
        }
        connection._dartVmEventController.close();
      }

      connection._dartVmEventController.onListen = listen;
      return connection._dartVmEventController.stream.asBroadcastStream();
    }

    connection._onDartVmEvent = dartVmStream();
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
    return connection;
  }

  /// Opens a connection to a Fuchsia device.
  ///
  /// Accepts an `address` to a Fuchsia device, and optionally a `sshConfigPath`
  /// in order to open the associated ssh_config for port forwarding.
  ///
  /// Will throw an [ArgumentError] if `address` is malformed.
  ///
  /// Once this function is called, the instance of [FuchsiaRemoteConnection]
  /// returned will keep all associated DartVM connections opened over the
  /// lifetime of the object.
  ///
  /// At its current state Dart VM connections will not be added or removed over
  /// the lifetime of this object.
  ///
  /// Throws an [ArgumentError] if the supplied `address` is not valid IPv6 or
  /// IPv4.
  ///
Ian Hickson's avatar
Ian Hickson committed
174 175 176 177
  /// If `address` is IPv6 link local (usually starts with `fe80::`), then
  /// `interface` will probably need to be set in order to connect successfully
  /// (that being the outgoing interface of your machine, not the interface on
  /// the target machine).
178 179 180 181 182 183 184 185 186 187 188 189 190 191
  ///
  /// Attempts to set `address` via the environment variable
  /// `FUCHSIA_DEVICE_URL` in the event that the argument is not passed.
  /// If `address` is not supplied, `interface` is also ignored, as the format
  /// is expected to contain the interface as well (in the event that it is
  /// link-local), like the following:
  ///
  /// ```
  /// fe80::1%eth0
  /// ```
  ///
  /// In the event that `FUCHSIA_SSH_CONFIG` is set in the environment, that
  /// will be used when `sshConfigPath` isn't supplied.
  static Future<FuchsiaRemoteConnection> connect([
192
    String? address,
193
    String interface = '',
194
    String? sshConfigPath,
195
  ]) async {
196 197 198
    address ??= Platform.environment['FUCHSIA_DEVICE_URL'];
    sshConfigPath ??= Platform.environment['FUCHSIA_SSH_CONFIG'];
    if (address == null) {
199
      throw FuchsiaRemoteConnectionError(
200
          r'No address supplied, and $FUCHSIA_DEVICE_URL not found.');
201 202 203 204 205 206 207 208 209
    }
    const String interfaceDelimiter = '%';
    if (address.contains(interfaceDelimiter)) {
      final List<String> addressAndInterface =
          address.split(interfaceDelimiter);
      address = addressAndInterface[0];
      interface = addressAndInterface[1];
    }

210
    return FuchsiaRemoteConnection.connectWithSshCommandRunner(
211
      SshCommandRunner(
212 213
        address: address,
        interface: interface,
214
        sshConfigPath: sshConfigPath,
215 216 217 218 219 220 221 222 223
      ),
    );
  }

  /// Closes all open connections.
  ///
  /// Any objects that this class returns (including any child objects from
  /// those objects) will subsequently have its connection closed as well, so
  /// behavior for them will be undefined.
224
  Future<void> stop() async {
225
    for (final PortForwarder pf in _forwardedVmServicePorts) {
226 227
      // Closes VM service first to ensure that the connection is closed cleanly
      // on the target before shutting down the forwarding itself.
228
      final Uri uri = _getDartVmUri(pf);
229
      final DartVm? vmService = _dartVmCache[uri];
230
      _dartVmCache[uri] = null;
231
      await vmService?.stop();
232
      await pf.stop();
233
    }
234
    for (final PortForwarder pf in _dartVmPortMap.values) {
235
      final Uri uri = _getDartVmUri(pf);
236
      final DartVm? vmService = _dartVmCache[uri];
237
      _dartVmCache[uri] = null;
238 239 240
      await vmService?.stop();
      await pf.stop();
    }
241 242
    _dartVmCache.clear();
    _forwardedVmServicePorts.clear();
243 244 245 246
    _dartVmPortMap.clear();
    _pollDartVms = false;
  }

247 248 249 250 251 252 253
  /// Helper method for [getMainIsolatesByPattern].
  ///
  /// Called when either there are no Isolates that exist that match
  /// `pattern`, or there are not yet any active Dart VM's on the system
  /// (possible when the Isolate we're attempting to connect to is in the only
  /// instance of the Dart VM and its service port has not yet opened).
  Future<List<IsolateRef>> _waitForMainIsolatesByPattern([
254
    Pattern? pattern,
255
    Duration timeout = _kIsolateFindTimeout,
256
    Duration vmConnectionTimeout = _kDartVmConnectionTimeout,
257
  ]) async {
258
    final Completer<List<IsolateRef>> completer = Completer<List<IsolateRef>>();
259 260 261 262 263
    _onDartVmEvent.listen(
      (DartVmEvent event) async {
        if (event.eventType == DartVmEventType.started) {
          _log.fine('New VM found on port: ${event.servicePort}. Searching '
              'for Isolate: $pattern');
264
          final DartVm? vmService = await _getDartVm(event.uri);
265
          // If the VM service is null, set the result to the empty list.
266
          final List<IsolateRef> result = await vmService?.getMainIsolatesByPattern(pattern!) ?? <IsolateRef>[];
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286
          if (result.isNotEmpty) {
            if (!completer.isCompleted) {
              completer.complete(result);
            } else {
              _log.warning('Found more than one Dart VM containing Isolates '
                  'that match the pattern "$pattern".');
            }
          }
        }
      },
      onDone: () {
        if (!completer.isCompleted) {
          _log.warning('Terminating isolate search for "$pattern"'
              ' before timeout reached.');
        }
      },
    );
    return completer.future.timeout(timeout);
  }

287 288
  /// Returns all Isolates running `main()` as matched by the [Pattern].
  ///
289 290 291
  /// If there are no live Dart VM's or the Isolate cannot be found, waits until
  /// either `timeout` is reached, or a Dart VM starts up with a name that
  /// matches `pattern`.
292
  Future<List<IsolateRef>> getMainIsolatesByPattern(
293
    Pattern pattern, {
294
    Duration timeout = _kIsolateFindTimeout,
295 296
    Duration vmConnectionTimeout = _kDartVmConnectionTimeout,
  }) async {
297 298
    // If for some reason there are no Dart VM's that are alive, wait for one to
    // start with the Isolate in question.
299
    if (_dartVmPortMap.isEmpty) {
300
      _log.fine('No live Dart VMs found. Awaiting new VM startup');
301 302
      return _waitForMainIsolatesByPattern(
          pattern, timeout, vmConnectionTimeout);
303 304 305 306 307
    }
    // Accumulate a list of eventual IsolateRef lists so that they can be loaded
    // simultaneously via Future.wait.
    final List<Future<List<IsolateRef>>> isolates =
        <Future<List<IsolateRef>>>[];
308
    for (final PortForwarder fp in _dartVmPortMap.values) {
309
      final DartVm? vmService =
310
      await _getDartVm(_getDartVmUri(fp), timeout: vmConnectionTimeout);
311 312 313
      if (vmService == null) {
        continue;
      }
314
      isolates.add(vmService.getMainIsolatesByPattern(pattern));
315
    }
316
    final List<IsolateRef> result =
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331
      await Future.wait<List<IsolateRef>>(isolates)
        .timeout(timeout)
        .then<List<IsolateRef>>((List<List<IsolateRef>> listOfLists) {
          final List<List<IsolateRef>> mutableListOfLists =
            List<List<IsolateRef>>.from(listOfLists)
              ..retainWhere((List<IsolateRef> list) => list.isNotEmpty);
          // Folds the list of lists into one flat list.
          return mutableListOfLists.fold<List<IsolateRef>>(
            <IsolateRef>[],
            (List<IsolateRef> accumulator, List<IsolateRef> element) {
              accumulator.addAll(element);
              return accumulator;
            },
          );
        });
332 333 334 335 336 337 338 339 340 341 342

    // If no VM instance anywhere has this, it's possible it hasn't spun up
    // anywhere.
    //
    // For the time being one Flutter Isolate runs at a time in each VM, so for
    // now this will wait until the timer runs out or a new Dart VM starts that
    // contains the Isolate in question.
    //
    // TODO(awdavies): Set this up to handle multiple Isolates per Dart VM.
    if (result.isEmpty) {
      _log.fine('No instance of the Isolate found. Awaiting new VM startup');
343 344
      return _waitForMainIsolatesByPattern(
          pattern, timeout, vmConnectionTimeout);
345
    }
346
    return result;
347 348 349 350
  }

  /// Returns a list of [FlutterView] objects.
  ///
351
  /// This is run across all connected Dart VM connections that this class is
352 353
  /// managing.
  Future<List<FlutterView>> getFlutterViews() async {
354
    if (_dartVmPortMap.isEmpty) {
355
      return <FlutterView>[];
356
    }
357 358
    final List<List<FlutterView>> flutterViewLists =
        await _invokeForAllVms<List<FlutterView>>((DartVm vmService) async {
359
      return vmService.getAllFlutterViews();
360 361 362 363 364 365
    });
    final List<FlutterView> results = flutterViewLists.fold<List<FlutterView>>(
        <FlutterView>[], (List<FlutterView> acc, List<FlutterView> element) {
      acc.addAll(element);
      return acc;
    });
366
    return List<FlutterView>.unmodifiable(results);
367 368 369 370 371 372 373 374
  }

  // Calls all Dart VM's, returning a list of results.
  //
  // A side effect of this function is that internally tracked port forwarding
  // will be updated in the event that ports are found to be broken/stale: they
  // will be shut down and removed from tracking.
  Future<List<E>> _invokeForAllVms<E>(
375
    Future<E> Function(DartVm vmService) vmFunction, [
376 377
    bool queueEvents = true,
  ]) async {
378
    final List<E> result = <E>[];
379 380

    // Helper function loop.
381
    Future<void> shutDownPortForwarder(PortForwarder pf) async {
382 383 384
      await pf.stop();
      _stalePorts.add(pf.remotePort);
      if (queueEvents) {
385
        _dartVmEventController.add(DartVmEvent._(
386 387
          eventType: DartVmEventType.stopped,
          servicePort: pf.remotePort,
388
          uri: _getDartVmUri(pf),
389 390 391 392
        ));
      }
    }

393
    for (final PortForwarder pf in _dartVmPortMap.values) {
394
      final DartVm? service = await _getDartVm(_getDartVmUri(pf));
395
      if (service == null) {
396
        await shutDownPortForwarder(pf);
397 398
      } else {
        result.add(await vmFunction(service));
399
      }
400
    }
401
    _stalePorts.forEach(_dartVmPortMap.remove);
402
    return result;
403 404
  }

405
  Uri _getDartVmUri(PortForwarder pf) {
406
    String? addr;
407 408 409
    if (pf.openPortAddress == null) {
      addr = _useIpV6 ? '[$_ipv6Loopback]' : _ipv4Loopback;
    } else {
410
      addr = isIpV6Address(pf.openPortAddress!)
411 412 413 414
        ? '[${pf.openPortAddress}]'
        : pf.openPortAddress;
    }
    final Uri uri = Uri.http('$addr:${pf.port}', '/');
415 416 417
    return uri;
  }

418 419 420 421
  /// Attempts to create a connection to a Dart VM.
  ///
  /// Returns null if either there is an [HttpException] or a
  /// [TimeoutException], else a [DartVm] instance.
422
  Future<DartVm?> _getDartVm(
423
    Uri uri, {
424 425
    Duration timeout = _kDartVmConnectionTimeout,
  }) async {
426
    if (!_dartVmCache.containsKey(uri)) {
427
      // When raising an HttpException this means that there is no instance of
428
      // the Dart VM to communicate with. The TimeoutException is raised when
429 430
      // the Dart VM instance is shut down in the middle of communicating.
      try {
431 432
        final DartVm dartVm = await DartVm.connect(uri, timeout: timeout);
        _dartVmCache[uri] = dartVm;
433 434 435 436 437 438 439
      } on HttpException {
        _log.warning('HTTP Exception encountered connecting to new VM');
        return null;
      } on TimeoutException {
        _log.warning('TimeoutException encountered connecting to new VM');
        return null;
      }
440
    }
441
    return _dartVmCache[uri];
442 443
  }

444 445 446 447
  /// Checks for changes in the list of Dart VM instances.
  ///
  /// If there are new instances of the Dart VM, then connections will be
  /// attempted (after clearing out stale connections).
448
  Future<void> _pollVms() async {
449 450
    await _checkPorts();
    final List<int> servicePorts = await getDeviceServicePorts();
451
    for (final int servicePort in servicePorts) {
452 453 454 455 456 457 458 459
      if (!_stalePorts.contains(servicePort) &&
          !_dartVmPortMap.containsKey(servicePort)) {
        _dartVmPortMap[servicePort] = await fuchsiaPortForwardingFunction(
            _sshCommandRunner.address,
            servicePort,
            _sshCommandRunner.interface,
            _sshCommandRunner.sshConfigPath);

460
        _dartVmEventController.add(DartVmEvent._(
461 462
          eventType: DartVmEventType.started,
          servicePort: servicePort,
463
          uri: _getDartVmUri(_dartVmPortMap[servicePort]!),
464 465 466 467 468 469 470 471
        ));
      }
    }
  }

  /// Runs a dummy heartbeat command on all Dart VM instances.
  ///
  /// Removes any failing ports from the cache.
472
  Future<void> _checkPorts([ bool queueEvents = true ]) async {
473
    // Filters out stale ports after connecting. Ignores results.
474
    await _invokeForAllVms<void>(
475
      (DartVm vmService) async {
476
        await vmService.ping();
477 478 479 480 481
      },
      queueEvents,
    );
  }

482
  /// Forwards a series of open ports to the remote device.
483 484 485
  ///
  /// When this function is run, all existing forwarded ports and connections
  /// are reset by way of [stop].
486
  Future<void> _forwardOpenPortsToDeviceServicePorts() async {
487 488
    await stop();
    final List<int> servicePorts = await getDeviceServicePorts();
489 490 491
    final List<PortForwarder?> forwardedVmServicePorts =
      await Future.wait<PortForwarder?>(
        servicePorts.map<Future<PortForwarder?>>((int deviceServicePort) {
492 493 494 495 496 497
          return fuchsiaPortForwardingFunction(
              _sshCommandRunner.address,
              deviceServicePort,
              _sshCommandRunner.interface,
              _sshCommandRunner.sshConfigPath);
        }));
498

499
    for (final PortForwarder? pf in forwardedVmServicePorts) {
500
      // TODO(awdavies): Handle duplicates.
501
      _dartVmPortMap[pf!.remotePort] = pf;
502 503 504 505 506 507
    }

    // Don't queue events, since this is the initial forwarding.
    await _checkPorts(false);

    _pollDartVms = true;
508 509
  }

510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542
  /// Helper for getDeviceServicePorts() to extract the vm_service_port from
  /// json response.
  List<int> getVmServicePortFromInspectSnapshot(dynamic inspectSnapshot) {
    final List<Map<String, dynamic>> snapshot =
        List<Map<String, dynamic>>.from(inspectSnapshot as List<dynamic>);
    final List<int> ports = <int>[];

    for (final Map<String, dynamic> item in snapshot) {
      if (!item.containsKey('payload') || item['payload'] == null) {
        continue;
      }
      final Map<String, dynamic> payload =
          Map<String, dynamic>.from(item['payload'] as Map<String, dynamic>);

      if (!payload.containsKey('root') || payload['root'] == null) {
        continue;
      }
      final Map<String, dynamic> root =
          Map<String, dynamic>.from(payload['root'] as Map<String, dynamic>);

      if (!root.containsKey('vm_service_port') ||
          root['vm_service_port'] == null) {
        continue;
      }

      final int? port = int.tryParse(root['vm_service_port'] as String);
      if (port != null) {
        ports.add(port);
      }
    }
    return ports;
  }

543 544 545 546 547 548 549 550
  /// Gets the open Dart VM service ports on a remote Fuchsia device.
  ///
  /// The method attempts to get service ports through an SSH connection. Upon
  /// successfully getting the VM service ports, returns them as a list of
  /// integers. If an empty list is returned, then no Dart VM instances could be
  /// found. An exception is thrown in the event of an actual error when
  /// attempting to acquire the ports.
  Future<List<int>> getDeviceServicePorts() async {
551 552 553 554 555 556 557 558
    final List<String> inspectResult = await _sshCommandRunner
        .run("iquery --format json show '**:root:vm_service_port'");
    final dynamic inspectOutputJson = jsonDecode(inspectResult.join('\n'));
    final List<int> ports =
        getVmServicePortFromInspectSnapshot(inspectOutputJson);

    if (ports.length > 1) {
      throw StateError('More than one Flutter observatory port found');
559 560 561 562 563 564 565 566 567 568 569 570
    }
    return ports;
  }
}

/// Defines an interface for port forwarding.
///
/// When a port forwarder is initialized, it is intended to save a port through
/// which a connection is persisted along the lifetime of this object.
///
/// To shut down a port forwarder you must call the [stop] function.
abstract class PortForwarder {
571
  /// Determines the port which is being forwarded.
572 573
  int get port;

574 575
  /// The address on which the open port is accessible. Defaults to null to
  /// indicate local loopback.
576
  String? get openPortAddress => null;
577

578 579 580 581
  /// The destination port on the other end of the port forwarding tunnel.
  int get remotePort;

  /// Shuts down and cleans up port forwarding.
582
  Future<void> stop();
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600
}

/// Instances of this class represent a running SSH tunnel.
///
/// The SSH tunnel is from the host to a VM service running on a Fuchsia device.
class _SshPortForwarder implements PortForwarder {
  _SshPortForwarder._(
    this._remoteAddress,
    this._remotePort,
    this._localSocket,
    this._interface,
    this._sshConfigPath,
    this._ipV6,
  );

  final String _remoteAddress;
  final int _remotePort;
  final ServerSocket _localSocket;
601 602
  final String? _sshConfigPath;
  final String? _interface;
603 604 605 606 607
  final bool _ipV6;

  @override
  int get port => _localSocket.port;

608 609 610
  @override
  String get openPortAddress => _ipV6 ? _ipv6Loopback : _ipv4Loopback;

611 612 613 614 615
  @override
  int get remotePort => _remotePort;

  /// Starts SSH forwarding through a subprocess, and returns an instance of
  /// [_SshPortForwarder].
616 617 618
  static Future<_SshPortForwarder> start(
    String address,
    int remotePort, [
619 620
    String? interface,
    String? sshConfigPath,
621
  ]) async {
622
    final bool isIpV6 = isIpV6Address(address);
623
    final ServerSocket? localSocket = await _createLocalSocket();
624 625 626
    if (localSocket == null || localSocket.port == 0) {
      _log.warning('_SshPortForwarder failed to find a local port for '
          '$address:$remotePort');
627
      throw StateError('Unable to create a socket or no available ports.');
628 629 630 631
    }
    // TODO(awdavies): The square-bracket enclosure for using the IPv6 loopback
    // didn't appear to work, but when assigning to the IPv4 loopback device,
    // netstat shows that the local port is actually being used on the IPv6
632 633 634
    // loopback (::1). Therefore, while the IPv4 loopback can be used for
    // forwarding to the destination IPv6 interface, when connecting to the
    // websocket, the IPV6 loopback should be used.
635 636 637
    final String formattedForwardingUrl =
        '${localSocket.port}:$_ipv4Loopback:$remotePort';
    final String targetAddress =
638
        isIpV6 && interface!.isNotEmpty ? '$address%$interface' : address;
639
    const String dummyRemoteCommand = 'true';
640 641 642 643 644
    final List<String> command = <String>[
      'ssh',
      if (isIpV6) '-6',
      if (sshConfigPath != null)
        ...<String>['-F', sshConfigPath],
645
      '-nNT',
646
      '-f',
647 648 649
      '-L',
      formattedForwardingUrl,
      targetAddress,
650
      dummyRemoteCommand,
651
    ];
652
    _log.fine("_SshPortForwarder running '${command.join(' ')}'");
653 654 655 656 657 658 659
    // Must await for the port forwarding function to completer here, as
    // forwarding must be completed before surfacing VM events (as the user may
    // want to connect immediately after an event is surfaced).
    final ProcessResult processResult = await _processManager.run(command);
    _log.fine("'${command.join(' ')}' exited with exit code "
        '${processResult.exitCode}');
    if (processResult.exitCode != 0) {
660
      throw StateError('Unable to start port forwarding');
661
    }
662
    final _SshPortForwarder result = _SshPortForwarder._(
663
        address, remotePort, localSocket, interface, sshConfigPath, isIpV6);
664 665 666
    _log.fine('Set up forwarding from ${localSocket.port} '
        'to $address port $remotePort');
    return result;
667 668 669 670 671
  }

  /// Kills the SSH forwarding command, then to ensure no ports are forwarded,
  /// runs the SSH 'cancel' command to shut down port forwarding completely.
  @override
672
  Future<void> stop() async {
673 674 675 676
    // Cancel the forwarding request. See [start] for commentary about why this
    // uses the IPv4 loopback.
    final String formattedForwardingUrl =
        '${_localSocket.port}:$_ipv4Loopback:$_remotePort';
677
    final String targetAddress = _ipV6 && _interface!.isNotEmpty
678 679
        ? '$_remoteAddress%$_interface'
        : _remoteAddress;
680 681
    final String? sshConfigPath = _sshConfigPath;
    final List<String> command = <String>[
682
      'ssh',
683 684
      if (sshConfigPath != null)
        ...<String>['-F', sshConfigPath],
685 686 687 688 689
      '-O',
      'cancel',
      '-L',
      formattedForwardingUrl,
      targetAddress,
690
    ];
691 692 693 694
    _log.fine(
        'Shutting down SSH forwarding with command: ${command.join(' ')}');
    final ProcessResult result = await _processManager.run(command);
    if (result.exitCode != 0) {
695 696
      _log.warning('Command failed:\nstdout: ${result.stdout}'
          '\nstderr: ${result.stderr}');
697 698 699 700 701 702 703 704
    }
    _localSocket.close();
  }

  /// Attempts to find an available port.
  ///
  /// If successful returns a valid [ServerSocket] (which must be disconnected
  /// later).
705
  static Future<ServerSocket?> _createLocalSocket() async {
706
    try {
707
      return await ServerSocket.bind(_ipv4Loopback, 0);
708
    } catch (e) {
709 710
      // We should not be catching all errors arbitrarily here, this might hide real errors.
      // TODO(ianh): Determine which exceptions to catch here.
711 712 713 714 715
      _log.warning('_createLocalSocket failed: $e');
      return null;
    }
  }
}