Flutter run restart (#4105)

* working on making a faster flutter run restart

* clean up todos; fire events on isolate changes

* use the Flutter.FrameworkInitialization event

* review comments
diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart
index 5ab2e80..1ae3359 100644
--- a/packages/flutter_tools/lib/src/android/android_device.dart
+++ b/packages/flutter_tools/lib/src/android/android_device.dart
@@ -6,6 +6,8 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:path/path.dart' as path;
+
 import '../android/android_sdk.dart';
 import '../application_package.dart';
 import '../base/os.dart';
@@ -14,6 +16,7 @@
 import '../device.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
+import '../observatory.dart';
 import '../protocol_discovery.dart';
 import 'adb.dart';
 import 'android.dart';
@@ -370,6 +373,39 @@
   }
 
   @override
+  Future<bool> restartApp(
+    ApplicationPackage package,
+    LaunchResult result, {
+    String mainPath,
+    Observatory observatory
+  }) async {
+    Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools');
+
+    try {
+      String snapshotPath = path.join(tempDir.path, 'snapshot_blob.bin');
+      int result = await flx.createSnapshot(mainPath: mainPath, snapshotPath: snapshotPath);
+
+      if (result != 0) {
+        printError('Failed to run the Flutter compiler; exit code: $result');
+        return false;
+      }
+
+      AndroidApk apk = package;
+      String androidActivity = apk.launchActivity;
+      bool success = await refreshSnapshot(androidActivity, snapshotPath);
+
+      if (!success) {
+        printError('Error refreshing snapshot on $this.');
+        return false;
+      }
+
+      return true;
+    } finally {
+      tempDir.deleteSync(recursive: true);
+    }
+  }
+
+  @override
   Future<bool> stopApp(ApplicationPackage app) {
     List<String> command = adbCommandForDevice(<String>['shell', 'am', 'force-stop', app.id]);
     return runCommandAndStreamOutput(command).then((int exitCode) => exitCode == 0);
@@ -416,7 +452,13 @@
       return false;
     }
 
-    runCheckedSync(adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath]));
+    RunResult result = await runAsync(
+      adbCommandForDevice(<String>['push', snapshotPath, _deviceSnapshotPath])
+    );
+    if (result.exitCode != 0) {
+      printStatus(result.toString());
+      return false;
+    }
 
     List<String> cmd = adbCommandForDevice(<String>[
       'shell', 'am', 'start',
@@ -426,9 +468,14 @@
       '--es', 'snapshot', _deviceSnapshotPath,
       activity,
     ]);
+    result = await runAsync(cmd);
+    if (result.exitCode != 0) {
+      printStatus(result.toString());
+      return false;
+    }
 
-    RegExp errorRegExp = new RegExp(r'^Error: .*$', multiLine: true);
-    Match errorMatch = errorRegExp.firstMatch(runCheckedSync(cmd));
+    final RegExp errorRegExp = new RegExp(r'^Error: .*$', multiLine: true);
+    Match errorMatch = errorRegExp.firstMatch(result.processResult.stdout);
     if (errorMatch != null) {
       printError(errorMatch.group(0));
       return false;
diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart
index 184dd1f..d4287a1 100644
--- a/packages/flutter_tools/lib/src/base/process.dart
+++ b/packages/flutter_tools/lib/src/base/process.dart
@@ -32,8 +32,7 @@
   RegExp filter,
   StringConverter mapFunction
 }) async {
-  Process process = await runCommand(cmd,
-                                     workingDirectory: workingDirectory);
+  Process process = await runCommand(cmd, workingDirectory: workingDirectory);
   process.stdout
     .transform(UTF8.decoder)
     .transform(const LineSplitter())
@@ -84,6 +83,18 @@
   );
 }
 
+Future<RunResult> runAsync(List<String> cmd, { String workingDirectory }) async {
+  printTrace(cmd.join(' '));
+  ProcessResult results = await Process.run(
+    cmd[0],
+    cmd.getRange(1, cmd.length).toList(),
+    workingDirectory: workingDirectory
+  );
+  RunResult runResults = new RunResult(results);
+  printTrace(runResults.toString());
+  return runResults;
+}
+
 /// Run cmd and return stdout.
 String runSync(List<String> cmd, { String workingDirectory }) {
   return _runWithLoggingSync(cmd, workingDirectory: workingDirectory);
@@ -146,3 +157,21 @@
   @override
   String toString() => message;
 }
