// Copyright 2016 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 'package:json_rpc_2/error_code.dart' as rpc_error_code; import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:meta/meta.dart'; import 'base/context.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; import 'base/utils.dart'; import 'build_info.dart'; import 'dart/dependencies.dart'; import 'device.dart'; import 'globals.dart'; import 'resident_runner.dart'; import 'usage.dart'; import 'vmservice.dart'; class HotRunnerConfig { /// Should the hot runner compute the minimal Dart dependencies? bool computeDartDependencies = true; /// Should the hot runner assume that the minimal Dart dependencies do not change? bool stableDartDependencies = false; } HotRunnerConfig get hotRunnerConfig => context[HotRunnerConfig]; const bool kHotReloadDefault = true; class HotRunner extends ResidentRunner { HotRunner( List<FlutterDevice> devices, { String target, DebuggingOptions debuggingOptions, bool usesTerminalUI: true, this.benchmarkMode: false, this.applicationBinary, this.hostIsIde: false, String projectRootPath, String packagesFilePath, this.dillOutputPath, bool stayResident: true, bool ipv6: false, }) : super(devices, target: target, debuggingOptions: debuggingOptions, usesTerminalUI: usesTerminalUI, projectRootPath: projectRootPath, packagesFilePath: packagesFilePath, stayResident: stayResident, ipv6: ipv6); final bool benchmarkMode; final String applicationBinary; final bool hostIsIde; Set<String> _dartDependencies; final String dillOutputPath; final Map<String, List<int>> benchmarkData = <String, List<int>>{}; // The initial launch is from a snapshot. bool _runningFromSnapshot = true; DateTime firstBuildTime; void _addBenchmarkData(String name, int value) { benchmarkData[name] ??= <int>[]; benchmarkData[name].add(value); } bool _refreshDartDependencies() { if (!hotRunnerConfig.computeDartDependencies) { // Disabled. return true; } if (_dartDependencies != null) { // Already computed. return true; } final DartDependencySetBuilder dartDependencySetBuilder = new DartDependencySetBuilder(mainPath, packagesFilePath); try { _dartDependencies = new Set<String>.from(dartDependencySetBuilder.build()); } on DartDependencyException catch (error) { printError( 'Your application could not be compiled, because its dependencies could not be established.\n' '$error' ); return false; } return true; } Future<Null> _reloadSourcesService(String isolateId, { bool force: false, bool pause: false }) async { // TODO(cbernaschina): check that isolateId is the id of the UI isolate. final OperationResult result = await restart(pauseAfterRestart: pause); if (!result.isOk) { throw new rpc.RpcException( rpc_error_code.INTERNAL_ERROR, 'Unable to reload sources', ); } } Future<int> attach({ Completer<DebugConnectionInfo> connectionInfoCompleter, Completer<Null> appStartedCompleter, String viewFilter, }) async { try { await connectToServiceProtocol(viewFilter: viewFilter, reloadSources: _reloadSourcesService); } catch (error) { printError('Error connecting to the service protocol: $error'); return 2; } for (FlutterDevice device in flutterDevices) device.initLogReader(); try { final List<Uri> baseUris = await _initDevFS(); if (connectionInfoCompleter != null) { // Only handle one debugger connection. connectionInfoCompleter.complete( new DebugConnectionInfo( httpUri: flutterDevices.first.observatoryUris.first, wsUri: flutterDevices.first.vmServices.first.wsAddress, baseUri: baseUris.first.toString() ) ); } } catch (error) { printError('Error initializing DevFS: $error'); return 3; } final Stopwatch initialUpdateDevFSsTimer = new Stopwatch()..start(); final bool devfsResult = await _updateDevFS(); _addBenchmarkData('hotReloadInitialDevFSSyncMilliseconds', initialUpdateDevFSsTimer.elapsed.inMilliseconds); if (!devfsResult) return 3; await refreshViews(); for (FlutterDevice device in flutterDevices) { // VM must have accepted the kernel binary, there will be no reload // report, so we let incremental compiler know that source code was accepted. if (device.generator != null) device.generator.accept(); for (FlutterView view in device.views) printTrace('Connected to $view.'); } if (stayResident) { setupTerminal(); registerSignalHandlers(); } appStartedCompleter?.complete(); if (benchmarkMode) { // We are running in benchmark mode. printStatus('Running in benchmark mode.'); // Measure time to perform a hot restart. printStatus('Benchmarking hot restart'); await restart(fullRestart: true); // TODO(johnmccutchan): Modify script entry point. printStatus('Benchmarking hot reload'); // Measure time to perform a hot reload. await restart(fullRestart: false); if (stayResident) { await waitForAppToFinish(); } else { printStatus('Benchmark completed. Exiting application.'); await _cleanupDevFS(); await stopEchoingDeviceLog(); await stopApp(); } final File benchmarkOutput = fs.file('hot_benchmark.json'); benchmarkOutput.writeAsStringSync(toPrettyJson(benchmarkData)); return 0; } if (stayResident) return waitForAppToFinish(); await cleanupAtFinish(); return 0; } @override Future<int> run({ Completer<DebugConnectionInfo> connectionInfoCompleter, Completer<Null> appStartedCompleter, String route, bool shouldBuild: true }) async { if (!fs.isFileSync(mainPath)) { String message = 'Tried to run $mainPath, but that file does not exist.'; if (target == null) message += '\nConsider using the -t option to specify the Dart file to start.'; printError(message); return 1; } // Determine the Dart dependencies eagerly. if (!_refreshDartDependencies()) { // Some kind of source level error or missing file in the Dart code. return 1; } firstBuildTime = new DateTime.now(); for (FlutterDevice device in flutterDevices) { final int result = await device.runHot( hotRunner: this, route: route, shouldBuild: shouldBuild, ); if (result != 0) { return result; } } return attach( connectionInfoCompleter: connectionInfoCompleter, appStartedCompleter: appStartedCompleter ); } @override Future<Null> handleTerminalCommand(String code) async { final String lower = code.toLowerCase(); if (lower == 'r') { final OperationResult result = await restart(fullRestart: code == 'R'); if (!result.isOk) { // TODO(johnmccutchan): Attempt to determine the number of errors that // occurred and tighten this message. printStatus('Try again after fixing the above error(s).', emphasis: true); } } } Future<List<Uri>> _initDevFS() async { final String fsName = fs.path.basename(projectRootPath); final List<Uri> devFSUris = <Uri>[]; for (FlutterDevice device in flutterDevices) { final Uri uri = await device.setupDevFS( fsName, fs.directory(projectRootPath), packagesFilePath: packagesFilePath ); devFSUris.add(uri); } return devFSUris; } Future<bool> _updateDevFS({ bool fullRestart: false }) async { if (!_refreshDartDependencies()) { // Did not update DevFS because of a Dart source error. return false; } final bool isFirstUpload = assetBundle.wasBuiltOnce() == false; final bool rebuildBundle = assetBundle.needsBuild(); if (rebuildBundle) { printTrace('Updating assets'); final int result = await assetBundle.build(); if (result != 0) return false; } for (FlutterDevice device in flutterDevices) { final bool result = await device.updateDevFS( mainPath: mainPath, target: target, bundle: assetBundle, firstBuildTime: firstBuildTime, bundleFirstUpload: isFirstUpload, bundleDirty: isFirstUpload == false && rebuildBundle, fileFilter: _dartDependencies, fullRestart: fullRestart, projectRootPath: projectRootPath, ); if (!result) return false; } if (!hotRunnerConfig.stableDartDependencies) { // Clear the set after the sync so they are recomputed next time. _dartDependencies = null; } return true; } Future<Null> _evictDirtyAssets() async { for (FlutterDevice device in flutterDevices) { if (device.devFS.assetPathsToEvict.isEmpty) return; if (device.views.first.uiIsolate == null) throw 'Application isolate not found'; for (String assetPath in device.devFS.assetPathsToEvict) await device.views.first.uiIsolate.flutterEvictAsset(assetPath); device.devFS.assetPathsToEvict.clear(); } } void _resetDirtyAssets() { for (FlutterDevice device in flutterDevices) device.devFS.assetPathsToEvict.clear(); } Future<Null> _cleanupDevFS() async { for (FlutterDevice device in flutterDevices) { if (device.devFS != null) { // Cleanup the devFS; don't wait indefinitely, and ignore any errors. await device.devFS.destroy() .timeout(const Duration(milliseconds: 250)) .catchError((dynamic error) { printTrace('$error'); }); } device.devFS = null; } } Future<Null> _launchInView(FlutterDevice device, Uri entryUri, Uri packagesUri, Uri assetsDirectoryUri) async { for (FlutterView view in device.views) await view.runFromSource(entryUri, packagesUri, assetsDirectoryUri); } Future<Null> _launchFromDevFS(String mainScript) async { final String entryUri = fs.path.relative(mainScript, from: projectRootPath); for (FlutterDevice device in flutterDevices) { final Uri deviceEntryUri = device.devFS.baseUri.resolveUri( fs.path.toUri(entryUri)); final Uri devicePackagesUri = device.devFS.baseUri.resolve('.packages'); final Uri deviceAssetsDirectoryUri = device.devFS.baseUri.resolveUri( fs.path.toUri(getAssetBuildDirectory())); await _launchInView(device, deviceEntryUri, devicePackagesUri, deviceAssetsDirectoryUri); if (benchmarkMode) { for (FlutterDevice device in flutterDevices) for (FlutterView view in device.views) await view.flushUIThreadTasks(); } } } Future<OperationResult> _restartFromSources() async { if (!_isPaused()) { printTrace('Refreshing active FlutterViews before restarting.'); await refreshViews(); } final Stopwatch restartTimer = new Stopwatch()..start(); // TODO(aam): Add generator reset logic once we switch to using incremental // compiler for full application recompilation on restart. final bool updatedDevFS = await _updateDevFS(fullRestart: true); if (!updatedDevFS) { for (FlutterDevice device in flutterDevices) { if (device.generator != null) device.generator.reject(); } return new OperationResult(1, 'DevFS synchronization failed'); } _resetDirtyAssets(); for (FlutterDevice device in flutterDevices) { // VM must have accepted the kernel binary, there will be no reload // report, so we let incremental compiler know that source code was accepted. if (device.generator != null) device.generator.accept(); } // Check if the isolate is paused and resume it. for (FlutterDevice device in flutterDevices) { for (FlutterView view in device.views) { if (view.uiIsolate != null) { // Reload the isolate. await view.uiIsolate.reload(); final ServiceEvent pauseEvent = view.uiIsolate.pauseEvent; if ((pauseEvent != null) && pauseEvent.isPauseEvent) { // Resume the isolate so that it can be killed by the embedder. await view.uiIsolate.resume(); } } } } // We are now running from source. _runningFromSnapshot = false; final String launchPath = debuggingOptions.buildInfo.previewDart2 ? mainPath + '.dill' : mainPath; await _launchFromDevFS(launchPath); restartTimer.stop(); printTrace('Restart performed in ' '${getElapsedAsMilliseconds(restartTimer.elapsed)}.'); // We are now running from sources. _runningFromSnapshot = false; _addBenchmarkData('hotRestartMillisecondsToFrame', restartTimer.elapsed.inMilliseconds); flutterUsage.sendEvent('hot', 'restart'); flutterUsage.sendTiming('hot', 'restart', restartTimer.elapsed); return OperationResult.ok; } /// Returns [true] if the reload was successful. /// Prints errors if [printErrors] is [true]. static bool validateReloadReport(Map<String, dynamic> reloadReport, { bool printErrors: true }) { if (reloadReport['type'] != 'ReloadReport') { if (printErrors) printError('Hot reload received invalid response: $reloadReport'); return false; } if (!reloadReport['success']) { if (printErrors) { printError('Hot reload was rejected:'); for (Map<String, dynamic> notice in reloadReport['details']['notices']) printError('${notice['message']}'); } return false; } return true; } @override bool get supportsRestart => true; @override Future<OperationResult> restart({ bool fullRestart: false, bool pauseAfterRestart: false }) async { if (fullRestart) { final Status status = logger.startProgress( 'Performing full restart...', progressId: 'hot.restart' ); try { final Stopwatch timer = new Stopwatch()..start(); await _restartFromSources(); timer.stop(); status.cancel(); printStatus('Restarted app in ${getElapsedAsMilliseconds(timer.elapsed)}.'); return OperationResult.ok; } catch (error) { status.cancel(); rethrow; } } else { final bool reloadOnTopOfSnapshot = _runningFromSnapshot; final String progressPrefix = reloadOnTopOfSnapshot ? 'Initializing' : 'Performing'; final Status status = logger.startProgress( '$progressPrefix hot reload...', progressId: 'hot.reload' ); try { final Stopwatch timer = new Stopwatch()..start(); final OperationResult result = await _reloadSources(pause: pauseAfterRestart); timer.stop(); status.cancel(); if (result.isOk) printStatus('${result.message} in ${getElapsedAsMilliseconds(timer.elapsed)}.'); if (result.hintMessage != null) printStatus('\n${result.hintMessage}'); return result; } catch (error) { status.cancel(); rethrow; } } } String _uriToRelativePath(Uri uri) { final String path = uri.toString(); final String base = new Uri.file(projectRootPath).toString(); if (path.startsWith(base)) return path.substring(base.length + 1); return path; } Future<OperationResult> _reloadSources({ bool pause: false }) async { for (FlutterDevice device in flutterDevices) { for (FlutterView view in device.views) { if (view.uiIsolate == null) throw 'Application isolate not found'; } } if (!_isPaused()) { printTrace('Refreshing active FlutterViews before reloading.'); await refreshViews(); } // The initial launch is from a script snapshot. When we reload from source // on top of a script snapshot, the first reload will be a worst case reload // because all of the sources will end up being dirty (library paths will // change from host path to a device path). Subsequent reloads will // not be affected, so we resume reporting reload times on the second // reload. final bool shouldReportReloadTime = !_runningFromSnapshot; final Stopwatch reloadTimer = new Stopwatch()..start(); final Stopwatch devFSTimer = new Stopwatch()..start(); final bool updatedDevFS = await _updateDevFS(); // Record time it took to synchronize to DevFS. _addBenchmarkData('hotReloadDevFSSyncMilliseconds', devFSTimer.elapsed.inMilliseconds); if (!updatedDevFS) return new OperationResult(1, 'DevFS synchronization failed'); String reloadMessage; final Stopwatch vmReloadTimer = new Stopwatch()..start(); try { final String entryPath = fs.path.relative( debuggingOptions.buildInfo.previewDart2 ? mainPath + '.dill' : mainPath, from: projectRootPath, ); final Completer<Map<String, dynamic>> retrieveFirstReloadReport = new Completer<Map<String, dynamic>>(); int countExpectedReports = 0; for (FlutterDevice device in flutterDevices) { if (_runningFromSnapshot) { // Asset directory has to be set only once when we switch from // running from snapshot to running from uploaded files. await device.resetAssetDirectory(); } // List has one report per Flutter view. final List<Future<Map<String, dynamic>>> reports = device.reloadSources( entryPath, pause: pause ); countExpectedReports += reports.length; Future.wait(reports).then((List<Map<String, dynamic>> list) { // TODO(aam): Investigate why we are validating only first reload report, // which seems to be current behavior final Map<String, dynamic> firstReport = list.first; // Don't print errors because they will be printed further down when // `validateReloadReport` is called again. device.updateReloadStatus(validateReloadReport(firstReport, printErrors: false)); if (!retrieveFirstReloadReport.isCompleted) retrieveFirstReloadReport.complete(firstReport); }, onError: (dynamic error, StackTrace stack) { retrieveFirstReloadReport.completeError(error, stack); }); } if (countExpectedReports == 0) { printError('Unable to hot reload. No instance of Flutter is currently running.'); return new OperationResult(1, 'No instances running'); } final Map<String, dynamic> reloadReport = await retrieveFirstReloadReport.future; if (!validateReloadReport(reloadReport)) { // Reload failed. flutterUsage.sendEvent('hot', 'reload-reject'); return new OperationResult(1, 'Reload rejected'); } else { flutterUsage.sendEvent('hot', 'reload'); final int loadedLibraryCount = reloadReport['details']['loadedLibraryCount']; final int finalLibraryCount = reloadReport['details']['finalLibraryCount']; printTrace('reloaded $loadedLibraryCount of $finalLibraryCount libraries'); reloadMessage = 'Reloaded $loadedLibraryCount of $finalLibraryCount libraries'; } } on Map<String, dynamic> catch (error, st) { printError('Hot reload failed: $error\n$st'); final int errorCode = error['code']; final String errorMessage = error['message']; if (errorCode == Isolate.kIsolateReloadBarred) { printError('Unable to hot reload app due to an unrecoverable error in ' 'the source code. Please address the error and then use ' '"R" to restart the app.'); flutterUsage.sendEvent('hot', 'reload-barred'); return new OperationResult(errorCode, errorMessage); } printError('Hot reload failed:\ncode = $errorCode\nmessage = $errorMessage\n$st'); return new OperationResult(errorCode, errorMessage); } catch (error, st) { printError('Hot reload failed: $error\n$st'); return new OperationResult(1, '$error'); } // Record time it took for the VM to reload the sources. _addBenchmarkData('hotReloadVMReloadMilliseconds', vmReloadTimer.elapsed.inMilliseconds); final Stopwatch reassembleTimer = new Stopwatch()..start(); // Reload the isolate. for (FlutterDevice device in flutterDevices) { printTrace('Sending reload events to ${device.device.name}'); for (FlutterView view in device.views) { printTrace('Sending reload event to "${view.uiIsolate.name}"'); await view.uiIsolate.reload(); } await device.refreshViews(); } // We are now running from source. _runningFromSnapshot = false; // Check if the isolate is paused. final List<FlutterView> reassembleViews = <FlutterView>[]; for (FlutterDevice device in flutterDevices) { for (FlutterView view in device.views) { // Check if the isolate is paused, and if so, don't reassemble. Ignore the // PostPauseEvent event - the client requesting the pause will resume the app. final ServiceEvent pauseEvent = view.uiIsolate.pauseEvent; if (pauseEvent != null && pauseEvent.isPauseEvent && pauseEvent.kind != ServiceEvent.kPausePostRequest) { continue; } reassembleViews.add(view); } } if (reassembleViews.isEmpty) { printTrace('Skipping reassemble because all isolates are paused.'); return new OperationResult(OperationResult.ok.code, reloadMessage); } printTrace('Evicting dirty assets'); await _evictDirtyAssets(); printTrace('Reassembling application'); bool reassembleAndScheduleErrors = false; bool reassembleTimedOut = false; for (FlutterView view in reassembleViews) { try { await view.uiIsolate.flutterReassemble(); } on TimeoutException { reassembleTimedOut = true; printTrace('Reassembling ${view.uiIsolate.name} took too long.'); printStatus('Hot reloading ${view.uiIsolate.name} took too long; the reload may have failed.'); continue; } catch (error) { reassembleAndScheduleErrors = true; printError('Reassembling ${view.uiIsolate.name} failed: $error'); continue; } try { /* ensure that a frame is scheduled */ await view.uiIsolate.uiWindowScheduleFrame(); } catch (error) { reassembleAndScheduleErrors = true; printError('Scheduling a frame for ${view.uiIsolate.name} failed: $error'); } } // Record time it took for Flutter to reassemble the application. _addBenchmarkData('hotReloadFlutterReassembleMilliseconds', reassembleTimer.elapsed.inMilliseconds); reloadTimer.stop(); printTrace('Hot reload performed in ' '${getElapsedAsMilliseconds(reloadTimer.elapsed)}.'); // Record complete time it took for the reload. _addBenchmarkData('hotReloadMillisecondsToFrame', reloadTimer.elapsed.inMilliseconds); // Only report timings if we reloaded a single view without any // errors or timeouts. if ((reassembleViews.length == 1) && !reassembleAndScheduleErrors && !reassembleTimedOut && shouldReportReloadTime) flutterUsage.sendTiming('hot', 'reload', reloadTimer.elapsed); String unusedElementMessage; if (!reassembleAndScheduleErrors && !reassembleTimedOut) { final List<Future<List<ProgramElement>>> unusedReports = <Future<List<ProgramElement>>>[]; for (FlutterDevice device in flutterDevices) unusedReports.add(device.unusedChangesInLastReload()); final List<ProgramElement> unusedElements = <ProgramElement>[]; for (Future<List<ProgramElement>> unusedReport in unusedReports) unusedElements.addAll(await unusedReport); if (unusedElements.isNotEmpty) { final String restartCommand = hostIsIde ? '' : ' (by pressing "R")'; unusedElementMessage = 'Some program elements were changed during reload but did not run when the view was reassembled;\n' 'you may need to restart the app$restartCommand for the changes to have an effect.'; for (ProgramElement unusedElement in unusedElements) { final String name = unusedElement.qualifiedName; final String path = _uriToRelativePath(unusedElement.uri); final int line = unusedElement.line; final String description = line == null ? '$name ($path)' : '$name ($path:$line)'; unusedElementMessage += '\n • $description'; } } } return new OperationResult( reassembleAndScheduleErrors ? 1 : OperationResult.ok.code, reloadMessage, hintMessage: unusedElementMessage, hintId: unusedElementMessage != null ? 'restartRecommended' : null, ); } bool _isPaused() { for (FlutterDevice device in flutterDevices) { for (FlutterView view in device.views) { if (view.uiIsolate != null) { final ServiceEvent pauseEvent = view.uiIsolate.pauseEvent; if (pauseEvent != null && pauseEvent.isPauseEvent) { return true; } } } } return false; } @override void printHelp({ @required bool details }) { const String fire = '🔥'; const String red = '\u001B[31m'; const String bold = '\u001B[0;1m'; const String reset = '\u001B[0m'; printStatus( '$fire To hot reload your app on the fly, press "r". To restart the app entirely, press "R".', ansiAlternative: '$red$fire$bold To hot reload your app on the fly, ' 'press "r". To restart the app entirely, press "R".$reset' ); for (FlutterDevice device in flutterDevices) { final String dname = device.device.name; for (Uri uri in device.observatoryUris) printStatus('An Observatory debugger and profiler on $dname is available at: $uri'); } if (details) { printHelpDetails(); printStatus('To repeat this help message, press "h". To quit, press "q".'); } else { printStatus('For a more detailed help message, press "h". To quit, press "q".'); } } @override Future<Null> cleanupAfterSignal() async { await stopEchoingDeviceLog(); await stopApp(); } @override Future<Null> preStop() => _cleanupDevFS(); @override Future<Null> cleanupAtFinish() async { await _cleanupDevFS(); await stopEchoingDeviceLog(); } }