fuchsia_reload.dart 20.5 KB
Newer Older
1 2 3 4 5
// Copyright 2017 The Chromium 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';
6
import 'dart:collection';
7
import 'dart:convert';
8

9
import '../artifacts.dart';
10
import '../base/common.dart';
11
import '../base/context.dart';
12 13
import '../base/file_system.dart';
import '../base/io.dart';
14
import '../base/process_manager.dart';
15
import '../base/terminal.dart';
16
import '../base/utils.dart';
17
import '../bundle.dart' as bundle;
18
import '../cache.dart';
19
import '../context_runner.dart';
20 21 22
import '../device.dart';
import '../fuchsia/fuchsia_device.dart';
import '../globals.dart';
23
import '../resident_runner.dart';
24 25
import '../run_hot.dart';
import '../runner/flutter_command.dart';
26
import '../vmservice.dart';
27 28

// Usage:
29 30 31 32 33 34 35 36 37 38 39 40 41 42
// With e.g. hello_mod already running, a HotRunner can be attached to it.
//
// From a Fuchsia in-tree build:
// $ flutter fuchsia_reload --address 192.168.1.39 \
//       --build-dir ~/fuchsia/out/x64 \
//       --gn-target //topaz/examples/ui/hello_mod:hello_mod
//
// From out of tree:
// $ flutter fuchsia_reload --address 192.168.1.39 \
//       --mod_name hello_mod \
//       --path /path/to/hello_mod
//       --dot-packages /path/to/hello_mod_out/app.packages \
//       --ssh-config /path/to/ssh_config \
//       --target /path/to/hello_mod/lib/main.dart
43

44
final String ipv4Loopback = InternetAddress.loopbackIPv4.address;
45

46 47 48
class FuchsiaReloadCommand extends FlutterCommand {
  FuchsiaReloadCommand() {
    addBuildModeFlags(defaultToRelease: false);
49 50 51
    argParser.addOption('frontend-server',
      abbr: 'f',
      help: 'The frontend server location');
52 53 54
    argParser.addOption('address',
      abbr: 'a',
      help: 'Fuchsia device network name or address.');
55
    argParser.addOption('build-dir',
56
      abbr: 'b',
57 58
      defaultsTo: null,
      help: 'Fuchsia build directory, e.g. out/release-x86-64.');
59 60 61 62 63
    argParser.addOption('dot-packages',
      abbr: 'd',
      defaultsTo: null,
      help: 'Path to the mod\'s .packages file. Required if no'
            'GN target specified.');
64 65
    argParser.addOption('gn-target',
      abbr: 'g',
66
      help: 'GN target of the application, e.g //path/to/app:app.');
67 68 69 70
    argParser.addOption('isolate-number',
      abbr: 'i',
      help: 'To reload only one instance, specify the isolate number, e.g. '
            'the number in foo\$main-###### given by --list.');
71 72 73
    argParser.addFlag('list',
      abbr: 'l',
      defaultsTo: false,
74
      help: 'Lists the running modules. ');
75 76 77 78 79 80 81 82 83 84 85 86
    argParser.addOption('mod-name',
      abbr: 'm',
      help: 'Name of the flutter mod. If used with -g, overrides the name '
            'inferred from the GN target.');
    argParser.addOption('path',
      abbr: 'p',
      defaultsTo: null,
      help: 'Path to the flutter mod project.');
    argParser.addOption('ssh-config',
      abbr: 's',
      defaultsTo: null,
      help: 'Path to the Fuchsia target\'s ssh config file.');
87 88
    argParser.addOption('target',
      abbr: 't',
89
      defaultsTo: bundle.defaultMainPath,
90
      help: 'Target app path / main entry-point file. '
91
            'Relative to --path or --gn-target path, e.g. lib/main.dart.');
92 93
  }

94 95 96 97 98 99
  @override
  final String name = 'fuchsia_reload';

