Split hot run mode into a separate file (#5233)

diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart
index 822f69d..af15760 100644
--- a/packages/flutter_tools/lib/src/asset.dart
+++ b/packages/flutter_tools/lib/src/asset.dart
@@ -52,10 +52,27 @@
   static const String _kFontSetMaterial = 'material';
   static const String _kFontSetRoboto = 'roboto';
 
+  DateTime _lastBuildTimestamp;
+
+  bool needsBuild({String manifestPath: defaultManifestPath}) {
+    if (_lastBuildTimestamp == null)
+      return true;
+
+    FileStat stat = new File(manifestPath).statSync();
+    if (stat.type == FileSystemEntityType.NOT_FOUND)
+      return true;
+
+    return stat.modified.isAfter(_lastBuildTimestamp);
+  }
+
   Future<int> build({String manifestPath: defaultManifestPath,
                      String workingDirPath: defaultWorkingDirPath,
                      bool includeRobotoFonts: true}) async {
     Object manifest = _loadFlutterYamlManifest(manifestPath);
+    if (manifest == null) {
+      // No manifest file found for this application.
+      return 0;
+    }
     if (manifest != null) {
      int result = await _validateFlutterYamlManifest(manifest);
      if (result != 0)
@@ -65,6 +82,8 @@
     assert(manifestDescriptor != null);
     String assetBasePath = path.dirname(path.absolute(manifestPath));
 
+    _lastBuildTimestamp = new DateTime.now();
+
     final PackageMap packageMap =
         new PackageMap(path.join(assetBasePath, '.packages'));
 
diff --git a/packages/flutter_tools/lib/src/commands/build_aot.dart b/packages/flutter_tools/lib/src/commands/build_aot.dart
index ce3656a..db20a97 100644
--- a/packages/flutter_tools/lib/src/commands/build_aot.dart
+++ b/packages/flutter_tools/lib/src/commands/build_aot.dart
@@ -13,7 +13,7 @@
 import '../build_info.dart';
 import '../dart/package_map.dart';
 import '../globals.dart';
-import '../run.dart';
+import '../resident_runner.dart';
 import 'build.dart';
 
 const String _kDefaultAotOutputDir = 'build/aot';
diff --git a/packages/flutter_tools/lib/src/commands/build_apk.dart b/packages/flutter_tools/lib/src/commands/build_apk.dart
index df7ea5c..539d5a4 100644
--- a/packages/flutter_tools/lib/src/commands/build_apk.dart
+++ b/packages/flutter_tools/lib/src/commands/build_apk.dart
@@ -18,7 +18,7 @@
 import '../build_info.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
-import '../run.dart';
+import '../resident_runner.dart';
 import '../services.dart';
 import 'build_aot.dart';
 import 'build.dart';
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 0486ec3..cb49fc9 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -291,7 +291,7 @@
     String route = _getStringArg(args, 'route');
     String mode = _getStringArg(args, 'mode');
     String target = _getStringArg(args, 'target');
-    bool hotMode = _getBoolArg(args, 'hot');
+    // TODO(johnmccutchan): Wire up support for hot mode.
 
     Device device = daemon.deviceDomain._getDevice(deviceId);
     if (device == null)
@@ -323,8 +323,7 @@
       device,
       target: target,
       debuggingOptions: options,
-      usesTerminalUI: false,
-      hotMode: hotMode
+      usesTerminalUI: false
     );
 
     AppInstance app = new AppInstance(_getNextAppId(), runner);
diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart
index abe4c07..213e9f1 100644
--- a/packages/flutter_tools/lib/src/commands/drive.dart
+++ b/packages/flutter_tools/lib/src/commands/drive.dart
@@ -21,7 +21,7 @@
 import '../device.dart';
 import '../globals.dart';
 import '../ios/simulators.dart' show SimControl, IOSSimulatorUtils;
-import '../run.dart';
+import '../resident_runner.dart';
 import 'build_apk.dart' as build_apk;
 import 'run.dart';
 
diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart
index cd14ae2..0e38310 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -12,7 +12,9 @@
 import '../cache.dart';
 import '../device.dart';
 import '../globals.dart';
+import '../hot.dart' as hot;
 import '../observatory.dart';
+import '../resident_runner.dart';
 import '../run.dart';
 import '../runner/flutter_command.dart';
 import 'build_apk.dart';
@@ -132,18 +134,25 @@
     }
 
     if (argResults['resident']) {
-      RunAndStayResident runner = new RunAndStayResident(
-        deviceForCommand,
-        target: targetFile,
-        debuggingOptions: options,
-        hotMode: argResults['hot']
-      );
-
-      return runner.run(
-        traceStartup: traceStartup,
-        benchmark: argResults['benchmark'],
-        route: route
-      );
+      if (argResults['hot']) {
+        hot.HotRunner runner = new hot.HotRunner(
+          deviceForCommand,
+          target: targetFile,
+          debuggingOptions: options
+        );
+        return runner.run(route: route);
+      } else {
+        RunAndStayResident runner = new RunAndStayResident(
+          deviceForCommand,
+          target: targetFile,
+          debuggingOptions: options
+        );
+        return runner.run(
+          traceStartup: traceStartup,
+          benchmark: argResults['benchmark'],
+          route: route
+        );
+      }
     } else {
       // TODO(devoncarew): Remove this path and support the `--no-resident` option
       // using the `RunAndStayResident` class.
diff --git a/packages/flutter_tools/lib/src/commands/run_mojo.dart b/packages/flutter_tools/lib/src/commands/run_mojo.dart
index eb7f6a2..5e5bdad 100644
--- a/packages/flutter_tools/lib/src/commands/run_mojo.dart
+++ b/packages/flutter_tools/lib/src/commands/run_mojo.dart
@@ -12,7 +12,7 @@
 import '../cache.dart';
 import '../flx.dart' as flx;
 import '../globals.dart';
-import '../run.dart';
+import '../resident_runner.dart';
 import '../runner/flutter_command.dart';
 
 const String _kDefaultBundlePath = 'build/app.flx';
diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart
index 2d4e4f0..cdae4d0 100644
--- a/packages/flutter_tools/lib/src/devfs.dart
+++ b/packages/flutter_tools/lib/src/devfs.dart
@@ -180,7 +180,9 @@
     return await _operations.destroy(fsName);
   }
 
-  Future<dynamic> update({ DevFSProgressReporter progressReporter, AssetBundle bundle }) async {
+  Future<dynamic> update({ DevFSProgressReporter progressReporter,
+                           AssetBundle bundle,
+                           bool bundleDirty: false }) async {
     _bytes = 0;
     // Mark all entries as not seen.
     _entries.forEach((String path, DevFSEntry entry) {
@@ -189,7 +191,8 @@
     printTrace('DevFS: Starting sync from $rootDirectory');
     // Send the root and lib directories.
     Directory directory = rootDirectory;
-    _syncDirectory(directory, recursive: true);
+    await _syncDirectory(directory, recursive: true);
+    printTrace('DevFS: Syncing of $rootDirectory finished');
     String packagesFilePath = path.join(rootDirectory.path, kPackagesFileName);
     StringBuffer sb;
     // Send the packages.
@@ -202,12 +205,16 @@
         if (uri.toString() == 'lib/')
           continue;
         Directory directory = new Directory.fromUri(uri);
-        if (_syncDirectory(directory,
-                           directoryName: 'packages/$packageName',
-                           recursive: true)) {
+        printTrace('DevFS: Syncing package $packageName started');
+        bool packageWritten =
+            await _syncDirectory(directory,
+                                 directoryName: 'packages/$packageName',
+                                 recursive: true);
+        if (packageWritten) {
           sb ??= new StringBuffer();
           sb.writeln('$packageName:packages/$packageName');
         }
+        printTrace('DevFS: Syncing package $packageName finished');
       }
     }
     if (bundle != null) {
@@ -216,6 +223,11 @@
         // We write the assets into 'build/flx' so that they are in the
         // same location in DevFS and the iOS simulator.
         final String devicePath = path.join('build/flx', entry.archivePath);
+        if (!bundleDirty && entry.isStringEntry) {
+          // When the bundle isn't dirty, we do not need to sync string
+          // entries.
+          continue;
+        }
         _syncBundleEntry(devicePath, entry);
       }
     }
@@ -309,10 +321,10 @@
     return false;
   }
 
-  bool _syncDirectory(Directory directory,
-                      {String directoryName,
-                       bool recursive: false,
-                       bool ignoreDotFiles: true}) {
+  Future<bool> _syncDirectory(Directory directory,
+                              {String directoryName,
+                               bool recursive: false,
+                               bool ignoreDotFiles: true}) async {
     String prefix = directoryName;
     if (prefix == null) {
       prefix = path.relative(directory.path, from: rootDirectory.path);
@@ -320,9 +332,9 @@
         prefix = '';
     }
     try {
-      List<FileSystemEntity> files =
-          directory.listSync(recursive: recursive, followLinks: false);
-      for (FileSystemEntity file in files) {
+      Stream<FileSystemEntity> files =
+          directory.list(recursive: recursive, followLinks: false);
+      await for (FileSystemEntity file in files) {
         if (file is! File) {
           // Skip non-files.
           continue;
diff --git a/packages/flutter_tools/lib/src/hot.dart b/packages/flutter_tools/lib/src/hot.dart
new file mode 100644
index 0000000..ba7f288
--- /dev/null
+++ b/packages/flutter_tools/lib/src/hot.dart
@@ -0,0 +1,351 @@
+// 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 'dart:io';
+
+import 'package:path/path.dart' as path;
+
+import 'application_package.dart';
+import 'asset.dart';
+import 'base/logger.dart';
+import 'base/utils.dart';
+import 'cache.dart';
+import 'commands/build_apk.dart';
+import 'commands/install.dart';
+import 'device.dart';
+import 'globals.dart';
+import 'devfs.dart';
+import 'observatory.dart';
+import 'resident_runner.dart';
+
+String getDevFSLoaderScript() {
+  return path.absolute(path.join(Cache.flutterRoot,
+                                 'packages',
+                                 'flutter',
+                                 'bin',
+                                 'loader',
+                                 'loader_app.dart'));
+}
+
+class HotRunner extends ResidentRunner {
+  HotRunner(
+    Device device, {
+    String target,
+    DebuggingOptions debuggingOptions,
+    bool usesTerminalUI: true
+  }) : super(device,
+             target: target,
+             debuggingOptions: debuggingOptions,
+             usesTerminalUI: usesTerminalUI);
+
+  ApplicationPackage _package;
+  String _mainPath;
+  final AssetBundle bundle = new AssetBundle();
+
+  /// Start the app and keep the process running during its lifetime.
+  Future<int> run({
+    Completer<int> observatoryPortCompleter,
+    String route
+  }) {
+    // Don't let uncaught errors kill the process.
+    return runZoned(() {
+      return _run(
+        observatoryPortCompleter: observatoryPortCompleter,
+        route: route
+      );
+    }, onError: (dynamic error, StackTrace stackTrace) {
+      printError('Exception from flutter run: $error', stackTrace);
+    });
+  }
+
+  Future<int> _run({
+    Completer<int> observatoryPortCompleter,
+    String route
+  }) async {
+    _mainPath = findMainDartFile(target);
+    if (!FileSystemEntity.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;
+    }
+
+    _package = getApplicationPackageForPlatform(device.platform);
+
+    if (_package == null) {
+      String message = 'No application found for ${device.platform}.';
+      String hint = getMissingPackageHintForPlatform(device.platform);
+      if (hint != null)
+        message += '\n$hint';
+      printError(message);
+      return 1;
+    }
+
+    // TODO(devoncarew): We shouldn't have to do type checks here.
+    if (device is AndroidDevice) {
+      printTrace('Running build command.');
+
+      int result = await buildApk(
+        device.platform,
+        target: target,
+        buildMode: debuggingOptions.buildMode
+      );
+
+      if (result != 0)
+        return result;
+    }
+
+    // TODO(devoncarew): Move this into the device.startApp() impls.
+    if (_package != null) {
+      printTrace("Stopping app '${_package.name}' on ${device.name}.");
+      // We don't wait for the stop command to complete.
+      device.stopApp(_package);
+    }
+
+    // Allow any stop commands from above to start work.
+    await new Future<Duration>.delayed(Duration.ZERO);
+
+    // TODO(devoncarew): This fails for ios devices - we haven't built yet.
+    if (device is AndroidDevice) {
+      printTrace('Running install command.');
+      if (!(installApp(device, _package, uninstall: false)))
+        return 1;
+    }
+
+    Map<String, dynamic> platformArgs = new Map<String, dynamic>();
+
+    await startEchoingDeviceLog();
+
+    if (device.needsDevFS) {
+      printStatus('Launching loader on ${device.name}...');
+    } else {
+      printStatus('Launching ${getDisplayPath(_mainPath)} on ${device.name}...');
+    }
+
+    LaunchResult result = await device.startApp(
+      _package,
+      debuggingOptions.buildMode,
+      mainPath: device.needsDevFS ? getDevFSLoaderScript() : _mainPath,
+      debuggingOptions: debuggingOptions,
+      platformArgs: platformArgs,
+      route: route
+    );
+
+    if (!result.started) {
+      if (device.needsDevFS) {
+        printError('Error launching DevFS loader on ${device.name}.');
+      } else {
+        printError('Error launching ${getDisplayPath(_mainPath)} on ${device.name}.');
+      }
+      await stopEchoingDeviceLog();
+      return 2;
+    }
+
+    if (observatoryPortCompleter != null && result.hasObservatory)
+      observatoryPortCompleter.complete(result.observatoryPort);
+
+    await connectToServiceProtocol(result.observatoryPort);
+
+    if (device.needsDevFS) {
+      _loaderShowMessage('Connecting...', progress: 0);
+      bool result = await _updateDevFS(
+        progressReporter: (int progress, int max) {
+          _loaderShowMessage('Syncing files to device...', progress: progress, max: max);
+        }
+      );
+      if (!result) {
+        _loaderShowMessage('Failed.');
+        printError('Could not perform initial file synchronization.');
+        return 3;
+      }
+      printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
+      _loaderShowMessage('Launching...');
+      await _launchFromDevFS(_package, _mainPath);
+    }
+
+    printStatus('Application running.');
+
+    setupTerminal();
+
+    registerSignalHandlers();
+
+    return await waitForAppToFinish();
+  }
+
+  @override
+  void handleTerminalCommand(String code) {
+    final String lower = code.toLowerCase();
+    if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
+      // F5, restart
+      if (code == 'r') {
+        // lower-case 'r'
+        _reloadSources();
+      } else {
+        // upper-case 'R'.
+        _restartFromSources();
+      }
+    }
+  }
+
+  void _loaderShowMessage(String message, { int progress, int max }) {
+    serviceProtocol.flutterLoaderShowMessage(serviceProtocol.firstIsolateId, message);
+    if (progress != null) {
+      serviceProtocol.flutterLoaderSetProgress(serviceProtocol.firstIsolateId, progress.toDouble());
+      serviceProtocol.flutterLoaderSetProgressMax(serviceProtocol.firstIsolateId, max?.toDouble() ?? 0.0);
+    } else {
+      serviceProtocol.flutterLoaderSetProgress(serviceProtocol.firstIsolateId, 0.0);
+      serviceProtocol.flutterLoaderSetProgressMax(serviceProtocol.firstIsolateId, -1.0);
+    }
+  }
+
+  DevFS _devFS;
+  String _devFSProjectRootPath;
+  Future<bool> _updateDevFS({ DevFSProgressReporter progressReporter }) async {
+    if (_devFS == null) {
+      Directory directory = Directory.current;
+      _devFSProjectRootPath = directory.path;
+      String fsName = path.basename(directory.path);
+      _devFS = new DevFS(serviceProtocol, fsName, directory);
+
+      try {
+        await _devFS.create();
+      } catch (error) {
+        _devFS = null;
+        printError('Error initializing DevFS: $error');
+        return false;
+      }
+    }
+
+    final bool rebuildBundle = bundle.needsBuild();
+    if (rebuildBundle) {
+      Status bundleStatus = logger.startProgress('Updating assets...');
+      await bundle.build();
+      bundleStatus.stop(showElapsedTime: true);
+    }
+    Status devFSStatus = logger.startProgress('Syncing files on device...');
+    await _devFS.update(progressReporter: progressReporter,
+                        bundle: bundle,
+                        bundleDirty: rebuildBundle);
+    devFSStatus.stop(showElapsedTime: true);
+    printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB');
+    return true;
+  }
+
+  Future<Null> _cleanupDevFS() async {
+    if (_devFS != null) {
+      // Cleanup the devFS.
+      await _devFS.destroy();
+    }
+    _devFS = null;
+  }
+
+  Future<Null> _launchFromDevFS(ApplicationPackage package,
+                                String mainScript) async {
+    String entryPath = path.relative(mainScript, from: _devFSProjectRootPath);
+    String deviceEntryPath =
+        _devFS.baseUri.resolve(entryPath).toFilePath();
+    String devicePackagesPath =
+        _devFS.baseUri.resolve('.packages').toFilePath();
+    String deviceAssetsDirectoryPath =
+        _devFS.baseUri.resolve('build/flx').toFilePath();
+    String viewId = await serviceProtocol.getFirstViewId();
+
+    // When this completer completes the isolate is running.
+    // TODO(johnmccutchan): Have the framework send an event after the first
+    // frame is rendered and use that instead of 'runnable'.
+    Completer<Null> completer = new Completer<Null>();
+    StreamSubscription<Event> subscription =
+        serviceProtocol.onIsolateEvent.listen((Event event) {
+      if (event.kind == 'IsolateStart') {
+        printTrace('Isolate is spawned.');
+      } else if (event.kind == 'IsolateRunnable') {
+        printTrace('Isolate is runnable.');
+        completer.complete(null);
+      }
+    });
+    await serviceProtocol.runInView(viewId,
+                                    deviceEntryPath,
+                                    devicePackagesPath,
+                                    deviceAssetsDirectoryPath);
+    await completer.future;
+    await subscription.cancel();
+  }
+
+  Future<Null> _restartFromSources() async {
+    if (_devFS != null)
+      await _updateDevFS();
+    Status restartStatus = logger.startProgress('Restarting application...');
+    await _launchFromDevFS(_package, _mainPath);
+    restartStatus.stop(showElapsedTime: true);
+  }
+
+  /// Returns [true] if the reload was successful.
+  bool _printReloadReport(Map<String, dynamic> reloadReport) {
+    if (!reloadReport['success']) {
+      printError('Hot reload was rejected:');
+      for (Map<String, dynamic> notice in reloadReport['details']['notices']) {
+        printError('${notice['message']}');
+      }
+      return false;
+    }
+    int loadedLibraryCount = reloadReport['details']['loadedLibraryCount'];
+    int finalLibraryCount = reloadReport['details']['finalLibraryCount'];
+    printStatus('Reloaded $loadedLibraryCount out of $finalLibraryCount libraries.');
+    return true;
+  }
+
+  Future<bool> _reloadSources() async {
+    if (serviceProtocol.firstIsolateId == null)
+      throw 'Application isolate not found';
+    if (_devFS != null)
+      await _updateDevFS();
+    Status reloadStatus = logger.startProgress('Performing hot reload');
+    try {
+      Map<String, dynamic> reloadReport =
+          await serviceProtocol.reloadSources(serviceProtocol.firstIsolateId);
+      reloadStatus.stop(showElapsedTime: true);
+      if (!_printReloadReport(reloadReport)) {
+        // Reload failed.
+        return false;
+      }
+    } catch (errorMessage) {
+      reloadStatus.stop(showElapsedTime: true);
+      printError('Hot reload failed:\n$errorMessage');
+      return false;
+    }
+    Status reassembleStatus =
+        logger.startProgress('Reassembling application');
+    try {
+      await serviceProtocol.flutterReassemble(serviceProtocol.firstIsolateId);
+    } catch (_) {
+      reassembleStatus.stop(showElapsedTime: true);
+      printError('Reassembling application failed.');
+      return false;
+    }
+    reassembleStatus.stop(showElapsedTime: true);
+    return true;
+  }
+
+  @override
+  void printHelp() {
+    printStatus('Type "h" or F1 for this help message. Type "q", F10, or ctrl-c to quit.', emphasis: true);
+    printStatus('Type "r" or F5 to perform a hot reload of the app.', emphasis: true);
+    printStatus('Type "R" to restart the app', emphasis: true);
+    printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.', emphasis: true);
+  }
+
+  @override
+  Future<Null> cleanupAfterSignal() async {
+    await stopEchoingDeviceLog();
+    await stopApp();
+  }
+
+  @override
+  Future<Null> cleanupAtFinish() async {
+    await _cleanupDevFS();
+    await stopEchoingDeviceLog();
+  }
+}
diff --git a/packages/flutter_tools/lib/src/observatory.dart b/packages/flutter_tools/lib/src/observatory.dart
index b7d3337..f13d096 100644
--- a/packages/flutter_tools/lib/src/observatory.dart
+++ b/packages/flutter_tools/lib/src/observatory.dart
@@ -116,16 +116,37 @@
     });
   }
 
-  Future<Null> reloadSources(String isolateId) async {
+  Future<Map<String, dynamic>> reloadSources(String isolateId) async {
     try {
-      await sendRequest('_reloadSources',
-                        <String, dynamic>{ 'isolateId': isolateId });
-      return null;
+      Response response =
+          await sendRequest('_reloadSources',
+                            <String, dynamic>{ 'isolateId': isolateId });
+      return response.response;
     } catch (e) {
-      return new Future<Null>.error(e.data['details']);
+      return new Future<Map<String, dynamic>>.error(e.data['details']);
     }
   }
 
+  Future<String> getFirstViewId() async {
+    Map<String, dynamic> response = await peer.sendRequest('_flutter.listViews');
+    List<Map<String, String>> views = response['views'];
+    return views[0]['id'];
+  }
+
+  Future<Null> runInView(String viewId,
+                         String main,
+                         String packages,
+                         String assetsDirectory) async {
+    await peer.sendRequest('_flutter.runInView',
+                           <String, dynamic> {
+                             'viewId': viewId,
+                             'mainScript': main,
+                             'packagesFile': packages,
+                             'assetDirectory': assetsDirectory
+                           });
+    return null;
+  }
+
   Future<Response> clearVMTimeline() => sendRequest('_clearVMTimeline');
 
   Future<Response> setVMTimelineFlags(List<String> recordedStreams) {
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
new file mode 100644
index 0000000..d8c2fde
--- /dev/null
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -0,0 +1,197 @@
+// 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 'dart:io';
+
+import 'package:path/path.dart' as path;
+
+import 'base/logger.dart';
+import 'build_info.dart';
+import 'device.dart';
+import 'globals.dart';
+import 'observatory.dart';
+
+// Shared code between different resident application runners.
+abstract class ResidentRunner {
+  ResidentRunner(this.device, {
+    this.target,
+    this.debuggingOptions,
+    this.usesTerminalUI: true
+  });
+
+  final Device device;
+  final String target;
+  final DebuggingOptions debuggingOptions;
+  final bool usesTerminalUI;
+  final Completer<int> _finished = new Completer<int>();
+
+  Observatory serviceProtocol;
+  StreamSubscription<String> _loggingSubscription;
+
+  Future<Null> stop() async {
+    await stopEchoingDeviceLog();
+    return stopApp();
+  }
+
+  void _debugDumpApp() {
+    serviceProtocol.flutterDebugDumpApp(serviceProtocol.firstIsolateId);
+  }
+
+  void _debugDumpRenderTree() {
+    serviceProtocol.flutterDebugDumpRenderTree(serviceProtocol.firstIsolateId);
+  }
+
+  void registerSignalHandlers() {
+    ProcessSignal.SIGINT.watch().listen((ProcessSignal signal) async {
+      _resetTerminal();
+      await cleanupAfterSignal();
+      exit(0);
+    });
+    ProcessSignal.SIGTERM.watch().listen((ProcessSignal signal) async {
+      _resetTerminal();
+      await cleanupAfterSignal();
+      exit(0);
+    });
+  }
+
+  Future<Null> startEchoingDeviceLog() async {
+    if (_loggingSubscription != null) {
+      return;
+    }
+    _loggingSubscription = device.logReader.logLines.listen((String line) {
+      if (!line.contains('Observatory listening on http') &&
+          !line.contains('Diagnostic server listening on http'))
+        printStatus(line);
+    });
+  }
+
+  Future<Null> stopEchoingDeviceLog() async {
+    if (_loggingSubscription != null) {
+      await _loggingSubscription.cancel();
+    }
+    _loggingSubscription = null;
+  }
+
+  Future<Null> connectToServiceProtocol(int port) async {
+    if (!debuggingOptions.debuggingEnabled) {
+      return new Future<Null>.error('Error the service protocol is not enabled.');
+    }
+    serviceProtocol = await Observatory.connect(port);
+    printTrace('Connected to service protocol on port $port');
+    serviceProtocol.populateIsolateInfo();
+    serviceProtocol.onExtensionEvent.listen((Event event) {
+      printTrace(event.toString());
+    });
+    serviceProtocol.onIsolateEvent.listen((Event event) {
+      printTrace(event.toString());
+    });
+    // Listen for service protocol connection to close.
+    serviceProtocol.done.whenComplete(() {
+      appFinished();
+    });
+  }
+
+  /// Returns [true] if the input has been handled by this function.
+  bool _commonTerminalInputHandler(String character) {
+    final String lower = character.toLowerCase();
+
+    printStatus(''); // the key the user tapped might be on this line
+
+    if (lower == 'h' || lower == '?' || character == AnsiTerminal.KEY_F1) {
+      // F1, help
+      printHelp();
+      return true;
+    } else if (lower == 'w') {
+      _debugDumpApp();
+      return true;
+    } else if (lower == 't') {
+      _debugDumpRenderTree();
+      return true;
+    } else if (lower == 'q' || character == AnsiTerminal.KEY_F10) {
+      // F10, exit
+      stopApp();
+      return true;
+    }
+
+    return false;
+  }
+
+  void appFinished() {
+    if (_finished.isCompleted)
+      return;
+    printStatus('Application finished.');
+    _resetTerminal();
+    _finished.complete(0);
+  }
+
+  void _resetTerminal() {
+    if (usesTerminalUI)
+      terminal.singleCharMode = false;
+  }
+
+  void setupTerminal() {
+    if (usesTerminalUI) {
+      if (!logger.quiet)
+        printHelp();
+
+      terminal.singleCharMode = true;
+      terminal.onCharInput.listen((String code) {
+        if (!_commonTerminalInputHandler(code)) {
+          handleTerminalCommand(code);
+        }
+      });
+    }
+  }
+
+  Future<int> waitForAppToFinish() async {
+    int exitCode = await _finished.future;
+    await cleanupAtFinish();
+    return exitCode;
+  }
+
+  Future<Null> stopApp() {
+    if (serviceProtocol != null && !serviceProtocol.isClosed) {
+      if (serviceProtocol.isolates.isNotEmpty) {
+        serviceProtocol.flutterExit(serviceProtocol.firstIsolateId);
+        return new Future<Null>.delayed(new Duration(milliseconds: 100));
+      }
+    }
+    appFinished();
+    return new Future<Null>.value();
+  }
+
+  /// Called when a signal has requested we exit.
+  Future<Null> cleanupAfterSignal();
+  /// Called right before we exit.
+  Future<Null> cleanupAtFinish();
+  /// Called to print help to the terminal.
+  void printHelp();
+  /// Called when the runner should handle a terminal command.
+  void handleTerminalCommand(String code);
+}
+
+/// Given the value of the --target option, return the path of the Dart file
+/// where the app's main function should be.
+String findMainDartFile([String target]) {
+  if (target == null)
+    target = '';
+  String targetPath = path.absolute(target);
+  if (FileSystemEntity.isDirectorySync(targetPath))
+    return path.join(targetPath, 'lib', 'main.dart');
+  else
+    return targetPath;
+}
+
+String getMissingPackageHintForPlatform(TargetPlatform platform) {
+  switch (platform) {
+    case TargetPlatform.android_arm:
+    case TargetPlatform.android_x64:
+      return 'Is your project missing an android/AndroidManifest.xml?\nConsider running "flutter create ." to create one.';
+    case TargetPlatform.ios:
+      return 'Is your project missing an ios/Runner/Info.plist?\nConsider running "flutter create ." to create one.';
+    default:
+      return null;
+  }
+}
diff --git a/packages/flutter_tools/lib/src/run.dart b/packages/flutter_tools/lib/src/run.dart
index 1ee4eab..098f4b5 100644
--- a/packages/flutter_tools/lib/src/run.dart
+++ b/packages/flutter_tools/lib/src/run.dart
@@ -5,66 +5,32 @@
 import 'dart:async';
 import 'dart:io';
 
-import 'package:path/path.dart' as path;
-
 import 'application_package.dart';
 import 'base/logger.dart';
 import 'base/utils.dart';
-import 'build_info.dart';
-import 'cache.dart';
 import 'commands/build_apk.dart';
 import 'commands/install.dart';
 import 'commands/trace.dart';
 import 'device.dart';
 import 'globals.dart';
 import 'observatory.dart';
-import 'devfs.dart';
+import 'resident_runner.dart';
 
-/// Given the value of the --target option, return the path of the Dart file
-/// where the app's main function should be.
-String findMainDartFile([String target]) {
-  if (target == null)
-    target = '';
-  String targetPath = path.absolute(target);
-  if (FileSystemEntity.isDirectorySync(targetPath))
-    return path.join(targetPath, 'lib', 'main.dart');
-  else
-    return targetPath;
-}
-
-String getDevFSLoaderScript() {
-  return path.absolute(path.join(Cache.flutterRoot,
-                                 'packages',
-                                 'flutter',
-                                 'bin',
-                                 'loader',
-                                 'loader_app.dart'));
-}
-
-class RunAndStayResident {
+class RunAndStayResident extends ResidentRunner {
   RunAndStayResident(
-    this.device, {
-    this.target,
-    this.debuggingOptions,
-    this.usesTerminalUI: true,
-    this.hotMode: false
-  });
-
-  final Device device;
-  final String target;
-  final DebuggingOptions debuggingOptions;
-  final bool usesTerminalUI;
-  final bool hotMode;
+    Device device, {
+    String target,
+    DebuggingOptions debuggingOptions,
+    bool usesTerminalUI: true
+  }) : super(device,
+             target: target,
+             debuggingOptions: debuggingOptions,
+             usesTerminalUI: usesTerminalUI);
 
   ApplicationPackage _package;
   String _mainPath;
   LaunchResult _result;
 
-  final Completer<int> _exitCompleter = new Completer<int>();
-  StreamSubscription<String> _loggingSubscription;
-
-  Observatory observatory;
-
   /// Start the app and keep the process running during its lifetime.
   Future<int> run({
     bool traceStartup: false,
@@ -86,7 +52,7 @@
   }
 
   Future<bool> restart() async {
-    if (observatory == null) {
+    if (serviceProtocol == null) {
       printError('Debugging is not enabled.');
       return false;
     } else {
@@ -95,7 +61,7 @@
       Future<Event> extensionAddedEvent;
 
       if (device.restartSendsFrameworkInitEvent) {
-        extensionAddedEvent = observatory.onExtensionEvent
+        extensionAddedEvent = serviceProtocol.onExtensionEvent
           .where((Event event) => event.extensionKind == 'Flutter.FrameworkInitialization')
           .first;
       }
@@ -104,7 +70,7 @@
         _package,
         _result,
         mainPath: _mainPath,
-        observatory: observatory
+        observatory: serviceProtocol
       );
 
       status.stop(showElapsedTime: true);
@@ -118,11 +84,6 @@
     }
   }
 
-  Future<Null> stop() {
-    _stopLogger();
-    return _stopApp();
-  }
-
   Future<int> _run({
     bool traceStartup: false,
     bool benchmark: false,
@@ -186,19 +147,13 @@
     if (traceStartup != null)
       platformArgs = <String, dynamic>{ 'trace-startup': traceStartup };
 
-    if (!hotMode || (hotMode && !device.needsDevFS))
-      printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
-
-
-    _loggingSubscription = device.logReader.logLines.listen((String line) {
-      if (!line.contains('Observatory listening on http') && !line.contains('Diagnostic server listening on http'))
-        printStatus(line);
-    });
+    await startEchoingDeviceLog();
+    printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
 
     _result = await device.startApp(
       _package,
       debuggingOptions.buildMode,
-      mainPath: (hotMode && device.needsDevFS) ? getDevFSLoaderScript() : _mainPath,
+      mainPath: _mainPath,
       debuggingOptions: debuggingOptions,
       platformArgs: platformArgs,
       route: route
@@ -206,7 +161,7 @@
 
     if (!_result.started) {
       printError('Error running application on ${device.name}.');
-      await _loggingSubscription.cancel();
+      await stopEchoingDeviceLog();
       return 2;
     }
 
@@ -217,101 +172,21 @@
 
     // Connect to observatory.
     if (debuggingOptions.debuggingEnabled) {
-      observatory = await Observatory.connect(_result.observatoryPort);
-      printTrace('Connected to observatory port: ${_result.observatoryPort}.');
-      observatory.populateIsolateInfo();
-      observatory.onExtensionEvent.listen((Event event) {
-        printTrace(event.toString());
-      });
-      observatory.onIsolateEvent.listen((Event event) {
-        printTrace(event.toString());
-      });
-
-      if (hotMode && device.needsDevFS) {
-        _loaderShowMessage('Connecting...', progress: 0);
-        bool result = await _updateDevFS(
-          progressReporter: (int progress, int max) {
-            _loaderShowMessage('Syncing files to device...', progress: progress, max: max);
-          }
-        );
-        if (!result) {
-          _loaderShowMessage('Failed.');
-          printError('Could not perform initial file synchronization.');
-          return 3;
-        }
-        printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
-        _loaderShowMessage('Launching...');
-        await _launchFromDevFS(_package, _mainPath);
-      }
+      await connectToServiceProtocol(_result.observatoryPort);
 
       if (benchmark)
-        await observatory.waitFirstIsolate;
-
-      // Listen for observatory connection close.
-      observatory.done.whenComplete(() {
-        if (!_exitCompleter.isCompleted) {
-          printStatus('Application finished.');
-          _exitCompleter.complete(0);
-        }
-      });
+        await serviceProtocol.waitFirstIsolate;
     }
 
     printStatus('Application running.');
 
-    if (observatory != null && traceStartup) {
+    if (serviceProtocol != null && traceStartup) {
       printStatus('Downloading startup trace info...');
-
-      await downloadStartupTrace(observatory);
-
-      if (!_exitCompleter.isCompleted)
-        _exitCompleter.complete(0);
+      await downloadStartupTrace(serviceProtocol);
+      appFinished();
     } else {
-      if (usesTerminalUI) {
-        if (!logger.quiet)
-          _printHelp();
-
-        terminal.singleCharMode = true;
-        terminal.onCharInput.listen((String code) {
-          printStatus(''); // the key the user tapped might be on this line
-          final String lower = code.toLowerCase();
-          if (lower == 'h' || lower == '?' || code == AnsiTerminal.KEY_F1) {
-            // F1, help
-            _printHelp();
-          } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
-            // F5, restart
-            if (hotMode && code == 'r') {
-              // lower-case 'r'
-              _reloadSources();
-            } else {
-              // upper-case 'r', or hot restart disabled
-              if (device.supportsRestart)
-                restart();
-            }
-          } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
-            // F10, exit
-            _stopApp();
-          } else if (lower == 'w') {
-            _debugDumpApp();
-          } else if (lower == 't') {
-            _debugDumpRenderTree();
-          }
-        });
-      }
-
-      ProcessSignal.SIGINT.watch().listen((ProcessSignal signal) async {
-        _resetTerminal();
-        await _cleanupDevFS();
-        await _stopLogger();
-        await _stopApp();
-        exit(0);
-      });
-      ProcessSignal.SIGTERM.watch().listen((ProcessSignal signal) async {
-        _resetTerminal();
-        await _cleanupDevFS();
-        await _stopLogger();
-        await _stopApp();
-        exit(0);
-      });
+      setupTerminal();
+      registerSignalHandlers();
     }
 
     if (benchmark) {
@@ -329,159 +204,36 @@
       stop();
     }
 
-    return _exitCompleter.future.then((int exitCode) async {
-      _resetTerminal();
-      _stopLogger();
-      return exitCode;
-    });
+    return await waitForAppToFinish();
   }
 
-  void _debugDumpApp() {
-    observatory.flutterDebugDumpApp(observatory.firstIsolateId);
-  }
-
-  void _debugDumpRenderTree() {
-    observatory.flutterDebugDumpRenderTree(observatory.firstIsolateId);
-  }
-
-  void _loaderShowMessage(String message, { int progress, int max }) {
-    observatory.flutterLoaderShowMessage(observatory.firstIsolateId, message);
-    if (progress != null) {
-      observatory.flutterLoaderSetProgress(observatory.firstIsolateId, progress.toDouble());
-      observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, max?.toDouble() ?? 0.0);
-    } else {
-      observatory.flutterLoaderSetProgress(observatory.firstIsolateId, 0.0);
-      observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, -1.0);
-    }
-  }
-
-  DevFS _devFS;
-  String _devFSProjectRootPath;
-  Future<bool> _updateDevFS({ DevFSProgressReporter progressReporter }) async {
-    if (_devFS == null) {
-      Directory directory = Directory.current;
-      _devFSProjectRootPath = directory.path;
-      String fsName = path.basename(directory.path);
-      _devFS = new DevFS(observatory, fsName, directory);
-
-      try {
-        await _devFS.create();
-      } catch (error) {
-        _devFS = null;
-        printError('Error initializing DevFS: $error');
-        return false;
-      }
-
-      _exitCompleter.future.then((_) async {
-        await _cleanupDevFS();
-      });
-    }
-
-    Status devFSStatus = logger.startProgress('Syncing files on device...');
-    await _devFS.update(progressReporter: progressReporter);
-    devFSStatus.stop(showElapsedTime: true);
-    printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB');
-    return true;
-  }
-
-  Future<Null> _cleanupDevFS() async {
-    if (_devFS != null) {
-      // Cleanup the devFS.
-      await _devFS.destroy();
-    }
-    _devFS = null;
-  }
-
-  Future<Null> _launchFromDevFS(ApplicationPackage package,
-                                String mainScript) async {
-    String entryPath = path.relative(mainScript, from: _devFSProjectRootPath);
-    String deviceEntryPath =
-        _devFS.baseUri.resolve(entryPath).toFilePath();
-    String devicePackagesPath =
-        _devFS.baseUri.resolve('.packages').toFilePath();
-    await device.runFromFile(package,
-                             deviceEntryPath,
-                             devicePackagesPath);
-  }
-
-  Future<bool> _reloadSources() async {
-    if (observatory.firstIsolateId == null)
-      throw 'Application isolate not found';
-    if (_devFS != null)
-      await _updateDevFS();
-    Status reloadStatus = logger.startProgress('Performing hot reload');
-    try {
-      await observatory.reloadSources(observatory.firstIsolateId);
-    } catch (errorMessage) {
-      reloadStatus.stop(showElapsedTime: true);
-      printError('Hot reload was rejected:\n$errorMessage');
-      return false;
-    }
-    reloadStatus.stop(showElapsedTime: true);
-    Status reassembleStatus =
-        logger.startProgress('Reassembling application');
-    try {
-      await observatory.flutterReassemble(observatory.firstIsolateId);
-    } catch (_) {
-      reassembleStatus.stop(showElapsedTime: true);
-      printError('Reassembling application failed.');
-      return false;
-    }
-    reassembleStatus.stop(showElapsedTime: true);
-    return true;
-  }
-
-  void _printHelp() {
-    printStatus('Type "h" or F1 for this help message. Type "q", F10, or ctrl-c to quit.', emphasis: true);
-    String hot = '';
-    String cold = '';
-    if (hotMode)
-      hot = 'Type "r" or F5 to perform a hot reload of the app';
-    if (device.supportsRestart) {
-      if (hotMode) {
-        cold = ', and "R" to cold restart the app';
-      } else {
-        cold = 'Type "r" or F5 to restart the app';
+  @override
+  void handleTerminalCommand(String code) {
+    String lower = code.toLowerCase();
+    if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
+      if (device.supportsRestart) {
+        // F5, restart
+        restart();
       }
     }
-    if (hot != '' || cold != '')
-      printStatus('$hot$cold.', emphasis: true);
-    printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.', emphasis: true);
   }
 
-  Future<dynamic> _stopLogger() {
-    return _loggingSubscription?.cancel();
+  @override
+  Future<Null> cleanupAfterSignal() async {
+    await stopEchoingDeviceLog();
+    await stopApp();
   }
 
-  void _resetTerminal() {
-    if (usesTerminalUI)
-      terminal.singleCharMode = false;
+  @override
+  Future<Null> cleanupAtFinish() async {
+    await stopEchoingDeviceLog();
   }
 
-  Future<Null> _stopApp() {
-    if (observatory != null && !observatory.isClosed) {
-      if (observatory.isolates.isNotEmpty) {
-        observatory.flutterExit(observatory.firstIsolateId);
-        return new Future<Null>.delayed(new Duration(milliseconds: 100));
-      }
-    }
-
-    if (!_exitCompleter.isCompleted)
-      _exitCompleter.complete(0);
-
-    return new Future<Null>.value();
-  }
-}
-
-String getMissingPackageHintForPlatform(TargetPlatform platform) {
-  switch (platform) {
-    case TargetPlatform.android_arm:
-    case TargetPlatform.android_x64:
-      return 'Is your project missing an android/AndroidManifest.xml?\nConsider running "flutter create ." to create one.';
-    case TargetPlatform.ios:
-      return 'Is your project missing an ios/Runner/Info.plist?\nConsider running "flutter create ." to create one.';
-    default:
-      return null;
+  @override
+  void printHelp() {
+    String restartText = device.supportsRestart ? ', "r" or F5 to restart the app,' : '';
+    printStatus('Type "h" or F1 for help$restartText and "q", F10, or ctrl-c to quit.');
+    printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.');
   }
 }