| // 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 '../artifacts.dart'; |
| import '../base/common.dart'; |
| import '../base/context.dart'; |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/process_manager.dart'; |
| import '../base/terminal.dart'; |
| import '../base/utils.dart'; |
| import '../bundle.dart' as bundle; |
| import '../cache.dart'; |
| import '../context_runner.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. 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 |
| |
| final String ipv4Loopback = InternetAddress.loopbackIPv4.address; |
| |
| class FuchsiaReloadCommand extends FlutterCommand { |
| FuchsiaReloadCommand() { |
| addBuildModeFlags(defaultToRelease: false); |
| argParser.addOption('frontend-server', |
| abbr: 'f', |
| help: 'The frontend server location'); |
| 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('dot-packages', |
| abbr: 'd', |
| defaultsTo: null, |
| help: 'Path to the mod\'s .packages file. Required if no' |
| 'GN target specified.'); |
| 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('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.'); |
| argParser.addOption('target', |
| abbr: 't', |
| defaultsTo: bundle.defaultMainPath, |
| help: 'Target app path / main entry-point file. ' |
| 'Relative to --path or --gn-target path, e.g. lib/main.dart.'); |
| } |
| |
| @override |
| final String name = 'fuchsia_reload'; |
| |
| @override |
| final String description = 'Hot reload on Fuchsia.'; |
| |
| String _modName; |
| String _isolateNumber; |
| String _fuchsiaProjectPath; |
| String _target; |
| String _address; |
| String _dotPackagesPath; |
| String _sshConfig; |
| File _frontendServerSnapshot; |
| |
| bool _list; |
| |
| @override |
| Future<FlutterCommandResult> runCommand() async { |
| Cache.releaseLockEarly(); |
| await _validateArguments(); |
| 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'); |
| |
| // 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(); |
| |
| 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; |
| } |
| |
| // 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'); |
| |
| // 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, |
| viewFilter: isolateName, |
| ); |
| flutterDevice.observatoryUris = observatoryUris; |
| final HotRunner hotRunner = HotRunner(<FlutterDevice>[flutterDevice], |
| debuggingOptions: DebuggingOptions.enabled(getBuildInfo()), |
| target: _target, |
| projectRootPath: _fuchsiaProjectPath, |
| packagesFilePath: _dotPackagesPath); |
| printStatus('Connecting to $_modName'); |
| await hotRunner.attach(); |
| } finally { |
| await Future.wait<void>(forwardedPorts.map<Future<void>>((_PortForwarder pf) => pf.stop())); |
| } |
| }, overrides: <Type, Generator>{ |
| Artifacts: () => OverrideArtifacts(parent: artifacts, frontendServer: _frontendServerSnapshot), |
| }); |
| return null; |
| } |
| |
| // A cache of VMService connections. |
| final HashMap<int, VMService> _vmServiceCache = 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.refreshViews(); |
| 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; |
| } |
| |
| 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 = StringBuffer( |
| '$tabs${terminal.bolden('$embedder at $addr')}\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${terminal.bolden(shortName)}\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<void> _listVMs(List<int> ports) async { |
| for (int port in ports) { |
| final VMService vmService = await _getVMService(port); |
| await vmService.getVM(); |
| await vmService.refreshViews(); |
| printStatus(_vmServiceToString(vmService)); |
| } |
| } |
| |
| Future<void> _validateArguments() async { |
| final String fuchsiaBuildDir = argResults['build-dir']; |
| final String gnTarget = argResults['gn-target']; |
| _frontendServerSnapshot = fs.file(argResults['frontend-server']); |
| |
| if (!_frontendServerSnapshot.existsSync()) { |
| throwToolExit('Must provide a frontend-server snapshot'); |
| } |
| |
| 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'; |
| } |
| |
| // 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.'); |
| |
| _address = argResults['address']; |
| if (_address == null && fuchsiaBuildDir != 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 ssh config and device address. |
| return; |
| } |
| |
| 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.'); |
| if (!_directoryExists(_fuchsiaProjectPath)) |
| throwToolExit('Cannot locate project at $_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.'); |
| |
| if (argResults['mod-name'] != null) |
| _modName = argResults['mod-name']; |
| if (_modName == null) |
| throwToolExit('Provide the mod name with --mod-name.'); |
| |
| 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.'); |
| 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 List<_PortForwarder> forwarders = <_PortForwarder>[]; |
| for (int port in remotePorts) { |
| final _PortForwarder f = |
| await _PortForwarder.start(_sshConfig, _address, port); |
| forwarders.add(f); |
| } |
| return forwarders; |
| } |
| |
| Future<List<int>> _getServicePorts() async { |
| final FuchsiaDeviceCommandRunner runner = |
| FuchsiaDeviceCommandRunner(_address, _sshConfig); |
| 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 { |
| _PortForwarder._(this._remoteAddress, |
| this._remotePort, |
| this._localPort, |
| this._process, |
| this._sshConfig); |
| |
| final String _remoteAddress; |
| final int _remotePort; |
| final int _localPort; |
| final Process _process; |
| final String _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 _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<String>(utf8.decoder) |
| .transform<String>(const LineSplitter()) |
| .listen((String data) { printTrace(data); }); |
| // Best effort to print the exit code. |
| process.exitCode.then<void>((int c) { // ignore: unawaited_futures |
| printTrace("'${command.join(' ')}' exited with exit code $c"); |
| }); |
| printTrace('Set up forwarding from $localPort to $address:$remotePort'); |
| return _PortForwarder._(address, remotePort, localPort, process, sshConfig); |
| } |
| |
| Future<void> 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 { |
| FuchsiaDeviceCommandRunner(this._address, this._sshConfig); |
| |
| final String _address; |
| final String _sshConfig; |
| |
| Future<List<String>> run(String command) async { |
| final List<String> args = <String>['ssh', '-F', _sshConfig, _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'); |
| } |
| } |