Add a detach command to detach without terminating (#21490)

* Add a detach command to detach without terminating (#21376)

* Add a detach command to detach without terminating

Fixes #21154.

* Bump protocol version for app.detach

* Tweak to detach/quit text

* Change logPrefix to named param

* Fix the text that the devicelab attach test looks for
diff --git a/dev/devicelab/bin/tasks/flutter_attach_test.dart b/dev/devicelab/bin/tasks/flutter_attach_test.dart
index a3defc5..64c8990 100644
--- a/dev/devicelab/bin/tasks/flutter_attach_test.dart
+++ b/dev/devicelab/bin/tasks/flutter_attach_test.dart
@@ -33,7 +33,7 @@
     stdout.add(line);
     if (line.contains('Waiting') && onListening != null)
       listening.complete(onListening());
-    if (line.contains('To quit, press "q".'))
+    if (line.contains('To detach, press "d"; to quit, press "q".'))
       ready.complete();
     if (line.contains('Reloaded '))
       reloaded.complete();
diff --git a/packages/flutter_tools/doc/daemon.md b/packages/flutter_tools/doc/daemon.md
index a407c8a..5e2c70a 100644
--- a/packages/flutter_tools/doc/daemon.md
+++ b/packages/flutter_tools/doc/daemon.md
@@ -92,6 +92,12 @@
 - `methodName`: the name of the service protocol extension to invoke; this is required.
 - `params`: an optional Map of parameters to pass to the service protocol extension.
 
+#### app.detach
+
+The `detach()` command takes one parameter, `appId`. It returns a `bool` to indicate success or failure in detaching from an app without stopping it.
+
+- `appId`: the id of a previously started app; this is required.
+
 #### app.stop
 
 The `stop()` command takes one parameter, `appId`. It returns a `bool` to indicate success or failure in stopping an app.
@@ -110,7 +116,7 @@
 
 #### app.started
 
-This is sent once the application launch process is complete and the app is either paused before main() (if `startPaused` is true) or main() has begun running. The `params` field will be a map containing the field `appId`.
+This is sent once the application launch process is complete and the app is either paused before main() (if `startPaused` is true) or main() has begun running. When attaching, this even will be fired once attached. The `params` field will be a map containing the field `appId`.
 
 #### app.log
 
@@ -122,7 +128,7 @@
 
 #### app.stop
 
-This is sent when an app is stopped. The `params` field will be a map with the field `appId`.
+This is sent when an app is stopped or detached from. The `params` field will be a map with the field `appId`.
 
 ### device domain
 
@@ -204,6 +210,7 @@
 - Commands
   - [`restart`](#apprestart)
   - [`callServiceExtension`](#appcallserviceextension)
+  - [`detach`](#appdetach)
   - [`stop`](#appstop)
 - Events
   - [`start`](#appstart)
@@ -219,6 +226,7 @@
 
 ## Changelog
 
+- 0.4.2: Added `app.detach` command
 - 0.4.1: Added `flutter attach --machine`
 - 0.4.0: Added `emulator.create` command
 - 0.3.0: Added `daemon.connected` event at startup
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 274f45b..9fd00a4 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -28,7 +28,7 @@
 import '../tester/flutter_tester.dart';
 import '../vmservice.dart';
 
-const String protocolVersion = '0.4.1';
+const String protocolVersion = '0.4.2';
 
 /// A server process command. This command will start up a long-lived server.
 /// It reads JSON-RPC based commands from stdin, executes them, and returns
@@ -316,6 +316,7 @@
     registerHandler('restart', restart);
     registerHandler('callServiceExtension', callServiceExtension);
     registerHandler('stop', stop);
+    registerHandler('detach', detach);
   }
 
   static final Uuid _uuidGenerator = new Uuid();
@@ -516,6 +517,23 @@
     });
   }
 
+  Future<bool> detach(Map<String, dynamic> args) async {
+    final String appId = _getStringArg(args, 'appId', required: true);
+
+    final AppInstance app = _getApp(appId);
+    if (app == null)
+      throw "app '$appId' not found";
+
+    return app.detach().timeout(const Duration(seconds: 5)).then<bool>((_) {
+      return true;
+    }).catchError((dynamic error) {
+      _sendAppEvent(app, 'log', <String, dynamic>{ 'log': '$error', 'error': true });
+      app.closeLogger();
+      _apps.remove(app);
+      return false;
+    });
+  }
+
   AppInstance _getApp(String id) {
     return _apps.firstWhere((AppInstance app) => app.id == id, orElse: () => null);
   }
@@ -769,6 +787,7 @@
   }
 
   Future<Null> stop() => runner.stop();
+  Future<Null> detach() => runner.detach();
 
   void closeLogger() {
     _logger.close();
diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart
index 395d26c..40a3340 100644
--- a/packages/flutter_tools/lib/src/run_hot.dart
+++ b/packages/flutter_tools/lib/src/run_hot.dart
@@ -66,6 +66,7 @@
   final bool benchmarkMode;
   final File applicationBinary;
   final bool hostIsIde;
+  bool _didAttach = false;
   Set<String> _dartDependencies;
   final String dillOutputPath;
 
@@ -152,6 +153,7 @@
     Completer<void> appStartedCompleter,
     String viewFilter,
   }) async {
+    _didAttach = true;
     try {
       await connectToServiceProtocol(viewFilter: viewFilter,
           reloadSources: _reloadSourcesService,
@@ -751,18 +753,25 @@
       for (Uri uri in device.observatoryUris)
         printStatus('An Observatory debugger and profiler on $dname is available at: $uri');
     }
+    final String quitMessage = _didAttach
+        ? 'To detach, press "d"; to quit, press "q".'
+        : 'To quit, press "q".';
     if (details) {
       printHelpDetails();
-      printStatus('To repeat this help message, press "h". To quit, press "q".');
+      printStatus('To repeat this help message, press "h". $quitMessage');
     } else {
-      printStatus('For a more detailed help message, press "h". To quit, press "q".');
+      printStatus('For a more detailed help message, press "h". $quitMessage');
     }
   }
 
   @override
   Future<Null> cleanupAfterSignal() async {
     await stopEchoingDeviceLog();
-    await stopApp();
+    if (_didAttach) {
+      appFinished();
+    } else {
+      await stopApp();
+    }
   }
 
   @override
diff --git a/packages/flutter_tools/test/integration/flutter_attach_test.dart b/packages/flutter_tools/test/integration/flutter_attach_test.dart
index 2a6e104..b31d5f8 100644
--- a/packages/flutter_tools/test/integration/flutter_attach_test.dart
+++ b/packages/flutter_tools/test/integration/flutter_attach_test.dart
@@ -6,7 +6,6 @@
 import 'package:flutter_tools/src/base/file_system.dart';
 
 import '../src/common.dart';
-import '../src/context.dart';
 import 'test_data/basic_project.dart';
 import 'test_driver.dart';
 
@@ -18,24 +17,36 @@
   setUp(() async {
     tempDir = fs.systemTempDirectory.createTempSync('flutter_attach_test.');
     await _project.setUpIn(tempDir);
-    _flutterRun = new FlutterTestDriver(tempDir);
-    _flutterAttach = new FlutterTestDriver(tempDir);
+    _flutterRun = new FlutterTestDriver(tempDir, logPrefix: 'RUN');
+    _flutterAttach = new FlutterTestDriver(tempDir, logPrefix: 'ATTACH');
   });
 
   tearDown(() async {
-    // We can't call stop() on both of these because they'll both try to stop the
-    // same app. Just quit the attach process and then send a stop to the original
-    // process.
+    await _flutterAttach.detach();
     await _flutterRun.stop();
-    await _flutterAttach.quit();
     tryToDelete(tempDir);
   });
 
   group('attached process', () {
-    testUsingContext('can hot reload', () async {
+    test('can hot reload', () async {
       await _flutterRun.run(withDebugger: true);
       await _flutterAttach.attach(_flutterRun.vmServicePort);
       await _flutterAttach.hotReload();
     });
+    test('can detach, reattach, hot reload', () async {
+      await _flutterRun.run(withDebugger: true);
+      await _flutterAttach.attach(_flutterRun.vmServicePort);
+      await _flutterAttach.detach();
+      await _flutterAttach.attach(_flutterRun.vmServicePort);
+      await _flutterAttach.hotReload();
+    });
+    test('killing process behaves the same as detach ', () async {
+      await _flutterRun.run(withDebugger: true);
+      await _flutterAttach.attach(_flutterRun.vmServicePort);
+      await _flutterAttach.quit();
+      _flutterAttach = new FlutterTestDriver(tempDir, logPrefix: 'ATTACH-2');
+      await _flutterAttach.attach(_flutterRun.vmServicePort);
+      await _flutterAttach.hotReload();
+    });
   }, timeout: const Timeout.factor(6));
 }
diff --git a/packages/flutter_tools/test/integration/test_driver.dart b/packages/flutter_tools/test/integration/test_driver.dart
index 892c275..fb350f4 100644
--- a/packages/flutter_tools/test/integration/test_driver.dart
+++ b/packages/flutter_tools/test/integration/test_driver.dart
@@ -23,9 +23,11 @@
 const Duration quitTimeout = Duration(seconds: 10);
 
 class FlutterTestDriver {
-  FlutterTestDriver(this._projectFolder);
+  FlutterTestDriver(this._projectFolder, {String logPrefix}):
+    this._logPrefix = logPrefix != null ? '$logPrefix: ' : '';
 
   final Directory _projectFolder;
+  final String _logPrefix;
   Process _proc;
   int _procPid;
   final StreamController<String> _stdout = new StreamController<String>.broadcast();
@@ -49,7 +51,7 @@
         msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg;
     _allMessages.add(truncatedMsg);
     if (_printJsonAndStderr) {
-      print(truncatedMsg);
+      print('$_logPrefix$truncatedMsg');
     }
     return msg;
   }
@@ -162,6 +164,31 @@
       _throwErrorResponse('Hot ${fullRestart ? 'restart' : 'reload'} request failed');
   }
 
+  Future<int> detach() async {
+    if (vmService != null) {
+      _debugPrint('Closing VM service');
+      await vmService.close()
+          .timeout(quitTimeout,
+              onTimeout: () { _debugPrint('VM Service did not quit within $quitTimeout'); });
+    }
+    if (_currentRunningAppId != null) {
+      _debugPrint('Detaching from app');
+      await Future.any<void>(<Future<void>>[
+        _proc.exitCode,
+        _sendRequest(
+          'app.detach',
+          <String, dynamic>{'appId': _currentRunningAppId}
+        ),
+      ]).timeout(
+        quitTimeout,
+        onTimeout: () { _debugPrint('app.detach did not return within $quitTimeout'); }
+      );
+      _currentRunningAppId = null;
+    }
+    _debugPrint('Waiting for process to end');
+    return _proc.exitCode.timeout(quitTimeout, onTimeout: _killGracefully);
+  }
+
   Future<int> stop() async {
     if (vmService != null) {
       _debugPrint('Closing VM service');