  @override
  final String description = 'Hot reload on Fuchsia.';

100
  String _modName;
101
  String _isolateNumber;
102 103 104 105
  String _fuchsiaProjectPath;
  String _target;
  String _address;
  String _dotPackagesPath;
106
  String _sshConfig;
107
  File _frontendServerSnapshot;
108

109 110
  bool _list;

111
  @override
112
  Future<FlutterCommandResult> runCommand() async {
113
    Cache.releaseLockEarly();
114
    await _validateArguments();
115 116 117 118 119 120 121
    await runInContext<void>(() async {
      // Find the network ports used on the device by VM service instances.
      final List<int> deviceServicePorts = await _getServicePorts();
      if (deviceServicePorts.isEmpty)
        throwToolExit('Couldn\'t find any running Observatory instances.');
      for (int port in deviceServicePorts)
        printTrace('Fuchsia service port: $port');
122

123 124 125 126 127 128
      // Set up ssh tunnels to forward the device ports to local ports.
      final List<_PortForwarder> forwardedPorts = await _forwardPorts(deviceServicePorts);
      // Wrap everything in try/finally to make sure we kill the ssh processes
      // doing the port forwarding.
      try {
        final List<int> servicePorts = forwardedPorts.map<int>((_PortForwarder pf) => pf.port).toList();
129

130 131 132 133 134 135 136 137 138
        if (_list) {
          await _listVMs(servicePorts);
          // Port forwarding stops when the command ends. Keep the program running
          // until directed by the user so that Observatory URLs that we print
          // continue to work.
          printStatus('Press Enter to exit.');
          await stdin.first;
          return null;
        }
139

140 141 142 143 144 145 146 147
        // Check that there are running VM services on the returned
        // ports, and find the Isolates that are running the target app.
        final String isolateName = '$_modName\$main$_isolateNumber';
        final List<int> targetPorts = await _filterPorts(servicePorts, isolateName);
        if (targetPorts.isEmpty)
          throwToolExit('No VMs found running $_modName.');
        for (int port in targetPorts)
          printTrace('Found $_modName at $port');
148

149 150 151 152 153 154 155 156 157 158 159 160
        // Set up a device and hot runner and attach the hot runner to the first
        // vm service we found.
        final List<String> fullAddresses =
            targetPorts.map<String>((int p) => '$ipv4Loopback:$p').toList();
        final List<Uri> observatoryUris = fullAddresses
            .map<Uri>((String a) => Uri.parse('http://$a'))
            .toList();
        final FuchsiaDevice device =
            FuchsiaDevice(fullAddresses[0], name: _address);
        final FlutterDevice flutterDevice = FlutterDevice(
          device,
          trackWidgetCreation: false,
161
          viewFilter: isolateName,
162 163 164 165 166 167
        );
        flutterDevice.observatoryUris = observatoryUris;
          final HotRunner hotRunner = HotRunner(<FlutterDevice>[flutterDevice],
          debuggingOptions: DebuggingOptions.enabled(getBuildInfo()),
          target: _target,
          projectRootPath: _fuchsiaProjectPath,
168 169
          packagesFilePath: _dotPackagesPath,
        );
170
        printStatus('Connecting to $_modName');
171
        await hotRunner.attach();
172 173 174 175 176 177
      } finally {
        await Future.wait<void>(forwardedPorts.map<Future<void>>((_PortForwarder pf) => pf.stop()));
      }
    }, overrides: <Type, Generator>{
      Artifacts: () => OverrideArtifacts(parent: artifacts, frontendServer: _frontendServerSnapshot),
    });
178
    return null;
179 180
  }

181
  // A cache of VMService connections.
182
  final HashMap<int, VMService> _vmServiceCache = HashMap<int, VMService>();
183

184
  Future<VMService> _getVMService(int port) async {
185
    if (!_vmServiceCache.containsKey(port)) {
186
      final String addr = 'http://$ipv4Loopback:$port';
187
      final Uri uri = Uri.parse(addr);
188
      final VMService vmService = await VMService.connect(uri);
189 190 191 192 193 194 195 196
      _vmServiceCache[port] = vmService;
    }
    return _vmServiceCache[port];
  }

