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

import 'dart:async';
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';

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

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

19
const ProcessManager _processManager = LocalProcessManager();
20

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

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

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

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

/// 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.
34
typedef PortForwardingFunction = Future<PortForwarder> Function(
35 36 37 38 39
  String address,
  int remotePort, [
  String interface,
  String configFile,
]);
40 41 42 43 44 45 46 47 48 49 50 51 52 53

/// 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;
}

54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
/// 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';
  }
}

69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
/// 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),
84
/// and contains the service port of the VM as well as a URL to connect to it.
85 86 87
class DartVmEvent {
  DartVmEvent._({this.eventType, this.servicePort, this.uri});

88
  /// The URL used to connect to the Dart VM.
89 90 91 92 93 94 95 96 97
  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;
}

98 99 100 101 102
/// 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
103 104
/// This class can be connected to several instances of the Fuchsia device's
/// Dart VM at any given time.
105
class FuchsiaRemoteConnection {
106
  FuchsiaRemoteConnection._(this._useIpV6, this._sshCommandRunner)
107
    : _pollDartVms = false;
108 109

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

114 115 116 117 118 119
  /// 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.
120
  final Set<int> _stalePorts = <int>{};
121 122 123 124 125

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

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

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

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

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

    connection._onDartVmEvent = dartVmStream();
153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
    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
173 174 175 176
  /// 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).
177 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([
    String address,
192 193 194
    String interface = '',
    String sshConfigPath,
  ]) async {
195 196 197
    address ??= Platform.environment['FUCHSIA_DEVICE_URL'];
    sshConfigPath ??= Platform.environment['FUCHSIA_SSH_CONFIG'];
    if (address == null) {
198
      throw FuchsiaRemoteConnectionError(
199
          r'No address supplied, and $FUCHSIA_DEVICE_URL not found.');
200 201 202 203 204 205 206 207 208
    }
    const String interfaceDelimiter = '%';
    if (address.contains(interfaceDelimiter)) {
      final List<String> addressAndInterface =
          address.split(interfaceDelimiter);
      address = addressAndInterface[0];
      interface = addressAndInterface[1];
    }

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

  /// 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.
223
  Future<void> stop() async {
224
    for (final PortForwarder pf in _forwardedVmServicePorts) {
225 226
      // Closes VM service first to ensure that the connection is closed cleanly
      // on the target before shutting down the forwarding itself.
227 228 229
      final Uri uri = _getDartVmUri(pf);
      final DartVm vmService = _dartVmCache[uri];
      _dartVmCache[uri] = null;
230
      await vmService?.stop();
231
      await pf.stop();
232
    }
233
    for (final PortForwarder pf in _dartVmPortMap.values) {
234 235 236
      final Uri uri = _getDartVmUri(pf);
      final DartVm vmService = _dartVmCache[uri];
      _dartVmCache[uri] = null;
237 238 239
      await vmService?.stop();
      await pf.stop();
    }
240 241
    _dartVmCache.clear();
    _forwardedVmServicePorts.clear();
242 243 244 245
    _dartVmPortMap.clear();
    _pollDartVms = false;
  }

246 247 248 249 250 251 252 253 254
  /// 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([
    Pattern pattern,
    Duration timeout = _kIsolateFindTimeout,
255
    Duration vmConnectionTimeout = _kDartVmConnectionTimeout,
256
  ]) async {
257
    final Completer<List<IsolateRef>> completer = Completer<List<IsolateRef>>();
258 259 260 261 262
    _onDartVmEvent.listen(
      (DartVmEvent event) async {
        if (event.eventType == DartVmEventType.started) {
          _log.fine('New VM found on port: ${event.servicePort}. Searching '
              'for Isolate: $pattern');
263 264
          final DartVm vmService = await _getDartVm(event.uri,
            timeout: _kDartVmConnectionTimeout);
265
          // If the VM service is null, set the result to the empty list.
266
          final List<IsolateRef> result = await vmService
267 268
                  ?.getMainIsolatesByPattern(pattern, timeout: timeout) ??
              <IsolateRef>[];
269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288
          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);
  }

289 290
  /// Returns all Isolates running `main()` as matched by the [Pattern].
  ///
291 292 293
  /// 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`.
294
  Future<List<IsolateRef>> getMainIsolatesByPattern(
295
    Pattern pattern, {
296
    Duration timeout = _kIsolateFindTimeout,
297 298
    Duration vmConnectionTimeout = _kDartVmConnectionTimeout,
  }) async {
299 300
    // If for some reason there are no Dart VM's that are alive, wait for one to
    // start with the Isolate in question.
301
    if (_dartVmPortMap.isEmpty) {
302
      _log.fine('No live Dart VMs found. Awaiting new VM startup');
303 304
      return _waitForMainIsolatesByPattern(
          pattern, timeout, vmConnectionTimeout);
305 306 307 308 309
    }
    // 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>>>[];
310
    for (final PortForwarder fp in _dartVmPortMap.values) {
311
      final DartVm vmService =
312
      await _getDartVm(_getDartVmUri(fp), timeout: vmConnectionTimeout);
313 314 315
      if (vmService == null) {
        continue;
      }
316
      isolates.add(vmService.getMainIsolatesByPattern(pattern));
317
    }
318
    final List<IsolateRef> result =
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333
      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;
            },
          );
        });
334 335 336 337 338 339 340 341 342 343 344

    // 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');
345 346
      return _waitForMainIsolatesByPattern(
          pattern, timeout, vmConnectionTimeout);
347
    }
