// 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'; import 'dart:collection'; import 'dart:convert'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/process_manager.dart'; import '../base/utils.dart'; import '../bundle.dart' as bundle; import '../cache.dart'; import '../device.dart'; import '../fuchsia/fuchsia_device.dart'; import '../globals.dart'; import '../resident_runner.dart'; import '../run_hot.dart'; import '../runner/flutter_command.dart'; import '../vmservice.dart'; // Usage: // With e.g. flutter_gallery already running, a HotRunner can be attached to it // with: // $ flutter fuchsia_reload -f ~/fuchsia -a 192.168.1.39 \ // -g //lib/flutter/examples/flutter_gallery:flutter_gallery final String ipv4Loopback = InternetAddress.loopbackIPv4.address; class FuchsiaReloadCommand extends FlutterCommand { FuchsiaReloadCommand() { addBuildModeFlags(defaultToRelease: false); argParser.addOption('address', abbr: 'a', help: 'Fuchsia device network name or address.'); argParser.addOption('build-dir', abbr: 'b', defaultsTo: null, help: 'Fuchsia build directory, e.g. out/release-x86-64.'); argParser.addOption('gn-target', abbr: 'g', help: 'GN target of the application, e.g //path/to/app:app.'); 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.'); argParser.addFlag('list', abbr: 'l', defaultsTo: false, help: 'Lists the running modules. '); argParser.addOption('name-override', abbr: 'n', help: 'On-device name of the application binary.'); argParser.addOption('target', abbr: 't', defaultsTo: bundle.defaultMainPath, help: 'Target app path / main entry-point file. ' 'Relative to --gn-target path, e.g. lib/main.dart.'); } @override final String name = 'fuchsia_reload'; @override final String description = 'Hot reload on Fuchsia.'; String _buildDir; String _projectRoot; String _projectName; String _binaryName; String _isolateNumber; String _fuchsiaProjectPath; String _target; String _address; String _dotPackagesPath; bool _list; @override Future<Null> runCommand() async { Cache.releaseLockEarly(); await _validateArguments(); // 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'); // 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( (_PortForwarder pf) => pf.port).toList(); 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; } // Check that there are running VM services on the returned // ports, and find the Isolates that are running the target app. final String isolateName = '$_binaryName\$main$_isolateNumber'; final List<int> targetPorts = await _filterPorts( servicePorts, isolateName); if (targetPorts.isEmpty) throwToolExit('No VMs found running $_binaryName.'); for (int port in targetPorts) printTrace('Found $_binaryName at $port'); // 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( (int p) => '$ipv4Loopback:$p' ).toList(); final List<Uri> observatoryUris = fullAddresses.map( (String a) => Uri.parse('http://$a') ).toList(); final FuchsiaDevice device = new FuchsiaDevice( fullAddresses[0], name: _address); final FlutterDevice flutterDevice = new FlutterDevice( device, trackWidgetCreation: false, ); flutterDevice.observatoryUris = observatoryUris; final HotRunner hotRunner = new HotRunner( <FlutterDevice>[flutterDevice], debuggingOptions: new DebuggingOptions.enabled(getBuildInfo()), target: _target, projectRootPath: _fuchsiaProjectPath, packagesFilePath: _dotPackagesPath ); printStatus('Connecting to $_binaryName'); await hotRunner.attach(viewFilter: isolateName); } finally { await Future.wait(forwardedPorts.map((_PortForwarder pf) => pf.stop())); } } // A cache of VMService connections. final HashMap<int, VMService> _vmServiceCache = new HashMap<int, VMService>(); Future<VMService> _getVMService(int port) async { if (!_vmServiceCache.containsKey(port)) { final String addr = 'http://$ipv4Loopback:$port'; final Uri uri = Uri.parse(addr); final VMService vmService = await VMService.connect(uri); _vmServiceCache[port] = vmService; } return _vmServiceCache[port]; } Future<List<FlutterView>> _getViews(List<int> ports) async { final List<FlutterView> views = <FlutterView>[]; for (int port in ports) { final VMService vmService = await _getVMService(port); await vmService.getVM(); await vmService.waitForViews(); views.addAll(vmService.vm.views); } return views; } // Find ports where there is a view isolate with the given name Future<List<int>> _filterPorts(List<int> ports, String viewFilter) async { printTrace('Looing for view $viewFilter'); final List<int> result = <int>[]; for (FlutterView v in await _getViews(ports)) { if (v.uiIsolate == null) continue; final Uri addr = v.owner.vmService.httpAddress; printTrace('At $addr, found view: ${v.uiIsolate.name}'); if (v.uiIsolate.name.contains(viewFilter)) result.add(addr.port); } return result; } static const String _bold = '\u001B[0;1m'; static const String _reset = '\u001B[0m'; String _vmServiceToString(VMService vmService, {int tabDepth = 0}) { final Uri addr = vmService.httpAddress; final String embedder = vmService.vm.embedder; 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); final StringBuffer stringBuffer = new StringBuffer( '$tabs$_bold$embedder at $addr$_reset\n' '${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(); } String _isolateToString(Isolate isolate, {int tabDepth = 0}) { final Uri vmServiceAddr = isolate.owner.vmService.httpAddress; final String name = isolate.name; final String shortName = name.substring(0, name.indexOf('\$')); const String main = '\$main-'; 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. final String isolateIdQuery = '?isolateId=isolates%2F$number'; final String isolateAddr = '$vmServiceAddr/#/inspect$isolateIdQuery'; final String debuggerAddr = '$vmServiceAddr/#/debugger$isolateIdQuery'; 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 '$tabs$_bold$shortName$_reset\n' '${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'; } Future<Null> _listVMs(List<int> ports) async { for (int port in ports) { final VMService vmService = await _getVMService(port); await vmService.getVM(); await vmService.waitForViews(); printStatus(_vmServiceToString(vmService)); } } Future<Null> _validateArguments() async { _buildDir = argResults['build-dir']; if (_buildDir == null) { final ProcessResult result = await processManager.run(<String>['fx', 'get-build-dir']); if (result.exitCode == 0) _buildDir = result.stdout.trim(); else printStatus('get-build-dir failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}'); } if (!_directoryExists(_buildDir)) throwToolExit('Specified --build-dir "$_buildDir" does not exist.'); _address = argResults['address']; if (_address == null) { 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}'); } if (_address == null) throwToolExit('Give the address of the device running Fuchsia with --address.'); _list = argResults['list']; if (_list) { // For --list, we only need the device address and the Fuchsia tree root. return; } final String gnTarget = argResults['gn-target']; if (gnTarget == null) throwToolExit('Give the GN target with --gn-target(-g).'); final List<String> targetInfo = _extractPathAndName(gnTarget); _projectRoot = targetInfo[0]; _projectName = targetInfo[1]; _fuchsiaProjectPath = '$_buildDir/../../$_projectRoot'; if (!_directoryExists(_fuchsiaProjectPath)) throwToolExit('Target does not exist in the Fuchsia tree: $_fuchsiaProjectPath.'); final String relativeTarget = argResults['target']; 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.'); final String nameOverride = argResults['name-override']; if (nameOverride == null) { _binaryName = _projectName; } else { _binaryName = nameOverride; } // When there's an override of the on-device binary name, use that name // to locate the .packages file. final String packagesFileName = '${_binaryName}_dart_library.packages'; _dotPackagesPath = '$_buildDir/dartlang/gen/$_projectRoot/$packagesFileName'; if (!_fileExists(_dotPackagesPath)) throwToolExit('Couldn\'t find .packages file at $_dotPackagesPath.'); final String isolateNumber = argResults['isolate-number']; if (isolateNumber == null) { _isolateNumber = ''; } else { _isolateNumber = '-$isolateNumber'; } } List<String> _extractPathAndName(String gnTarget) { final String errorMessage = 'fuchsia_reload --target "$gnTarget" should have the form: ' '"//path/to/app:name"'; // Separate strings like //path/to/target:app into [path/to/target, app] final int lastColon = gnTarget.lastIndexOf(':'); if (lastColon < 0) throwToolExit(errorMessage); final String name = gnTarget.substring(lastColon + 1); // Skip '//' and chop off after : if ((gnTarget.length < 3) || (gnTarget[0] != '/') || (gnTarget[1] != '/')) throwToolExit(errorMessage); final String path = gnTarget.substring(2, lastColon); return <String>[path, name]; } Future<List<_PortForwarder>> _forwardPorts(List<int> remotePorts) async { final String config = '$_buildDir/ssh-keys/ssh_config'; final List<_PortForwarder> forwarders = <_PortForwarder>[]; for (int port in remotePorts) { final _PortForwarder f = await _PortForwarder.start(config, _address, port); forwarders.add(f); } return forwarders; } Future<List<int>> _getServicePorts() async { final FuchsiaDeviceCommandRunner runner = new FuchsiaDeviceCommandRunner(_address, _buildDir); final List<String> lsOutput = await runner.run('ls /tmp/dart.services'); final List<int> ports = <int>[]; 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 != '..')) { final int value = int.tryParse(lastWord); if (value != null) ports.add(value); } } } 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(); } } // 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 { final String _remoteAddress; final int _remotePort; final int _localPort; final Process _process; final String _sshConfig; _PortForwarder._(this._remoteAddress, this._remotePort, this._localPort, this._process, this._sshConfig); 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'); return new _PortForwarder._(null, 0, 0, null, null); } const String dummyRemoteCommand = 'date'; final List<String> command = <String>[ 'ssh', '-F', sshConfig, '-nNT', '-vvv', '-f', '-L', '$localPort:$ipv4Loopback:$remotePort', address, dummyRemoteCommand]; printTrace("_PortForwarder running '${command.join(' ')}'"); final Process process = await processManager.start(command); process.stderr .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String data) { printTrace(data); }); // Best effort to print the exit code. process.exitCode.then((int c) { // ignore: unawaited_futures printTrace("'${command.join(' ')}' exited with exit code $c"); }); printTrace('Set up forwarding from $localPort to $address:$remotePort'); return new _PortForwarder._(address, remotePort, localPort, process, sshConfig); } Future<Null> stop() async { // 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>[ 'ssh', '-F', _sshConfig, '-O', 'cancel', '-vvv', '-L', '$_localPort:$ipv4Loopback:$_remotePort', _remoteAddress]; final ProcessResult result = await processManager.run(command); printTrace(command.join(' ')); if (result.exitCode != 0) { printTrace('Command failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}'); } } 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; } } class FuchsiaDeviceCommandRunner { final String _address; final String _buildDir; FuchsiaDeviceCommandRunner(this._address, this._buildDir); Future<List<String>> run(String command) async { final String config = '$_buildDir/ssh-keys/ssh_config'; final List<String> args = <String>['ssh', '-F', config, _address, command]; printTrace(args.join(' ')); final ProcessResult result = await processManager.run(args); if (result.exitCode != 0) { printStatus('Command failed: $command\nstdout: ${result.stdout}\nstderr: ${result.stderr}'); return null; } printTrace(result.stdout); return result.stdout.split('\n'); } }