  Future<List<FlutterView>> _getViews(List<int> ports) async {
    final List<FlutterView> views = <FlutterView>[];
    for (int port in ports) {
197
      final VMService vmService = await _getVMService(port);
198
      await vmService.getVM();
199
      await vmService.refreshViews();
200 201 202 203 204 205
      views.addAll(vmService.vm.views);
    }
    return views;
  }

  // Find ports where there is a view isolate with the given name
206
  Future<List<int>> _filterPorts(List<int> ports, String viewFilter) async {
207
    printTrace('Looing for view $viewFilter');
208 209
    final List<int> result = <int>[];
    for (FlutterView v in await _getViews(ports)) {
210 211
      if (v.uiIsolate == null)
        continue;
212 213
      final Uri addr = v.owner.vmService.httpAddress;
      printTrace('At $addr, found view: ${v.uiIsolate.name}');
214
      if (v.uiIsolate.name.contains(viewFilter))
215
        result.add(addr.port);
216 217
    }
    return result;
218 219
  }

220
  String _vmServiceToString(VMService vmService, {int tabDepth = 0}) {
221
    final Uri addr = vmService.httpAddress;
222
    final String embedder = vmService.vm.embedder;
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
    final int numIsolates = vmService.vm.isolates.length;
    final String maxRSS = getSizeAsMB(vmService.vm.maxRSS);
    final String heapSize = getSizeAsMB(vmService.vm.heapAllocatedMemoryUsage);
    int totalNewUsed = 0;
    int totalNewCap = 0;
    int totalOldUsed = 0;
    int totalOldCap = 0;
    int totalExternal = 0;
    for (Isolate i in vmService.vm.isolates) {
      totalNewUsed += i.newSpace.used;
      totalNewCap += i.newSpace.capacity;
      totalOldUsed += i.oldSpace.used;
      totalOldCap += i.oldSpace.capacity;
      totalExternal += i.newSpace.external;
      totalExternal += i.oldSpace.external;
    }
    final String newUsed = getSizeAsMB(totalNewUsed);
    final String newCap = getSizeAsMB(totalNewCap);
    final String oldUsed = getSizeAsMB(totalOldUsed);
    final String oldCap = getSizeAsMB(totalOldCap);
    final String external = getSizeAsMB(totalExternal);
    final String tabs = '\t' * tabDepth;
    final String extraTabs = '\t' * (tabDepth + 1);
246
    final StringBuffer stringBuffer = StringBuffer(
247
      '$tabs${terminal.bolden('$embedder at $addr')}\n'
248 249 250 251 252 253 254 255 256 257 258 259 260
      '${extraTabs}RSS: $maxRSS\n'
      '${extraTabs}Native allocations: $heapSize\n'
      '${extraTabs}New Spaces: $newUsed of $newCap\n'
      '${extraTabs}Old Spaces: $oldUsed of $oldCap\n'
      '${extraTabs}External: $external\n'
      '${extraTabs}Isolates: $numIsolates\n'
    );
    for (Isolate isolate in vmService.vm.isolates) {
      stringBuffer.write(_isolateToString(isolate, tabDepth: tabDepth + 1));
    }
    return stringBuffer.toString();
  }

261
  String _isolateToString(Isolate isolate, {int tabDepth = 0}) {
262 263 264
    final Uri vmServiceAddr = isolate.owner.vmService.httpAddress;
    final String name = isolate.name;
    final String shortName = name.substring(0, name.indexOf('\$'));
265
    const String main = '\$main-';
266 267 268 269
    final String number = name.substring(name.indexOf(main) + main.length);

    // The Observatory requires somewhat non-standard URIs that the Uri class
    // can't build for us, so instead we build them by hand.
270 271 272
    final String isolateIdQuery = '?isolateId=isolates%2F$number';
    final String isolateAddr = '$vmServiceAddr/#/inspect$isolateIdQuery';
    final String debuggerAddr = '$vmServiceAddr/#/debugger$isolateIdQuery';
273 274 275 276 277 278 279 280 281 282 283 284 285

    final String newUsed = getSizeAsMB(isolate.newSpace.used);
    final String newCap = getSizeAsMB(isolate.newSpace.capacity);
    final String newFreq = '${isolate.newSpace.avgCollectionTime.inMilliseconds}ms';
    final String newPer = '${isolate.newSpace.avgCollectionPeriod.inSeconds}s';
    final String oldUsed = getSizeAsMB(isolate.oldSpace.used);
    final String oldCap = getSizeAsMB(isolate.oldSpace.capacity);
    final String oldFreq = '${isolate.oldSpace.avgCollectionTime.inMilliseconds}ms';
    final String oldPer = '${isolate.oldSpace.avgCollectionPeriod.inSeconds}s';
    final String external = getSizeAsMB(isolate.newSpace.external + isolate.oldSpace.external);
    final String tabs = '\t' * tabDepth;
    final String extraTabs = '\t' * (tabDepth + 1);
    return
286
      '$tabs${terminal.bolden(shortName)}\n'
287 288 289 290 291 292 293 294
      '${extraTabs}Isolate number: $number\n'
      '${extraTabs}Observatory: $isolateAddr\n'
      '${extraTabs}Debugger: $debuggerAddr\n'
      '${extraTabs}New gen: $newUsed used of $newCap, GC: $newFreq every $newPer\n'
      '${extraTabs}Old gen: $oldUsed used of $oldCap, GC: $oldFreq every $oldPer\n'
      '${extraTabs}External: $external\n';
  }

295
  Future<void> _listVMs(List<int> ports) async {
296
    for (int port in ports) {
297
      final VMService vmService = await _getVMService(port);
298
      await vmService.getVM();
299
      await vmService.refreshViews();
300
      printStatus(_vmServiceToString(vmService));
301 302 303
    }
  }

304
  Future<void> _validateArguments() async {
305 306
    final String fuchsiaBuildDir = argResults['build-dir'];
    final String gnTarget = argResults['gn-target'];
307 308 309 310 311
    _frontendServerSnapshot = fs.file(argResults['frontend-server']);

    if (!_frontendServerSnapshot.existsSync()) {
      throwToolExit('Must provide a frontend-server snapshot');
    }
312 313 314 315 316 317 318 319 320

    if (fuchsiaBuildDir != null) {
      if (gnTarget == null)
        throwToolExit('Must provide --gn-target when specifying --build-dir.');

      if (!_directoryExists(fuchsiaBuildDir))
        throwToolExit('Specified --build-dir "$fuchsiaBuildDir" does not exist.');

      _sshConfig = '$fuchsiaBuildDir/ssh-keys/ssh_config';
321
    }
322 323 324 325 326 327 328

    // If sshConfig path not available from the fuchsiaBuildDir, get from command line.
    _sshConfig ??= argResults['ssh-config'];
    if (_sshConfig == null)
      throwToolExit('Provide the path to the ssh config file with --ssh-config.');
    if (!_fileExists(_sshConfig))
      throwToolExit('Couldn\'t find ssh config file at $_sshConfig.');
329 330

    _address = argResults['address'];
331
    if (_address == null && fuchsiaBuildDir != null) {
332 333 334 335 336 337
      final ProcessResult result = await processManager.run(<String>['fx', 'netaddr', '--fuchsia']);
      if (result.exitCode == 0)
        _address = result.stdout.trim();
      else
        printStatus('netaddr failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}');
    }
338 339
    if (_address == null)
      throwToolExit('Give the address of the device running Fuchsia with --address.');
340

341 342
    _list = argResults['list'];
    if (_list) {
343
      // For --list, we only need the ssh config and device address.
344 345 346
      return;
    }

347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
    String projectRoot;
    if (gnTarget != null) {
      if (fuchsiaBuildDir == null)
        throwToolExit('Must provide --build-dir when specifying --gn-target.');

      final List<String> targetInfo = _extractPathAndName(gnTarget);
      projectRoot = targetInfo[0];
      _modName = targetInfo[1];
      _fuchsiaProjectPath = '$fuchsiaBuildDir/../../$projectRoot';
    } else if (argResults['path'] != null) {
      _fuchsiaProjectPath = argResults['path'];
    }

    if (_fuchsiaProjectPath == null)
      throwToolExit('Provide the mod project path with --path.');
362
    if (!_directoryExists(_fuchsiaProjectPath))
363
      throwToolExit('Cannot locate project at $_fuchsiaProjectPath.');
364 365

    final String relativeTarget = argResults['target'];
366 367 368 369 370
    if (relativeTarget == null)
      throwToolExit('Give the application entry point with --target.');
    _target = '$_fuchsiaProjectPath/$relativeTarget';
    if (!_fileExists(_target))
      throwToolExit('Couldn\'t find application entry point at $_target.');
371

372 373 374 375
    if (argResults['mod-name'] != null)
      _modName = argResults['mod-name'];
    if (_modName == null)
      throwToolExit('Provide the mod name with --mod-name.');
376

377 378 379 380 381 382 383 384
    if (argResults['dot-packages'] != null) {
      _dotPackagesPath = argResults['dot-packages'];
    } else if (fuchsiaBuildDir != null) {
      final String packagesFileName = '${_modName}_dart_library.packages';
      _dotPackagesPath = '$fuchsiaBuildDir/dartlang/gen/$projectRoot/$packagesFileName';
    }
    if (_dotPackagesPath == null)
      throwToolExit('Provide the .packages path with --dot-packages.');
385 386 387
    if (!_fileExists(_dotPackagesPath))
      throwToolExit('Couldn\'t find .packages file at $_dotPackagesPath.');

388 389 390 391 392 393
    final String isolateNumber = argResults['isolate-number'];
    if (isolateNumber == null) {
      _isolateNumber = '';
    } else {
      _isolateNumber = '-$isolateNumber';
    }
394 395 396 397
  }