348
    return result;
349 350 351 352
  }

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

  // 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>(
377 378 379
    Future<E> vmFunction(DartVm vmService), [
    bool queueEvents = true,
  ]) async {
380
    final List<E> result = <E>[];
381 382

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

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

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

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

446 447 448 449
  /// 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).
450
  Future<void> _pollVms() async {
451 452
    await _checkPorts();
    final List<int> servicePorts = await getDeviceServicePorts();
453
    for (final int servicePort in servicePorts) {
454 455 456 457 458 459 460 461
      if (!_stalePorts.contains(servicePort) &&
          !_dartVmPortMap.containsKey(servicePort)) {
        _dartVmPortMap[servicePort] = await fuchsiaPortForwardingFunction(
            _sshCommandRunner.address,
            servicePort,
            _sshCommandRunner.interface,
            _sshCommandRunner.sshConfigPath);

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

  /// Runs a dummy heartbeat command on all Dart VM instances.
  ///
  /// Removes any failing ports from the cache.
474
  Future<void> _checkPorts([ bool queueEvents = true ]) async {
475 476 477 478 479
    // Filters out stale ports after connecting. Ignores results.
    await _invokeForAllVms<Map<String, dynamic>>(
      (DartVm vmService) async {
        final Map<String, dynamic> res =
            await vmService.invokeRpc('getVersion');
480
        _log.fine('DartVM(${vmService.uri}) version check result: $res');
481 482 483 484 485 486
        return res;
      },
      queueEvents,
    );
  }

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

504
    for (final PortForwarder pf in forwardedVmServicePorts) {
505 506 507 508 509 510 511 512
      // TODO(awdavies): Handle duplicates.
      _dartVmPortMap[pf.remotePort] = pf;
    }

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

    _pollDartVms = true;
513 514 515 516 517 518 519 520 521 522
  }

  /// 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 {
523
    final List<String> portPaths = await _sshCommandRunner
524
        .run('/bin/find /hub -name vmservice-port');
525
    final List<int> ports = <int>[];
526
    for (final String path in portPaths) {
527 528 529
      if (path == '') {
        continue;
      }
530
      final List<String> lsOutput =
531
          await _sshCommandRunner.run('/bin/ls $path');
532
      for (final String line in lsOutput) {
533 534 535 536 537 538
        if (line == '') {
          continue;
        }
        final int port = int.tryParse(line);
        if (port != null) {
          ports.add(port);
539 540 541 542 543 544 545 546 547 548 549 550 551 552
        }
      }
    }
    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 {
553
  /// Determines the port which is being forwarded.
554 555
  int get port;

556 557 558 559
  /// The address on which the open port is accessible. Defaults to null to
  /// indicate local loopback.
  String get openPortAddress => null;

560 561 562 563
  /// The destination port on the other end of the port forwarding tunnel.
  int get remotePort;

  /// Shuts down and cleans up port forwarding.
564
  Future<void> stop();
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
}

/// 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;
  final String _sshConfigPath;
  final String _interface;
  final bool _ipV6;

  @override
  int get port => _localSocket.port;

590 591 592
  @override
  String get openPortAddress => _ipV6 ? _ipv6Loopback : _ipv4Loopback;

593 594 595 596 597
  @override
  int get remotePort => _remotePort;

  /// Starts SSH forwarding through a subprocess, and returns an instance of
  /// [_SshPortForwarder].
598 599 600 601 602 603
  static Future<_SshPortForwarder> start(
    String address,
    int remotePort, [
    String interface,
    String sshConfigPath,
  ]) async {
604 605 606 607 608 609 610 611 612 613
    final bool isIpV6 = isIpV6Address(address);
    final ServerSocket localSocket = await _createLocalSocket();
    if (localSocket == null || localSocket.port == 0) {
      _log.warning('_SshPortForwarder failed to find a local port for '
          '$address:$remotePort');
      return null;
    }
    // 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
614 615 616
    // 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.
617 618 619 620
    final String formattedForwardingUrl =
        '${localSocket.port}:$_ipv4Loopback:$remotePort';
    final String targetAddress =
        isIpV6 && interface.isNotEmpty ? '$address%$interface' : address;
621
    const String dummyRemoteCommand = 'true';
622 623 624 625 626
    final List<String> command = <String>[
      'ssh',
      if (isIpV6) '-6',
      if (sshConfigPath != null)
        ...<String>['-F', sshConfigPath],
627
      '-nNT',
628
      '-f',
629 630 631
      '-L',
      formattedForwardingUrl,
      targetAddress,
632
      dummyRemoteCommand,
633
    ];
634
    _log.fine("_SshPortForwarder running '${command.join(' ')}'");
635 636 637 638 639 640 641 642 643
    // 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) {
      return null;
    }
644
    final _SshPortForwarder result = _SshPortForwarder._(
645
        address, remotePort, localSocket, interface, sshConfigPath, isIpV6);
646 647 648
    _log.fine('Set up forwarding from ${localSocket.port} '
        'to $address port $remotePort');
    return result;
649 650 651 652 653
  }

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

  /// Attempts to find an available port.
  ///
  /// If successful returns a valid [ServerSocket] (which must be disconnected
  /// later).
  static Future<ServerSocket> _createLocalSocket() async {
    ServerSocket s;
    try {
      s = await ServerSocket.bind(_ipv4Loopback, 0);
    } catch (e) {
      // Failures are signaled by a return value of 0 from this function.
      _log.warning('_createLocalSocket failed: $e');
      return null;
    }
    return s;
  }
}