+
+class RunResult {
+  RunResult(this.processResult);
+
+  final ProcessResult processResult;
+
+  int get exitCode => processResult.exitCode;
+
+  @override
+  String toString() {
+    StringBuffer out = new StringBuffer();
+    if (processResult.stdout.isNotEmpty)
+      out.writeln(processResult.stdout);
+    if (processResult.stderr.isNotEmpty)
+      out.writeln(processResult.stderr);
+    return out.toString().trimRight();
+  }
+}
diff --git a/packages/flutter_tools/lib/src/commands/analyze.dart b/packages/flutter_tools/lib/src/commands/analyze.dart
index cd4eaea..0a5c156 100644
--- a/packages/flutter_tools/lib/src/commands/analyze.dart
+++ b/packages/flutter_tools/lib/src/commands/analyze.dart
@@ -17,7 +17,6 @@
 import '../globals.dart';
 import '../runner/flutter_command.dart';
 
-
 bool isDartFile(FileSystemEntity entry) => entry is File && entry.path.endsWith('.dart');
 
 typedef bool FileFilter(FileSystemEntity entity);
diff --git a/packages/flutter_tools/lib/src/commands/refresh.dart b/packages/flutter_tools/lib/src/commands/refresh.dart
index 5306ce2..2dfbc7d 100644
--- a/packages/flutter_tools/lib/src/commands/refresh.dart
+++ b/packages/flutter_tools/lib/src/commands/refresh.dart
@@ -39,11 +39,8 @@
     Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools');
     try {
       String snapshotPath = path.join(tempDir.path, 'snapshot_blob.bin');
+      int result = await createSnapshot(mainPath: argResults['target'], snapshotPath: snapshotPath);
 
-      int result = await createSnapshot(
-        mainPath: argResults['target'],
-        snapshotPath: snapshotPath
-      );
       if (result != 0) {
         printError('Failed to run the Flutter compiler. Exit code: $result');
         return result;
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index 75f9fe0..ceee469 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -295,10 +295,18 @@
   StreamSubscription<String> _loggingSubscription;
 
   Observatory observatory;
-  String _isolateId;
 
   /// Start the app and keep the process running during its lifetime.
-  Future<int> run({ bool traceStartup: false, bool benchmark: false }) async {
+  Future<int> run({ bool traceStartup: false, bool benchmark: false }) {
+    // Don't let uncaught errors kill the process.
+    return runZoned(() {
+      return _run(traceStartup: traceStartup, benchmark: benchmark);
+    }, onError: (dynamic error) {
+      printError('Exception from flutter run: $error');
+    });
+  }
+
+  Future<int> _run({ bool traceStartup: false, bool benchmark: false }) async {
     String mainPath = findMainDartFile(target);
     if (!FileSystemEntity.isFileSync(mainPath)) {
       String message = 'Tried to run $mainPath, but that file does not exist.';
@@ -319,7 +327,7 @@
       return 1;
     }
 
-    Stopwatch stopwatch = new Stopwatch()..start();
+    Stopwatch startTime = new Stopwatch()..start();
 
     // TODO(devoncarew): We shouldn't have to do type checks here.
     if (device is AndroidDevice) {
@@ -377,7 +385,7 @@
       return 2;
     }
 
-    stopwatch.stop();
+    startTime.stop();
 
     _exitCompleter = new Completer<int>();
 
@@ -386,21 +394,21 @@
       observatory = await Observatory.connect(result.observatoryPort);
       printTrace('Connected to observatory port: ${result.observatoryPort}.');
 
-      observatory.onIsolateEvent.listen((Event event) {
-        if (event['isolate'] != null)
-          _isolateId = event['isolate']['id'];
+      observatory.onExtensionEvent.listen((Event event) {
+        printTrace(event.toString());
       });
-      observatory.streamListen('Isolate');
+
+      observatory.onIsolateEvent.listen((Event event) {
+        printTrace(event.toString());
+      });
+
+      if (benchmark)
+        await observatory.waitFirstIsolate;
 
       // Listen for observatory connection close.
       observatory.done.whenComplete(() {
         _handleExit();
       });
-
-      observatory.getVM().then((VM vm) {
-        if (vm.isolates.isNotEmpty)
-          _isolateId = vm.isolates.first['id'];
-      });
     }
 
     printStatus('Application running.');
@@ -425,7 +433,7 @@
           _printHelp();
         } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
           // F5, refresh
-          _handleRefresh();
+          _handleRefresh(package, result, mainPath);
         } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
           // F10, exit
           _handleExit();
@@ -441,18 +449,31 @@
     }
 
     if (benchmark) {
-      _writeBenchmark(stopwatch);
-      new Future<Null>.delayed(new Duration(seconds: 2)).then((_) {
-        _handleExit();
-      });
+      await new Future<Null>.delayed(new Duration(seconds: 4));
+
+      // Touch the file.
+      File mainFile = new File(mainPath);
+      mainFile.writeAsBytesSync(mainFile.readAsBytesSync());
+
+      Stopwatch restartTime = new Stopwatch()..start();
+      bool restarted = await _handleRefresh(package, result, mainPath);
+      restartTime.stop();
+      _writeBenchmark(startTime, restarted ? restartTime : null);
+      await new Future<Null>.delayed(new Duration(seconds: 2));
+      _handleExit();
     }
 
     return _exitCompleter.future.then((int exitCode) async {
-      if (observatory != null && !observatory.isClosed && _isolateId != null) {
-        observatory.flutterExit(_isolateId);
-
-        // WebSockets do not have a flush() method.
-        await new Future<Null>.delayed(new Duration(milliseconds: 100));
+      try {
+        if (observatory != null && !observatory.isClosed) {
+          if (observatory.isolates.isNotEmpty) {
+            observatory.flutterExit(observatory.firstIsolateId);
+            // The Dart WebSockets API does not have a flush() method.
+            await new Future<Null>.delayed(new Duration(milliseconds: 100));
+          }
+        }
+      } catch (error) {
+        stderr.writeln(error.toString());
       }
 
       return exitCode;
@@ -463,15 +484,33 @@
     printStatus('Type "h" or F1 for help, "r" or F5 to restart the app, and "q", F10, or ctrl-c to quit.');
   }
 
-  void _handleRefresh() {
+  Future<bool> _handleRefresh(ApplicationPackage package, LaunchResult result, String mainPath) async {
     if (observatory == null) {
       printError('Debugging is not enabled.');
+      return false;
     } else {
-      printStatus('Re-starting application...');
+      Status status = logger.startProgress('Re-starting application...');
 
-      observatory.isolateReload(_isolateId).catchError((dynamic error) {
-        printError('Error restarting app: $error');
-      });
+      Future<Event> extensionAddedEvent = observatory.onExtensionEvent
+        .where((Event event) => event.extensionKind == 'Flutter.FrameworkInitialization')
+        .first;
+
+      bool restartResult = await device.restartApp(
+        package,
+        result,
+        mainPath: mainPath,
+        observatory: observatory
+      );
+
+      status.stop(showElapsedTime: true);
+
+      if (restartResult) {
+        // TODO(devoncarew): We should restore the route here.
+
+        await extensionAddedEvent;
+      }
+
+      return restartResult;
     }
   }
 
@@ -533,11 +572,15 @@
   printStatus('Saved startup trace info in ${traceInfoFile.path}.');
 }
 
-void _writeBenchmark(Stopwatch stopwatch) {
+void _writeBenchmark(Stopwatch startTime, [Stopwatch restartTime]) {
   final String benchmarkOut = 'refresh_benchmark.json';
   Map<String, dynamic> data = <String, dynamic>{
-    'time': stopwatch.elapsedMilliseconds
+    'start': startTime.elapsedMilliseconds,
+    'time': (restartTime ?? startTime).elapsedMilliseconds // time and restart are the same
   };
+  if (restartTime != null)
+    data['restart'] = restartTime.elapsedMilliseconds;
+
   new File(benchmarkOut).writeAsStringSync(toPrettyJson(data));
   printStatus('Run benchmark written to $benchmarkOut ($data).');
 }
diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart
index 3528682..f67c0d3 100644
--- a/packages/flutter_tools/lib/src/device.dart
+++ b/packages/flutter_tools/lib/src/device.dart
@@ -13,6 +13,7 @@
 import 'base/utils.dart';
 import 'build_info.dart';
 import 'globals.dart';
+import 'observatory.dart';
 import 'ios/devices.dart';
 import 'ios/simulators.dart';
 
@@ -185,6 +186,15 @@
     Map<String, dynamic> platformArgs
   });
 
+  /// Restart the given app; the application will already have been launched with
+  /// [startApp].
+  Future<bool> restartApp(
+    ApplicationPackage package,
+    LaunchResult result, {
+    String mainPath,
+    Observatory observatory
+  });
+
   /// Stop an app package on the current device.
   Future<bool> stopApp(ApplicationPackage app);
 
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index 9b66c8f..321ab3a 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -12,6 +12,7 @@
 import '../build_info.dart';
 import '../device.dart';
 import '../globals.dart';
+import '../observatory.dart';
 import 'mac.dart';
 
 const String _ideviceinstallerInstructions =
@@ -199,6 +200,21 @@
   }
 
   @override
+  Future<bool> restartApp(
+    ApplicationPackage package,
+    LaunchResult result, {
+    String mainPath,
+    Observatory observatory
+  }) async {
+    return observatory.isolateReload(observatory.firstIsolateId).then((Response response) {
+      return true;
+    }).catchError((dynamic error) {
+      printError('Error restarting app: $error');
+      return false;
+    });
+  }
+
+  @override
   Future<bool> stopApp(ApplicationPackage app) async {
     // Currently we don't have a way to stop an app running on iOS.
     return false;
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index 7a6df56..f566a14 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -15,6 +15,7 @@
 import '../device.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
+import '../observatory.dart';
 import '../protocol_discovery.dart';
 import 'mac.dart';
 
@@ -561,6 +562,21 @@
   }
 
   @override
+  Future<bool> restartApp(
+    ApplicationPackage package,
+    LaunchResult result, {
+    String mainPath,
+    Observatory observatory
+  }) {
+    return observatory.isolateReload(observatory.firstIsolateId).then((Response response) {
+      return true;
+    }).catchError((dynamic error) {
+      printError('Error restarting app: $error');
+      return false;
+    });
+  }
+
+  @override
   Future<bool> stopApp(ApplicationPackage app) async {
     // Currently we don't have a way to stop an app running on iOS.
     return false;
diff --git a/packages/flutter_tools/lib/src/observatory.dart b/packages/flutter_tools/lib/src/observatory.dart
index 27b83a4..93e9b10 100644
--- a/packages/flutter_tools/lib/src/observatory.dart
+++ b/packages/flutter_tools/lib/src/observatory.dart
@@ -13,6 +13,15 @@
     peer.registerMethod('streamNotify', (rpc.Parameters event) {
       _handleStreamNotify(event.asMap);
     });
+
+    onIsolateEvent.listen((Event event) {
+      if (event.kind == 'IsolateStart') {
+        _addIsolate(event.isolate);
+      } else if (event.kind == 'IsolateExit') {
+        String removedId = event.isolate.id;
+        isolates.removeWhere((IsolateRef ref) => ref.id == removedId);
+      }
+    });
   }
 
   static Future<Observatory> connect(int port) async {
@@ -26,19 +35,30 @@
   final rpc.Peer peer;
   final int port;
 
+  List<IsolateRef> isolates = <IsolateRef>[];
+  Completer<IsolateRef> _waitFirstIsolateCompleter;
+
   Map<String, StreamController<Event>> _eventControllers = <String, StreamController<Event>>{};
 
+  Set<String> _listeningFor = new Set<String>();
+
   bool get isClosed => peer.isClosed;
   Future<Null> get done => peer.done;
 
+  String get firstIsolateId => isolates.isEmpty ? null : isolates.first.id;
+
   // Events
 
+  Stream<Event> get onExtensionEvent => onEvent('Extension');
   // IsolateStart, IsolateRunnable, IsolateExit, IsolateUpdate, ServiceExtensionAdded
-  Stream<Event> get onIsolateEvent => _getEventController('Isolate').stream;
-  Stream<Event> get onTimelineEvent => _getEventController('Timeline').stream;
+  Stream<Event> get onIsolateEvent => onEvent('Isolate');
+  Stream<Event> get onTimelineEvent => onEvent('Timeline');
 
   // Listen for a specific event name.
-  Stream<Event> onEvent(String streamName) => _getEventController(streamName).stream;
+  Stream<Event> onEvent(String streamId) {
+    streamListen(streamId);
+    return _getEventController(streamId).stream;
+  }
 
   StreamController<Event> _getEventController(String eventName) {
     StreamController<Event> controller = _eventControllers[eventName];
@@ -54,16 +74,31 @@
     _getEventController(data['streamId']).add(event);
   }
 
+  Future<IsolateRef> get waitFirstIsolate async {
+    if (isolates.isNotEmpty)
+      return isolates.first;
+
+    _waitFirstIsolateCompleter = new Completer<IsolateRef>();
+
+    getVM().then((VM vm) {
+      for (IsolateRef isolate in vm.isolates)
+        _addIsolate(isolate);
+    });
+
+    return _waitFirstIsolateCompleter.future;
+  }
+
   // Requests
 
   Future<Response> sendRequest(String method, [Map<String, dynamic> args]) {
     return peer.sendRequest(method, args).then((dynamic result) => new Response(result));
   }
 
-  Future<Response> streamListen(String streamId) {
-    return sendRequest('streamListen', <String, dynamic>{
-      'streamId': streamId
-    });
+  Future<Null> streamListen(String streamId) async {
+    if (!_listeningFor.contains(streamId)) {
+      _listeningFor.add(streamId);
+      sendRequest('streamListen', <String, dynamic>{ 'streamId': streamId });
+    }
   }
 
   Future<VM> getVM() {
@@ -97,6 +132,17 @@
       'isolateId': isolateId
     }).then((dynamic result) => new Response(result));
   }
+
+  void _addIsolate(IsolateRef isolate) {
+    if (!isolates.contains(isolate)) {
+      isolates.add(isolate);
+
+      if (_waitFirstIsolateCompleter != null) {
+        _waitFirstIsolateCompleter.complete(isolate);
+        _waitFirstIsolateCompleter = null;
+      }
+    }
+  }
 }
 
 class Response {
@@ -104,6 +150,8 @@
 
   final Map<String, dynamic> response;
 
+  String get type => response['type'];
+
   dynamic operator[](String key) => response[key];
 
   @override
@@ -113,18 +161,35 @@
 class VM extends Response {
   VM(Map<String, dynamic> response) : super(response);
 
-  List<dynamic> get isolates => response['isolates'];
+  List<IsolateRef> get isolates => response['isolates'].map((dynamic ref) => new IsolateRef(ref)).toList();
 }
 
-class Event {
-  Event(this.event);
+class Event extends Response {
+  Event(Map<String, dynamic> response) : super(response);
 
-  final Map<String, dynamic> event;
+  String get kind => response['kind'];
+  IsolateRef get isolate => new IsolateRef.from(response['isolate']);
 
-  String get kind => event['kind'];
+  /// Only valid for [kind] == `Extension`.
+  String get extensionKind => response['extensionKind'];
+}
 
-  dynamic operator[](String key) => event[key];
+class IsolateRef extends Response {
+  IsolateRef(Map<String, dynamic> response) : super(response);
+  factory IsolateRef.from(dynamic ref) => ref == null ? null : new IsolateRef(ref);
+
+  String get id => response['id'];
 
   @override
-  String toString() => event.toString();
+  bool operator ==(dynamic other) {
+    if (identical(this, other))
+      return true;
+    if (other is! IsolateRef)
+      return false;
+    final IsolateRef typedOther = other;
+    return id == typedOther.id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
 }