  List<String> _extractPathAndName(String gnTarget) {
    final String errorMessage =
398 399
      'fuchsia_reload --target "$gnTarget" should have the form: '
      '"//path/to/app:name"';
400 401
    // Separate strings like //path/to/target:app into [path/to/target, app]
    final int lastColon = gnTarget.lastIndexOf(':');
402
    if (lastColon < 0)
403 404 405
      throwToolExit(errorMessage);
    final String name = gnTarget.substring(lastColon + 1);
    // Skip '//' and chop off after :
406
    if ((gnTarget.length < 3) || (gnTarget[0] != '/') || (gnTarget[1] != '/'))
407 408 409 410 411
      throwToolExit(errorMessage);
    final String path = gnTarget.substring(2, lastColon);
    return <String>[path, name];
  }

412 413 414 415
  Future<List<_PortForwarder>> _forwardPorts(List<int> remotePorts) async {
    final List<_PortForwarder> forwarders = <_PortForwarder>[];
    for (int port in remotePorts) {
      final _PortForwarder f =
416
          await _PortForwarder.start(_sshConfig, _address, port);
417 418 419
      forwarders.add(f);
    }
    return forwarders;
420 421
  }

422
  Future<List<int>> _getServicePorts() async {
423
    final FuchsiaDeviceCommandRunner runner =
424
        FuchsiaDeviceCommandRunner(_address, _sshConfig);
425
    final List<String> lsOutput = await runner.run('ls /tmp/dart.services');
426
    final List<int> ports = <int>[];
427 428 429 430 431 432
    if (lsOutput != null) {
      for (String s in lsOutput) {
        final String trimmed = s.trim();
        final int lastSpace = trimmed.lastIndexOf(' ');
        final String lastWord = trimmed.substring(lastSpace + 1);
        if ((lastWord != '.') && (lastWord != '..')) {
433 434

          final int value = int.tryParse(lastWord);
435 436 437
          if (value != null)
            ports.add(value);
        }
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
      }
    }
    return ports;
  }

  bool _directoryExists(String path) {
    final Directory d = fs.directory(path);
    return d.existsSync();
  }

  bool _fileExists(String path) {
    final File f = fs.file(path);
    return f.existsSync();
  }
}

454 455 456 457 458 459 460 461 462 463
// Instances of this class represent a running ssh tunnel from the host to a
// VM service running on a Fuchsia device. [process] is the ssh process running
// the tunnel and [port] is the local port.
class _PortForwarder {
  _PortForwarder._(this._remoteAddress,
                   this._remotePort,
                   this._localPort,
                   this._process,
                   this._sshConfig);

464 465 466 467 468 469
  final String _remoteAddress;
  final int _remotePort;
  final int _localPort;
  final Process _process;
  final String _sshConfig;

470 471 472 473 474 475 476 477 478
  int get port => _localPort;

  static Future<_PortForwarder> start(String sshConfig,
                                      String address,
                                      int remotePort) async {
    final int localPort = await _potentiallyAvailablePort();
    if (localPort == 0) {
      printStatus(
          '_PortForwarder failed to find a local port for $address:$remotePort');
479
      return _PortForwarder._(null, 0, 0, null, null);
480
    }
481
    const String dummyRemoteCommand = 'date';
482
    final List<String> command = <String>[
483 484
        'ssh', '-F', sshConfig, '-nNT', '-vvv', '-f',
        '-L', '$localPort:$ipv4Loopback:$remotePort', address, dummyRemoteCommand];
485 486
    printTrace("_PortForwarder running '${command.join(' ')}'");
    final Process process = await processManager.start(command);
487
    process.stderr
488 489
        .transform<String>(utf8.decoder)
        .transform<String>(const LineSplitter())
490
        .listen((String data) { printTrace(data); });
491
    // Best effort to print the exit code.
492
    process.exitCode.then<void>((int c) { // ignore: unawaited_futures
493 494 495
      printTrace("'${command.join(' ')}' exited with exit code $c");
    });
    printTrace('Set up forwarding from $localPort to $address:$remotePort');
496
    return _PortForwarder._(address, remotePort, localPort, process, sshConfig);
497 498
  }

499
  Future<void> stop() async {
500 501 502 503 504 505 506
    // Kill the original ssh process if it is still around.
    if (_process != null) {
      printTrace('_PortForwarder killing ${_process.pid} for port $_localPort');
      _process.kill();
    }
    // Cancel the forwarding request.
    final List<String> command = <String>[
507
        'ssh', '-F', _sshConfig, '-O', 'cancel', '-vvv',
508 509 510 511
        '-L', '$_localPort:$ipv4Loopback:$_remotePort', _remoteAddress];
    final ProcessResult result = await processManager.run(command);
    printTrace(command.join(' '));
    if (result.exitCode != 0) {
512
      printTrace('Command failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}');
513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530
    }
  }

  static Future<int> _potentiallyAvailablePort() async {
    int port = 0;
    ServerSocket s;
    try {
      s = await ServerSocket.bind(ipv4Loopback, 0);
      port = s.port;
    } catch (e) {
      // Failures are signaled by a return value of 0 from this function.
      printTrace('_potentiallyAvailablePort failed: $e');
    }
    if (s != null)
      await s.close();
    return port;
  }
}
531 532

class FuchsiaDeviceCommandRunner {
533 534
  FuchsiaDeviceCommandRunner(this._address, this._sshConfig);

535
  final String _address;
536
  final String _sshConfig;
537 538

  Future<List<String>> run(String command) async {
539
    final List<String> args = <String>['ssh', '-F', _sshConfig, _address, command];
540 541
    printTrace(args.join(' '));
    final ProcessResult result = await processManager.run(args);
542
    if (result.exitCode != 0) {
543
      printStatus('Command failed: $command\nstdout: ${result.stdout}\nstderr: ${result.stderr}');
544 545
      return null;
    }
546 547
    printTrace(result.stdout);
    return result.stdout.split('\n');
548 549
  }
}