Detect and cleanup leaky processes (#29196)

* Detect and cleanup leaky processes
* Add flaky tests for detecting leaked processes
diff --git a/dev/devicelab/bin/tasks/run_without_leak_linux.dart b/dev/devicelab/bin/tasks/run_without_leak_linux.dart
new file mode 100644
index 0000000..614ea70
--- /dev/null
+++ b/dev/devicelab/bin/tasks/run_without_leak_linux.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2019 The Flutter 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:flutter_devicelab/framework/utils.dart';
+import 'package:flutter_devicelab/tasks/run_without_leak.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:path/path.dart' as path;
+
+Future<void> main() async {
+  await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
+}
diff --git a/dev/devicelab/bin/tasks/run_without_leak_mac.dart b/dev/devicelab/bin/tasks/run_without_leak_mac.dart
new file mode 100644
index 0000000..614ea70
--- /dev/null
+++ b/dev/devicelab/bin/tasks/run_without_leak_mac.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2019 The Flutter 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:flutter_devicelab/framework/utils.dart';
+import 'package:flutter_devicelab/tasks/run_without_leak.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:path/path.dart' as path;
+
+Future<void> main() async {
+  await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
+}
diff --git a/dev/devicelab/bin/tasks/run_without_leak_win.dart b/dev/devicelab/bin/tasks/run_without_leak_win.dart
new file mode 100644
index 0000000..614ea70
--- /dev/null
+++ b/dev/devicelab/bin/tasks/run_without_leak_win.dart
@@ -0,0 +1,14 @@
+// Copyright (c) 2019 The Flutter 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:flutter_devicelab/framework/utils.dart';
+import 'package:flutter_devicelab/tasks/run_without_leak.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:path/path.dart' as path;
+
+Future<void> main() async {
+  await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
+}
diff --git a/dev/devicelab/lib/framework/framework.dart b/dev/devicelab/lib/framework/framework.dart
index b3063a5..c7372a7 100644
--- a/dev/devicelab/lib/framework/framework.dart
+++ b/dev/devicelab/lib/framework/framework.dart
@@ -11,6 +11,7 @@
 import 'package:logging/logging.dart';
 import 'package:stack_trace/stack_trace.dart';
 
+import 'running_processes.dart';
 import 'utils.dart';
 
 /// Maximum amount of time a single task is allowed to take to run.
@@ -82,7 +83,37 @@
     try {
       _taskStarted = true;
       print('Running task.');
-      final TaskResult result = await _performTask().timeout(taskTimeout);
+      final String exe = Platform.isWindows ? '.exe' : '';
+      section('Checking running Dart$exe processes');
+      final Set<RunningProcessInfo> beforeRunningDartInstances = await getRunningProcesses(
+        processName: 'dart$exe',
+      ).toSet();
+      beforeRunningDartInstances.forEach(print);
+
+      TaskResult result = await _performTask().timeout(taskTimeout);
+
+      section('Checking running Dart$exe processes after task...');
+      final List<RunningProcessInfo> afterRunningDartInstances = await getRunningProcesses(
+        processName: 'dart$exe',
+      ).toList();
+      for (final RunningProcessInfo info in afterRunningDartInstances) {
+        if (!beforeRunningDartInstances.contains(info)) {
+          print('$info was leaked by this test.');
+          // TODO(dnfield): remove this special casing after https://github.com/flutter/flutter/issues/29141 is resolved.
+          if (result is TaskResultCheckProcesses) {
+            result = TaskResult.failure('This test leaked dart processes');
+          } else {
+            result = TaskResult.success(null);
+          }
+          final bool killed = await killProcess(info.pid);
+          if (!killed) {
+            print('Failed to kill process ${info.pid}.');
+          } else {
+            print('Killed process id ${info.pid}.');
+          }
+        }
+      }
+
       _completer.complete(result);
       return result;
     } on TimeoutException catch (_) {
@@ -231,3 +262,7 @@
     return json;
   }
 }
+
+class TaskResultCheckProcesses extends TaskResult {
+  TaskResultCheckProcesses() : super.success(null);
+}
diff --git a/dev/devicelab/lib/framework/running_processes.dart b/dev/devicelab/lib/framework/running_processes.dart
new file mode 100644
index 0000000..0efb77c
--- /dev/null
+++ b/dev/devicelab/lib/framework/running_processes.dart
@@ -0,0 +1,261 @@
+// Copyright 2019 The Flutter 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:io';
+
+import 'package:meta/meta.dart';
+import 'package:process/process.dart';
+
+@immutable
+class RunningProcessInfo {
+  const RunningProcessInfo(this.pid, this.creationDate, this.commandLine)
+      : assert(pid != null),
+        assert(commandLine != null);
+
+  final String commandLine;
+  final int pid;
+  final DateTime creationDate;
+
+  @override
+  bool operator ==(Object other) {
+    return other is RunningProcessInfo &&
+        other.pid == pid &&
+        other.commandLine == commandLine &&
+        other.creationDate == creationDate;
+  }
+
+  @override
+  int get hashCode {
+    // TODO(dnfield): Replace this when Object.hashValues lands.
+    int hash = 17;
+    if (pid != null) {
+      hash = hash * 23 + pid.hashCode;
+    }
+    if (commandLine != null) {
+      hash = hash * 23 + commandLine.hashCode;
+    }
+    if (creationDate != null) {
+      hash = hash * 23 + creationDate.hashCode;
+    }
+    return hash;
+  }
+
+  @override
+  String toString() {
+    return 'RunningProcesses{pid: $pid, commandLine: $commandLine, creationDate: $creationDate}';
+  }
+}
+
+Future<bool> killProcess(int pid, {ProcessManager processManager}) async {
+  assert(pid != null, 'Must specify a pid to kill');
+  processManager ??= const LocalProcessManager();
+  ProcessResult result;
+  if (Platform.isWindows) {
+    result = await processManager.run(<String>[
+      'taskkill.exe',
+      '/pid',
+      pid.toString(),
+      '/f',
+    ]);
+  } else {
+    result = await processManager.run(<String>[
+      'kill',
+      '-9',
+      pid.toString(),
+    ]);
+  }
+  return result.exitCode == 0;
+}
+
+Stream<RunningProcessInfo> getRunningProcesses({
+  String processName,
+  ProcessManager processManager,
+}) {
+  processManager ??= const LocalProcessManager();
+  if (Platform.isWindows) {
+    return windowsRunningProcesses(processName);
+  }
+  return posixRunningProcesses(processName, processManager);
+}
+
+@visibleForTesting
+Stream<RunningProcessInfo> windowsRunningProcesses(String processName) async* {
+  // PowerShell script to get the command line arguments and create time of
+  // a process.
+  // See: https://docs.microsoft.com/en-us/windows/desktop/cimwin32prov/win32-process
+  final String script = processName != null
+      ? '"Get-CimInstance Win32_Process -Filter \\\"name=\'$processName\'\\\" | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"'
+      : '"Get-CimInstance Win32_Process | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"';
+  // Unfortunately, there doesn't seem to be a good way to get ProcessManager to
+  // run this. May be a bug in Dart.
+  // TODO(dnfield): fix this when https://github.com/dart-lang/sdk/issues/36175 is resolved.
+  final ProcessResult result = await Process.run(
+    'powershell -command $script',
+    <String>[],
+  );
+  if (result.exitCode != 0) {
+    print('Could not list processes!');
+    print(result.stderr);
+    print(result.stdout);
+    return;
+  }
+  for (RunningProcessInfo info in processPowershellOutput(result.stdout)) {
+    yield info;
+  }
+}
+
+/// Parses the output of the PowerShell script from [windowsRunningProcesses].
+///
+/// E.g.:
+/// ProcessId CreationDate          CommandLine
+/// --------- ------------          -----------
+///      2904 3/11/2019 11:01:54 AM "C:\Program Files\Android\Android Studio\jre\bin\java.exe" -Xmx1536M -Dfile.encoding=windows-1252 -Duser.country=US -Duser.language=en -Duser.variant -cp C:\Users\win1\.gradle\wrapper\dists\gradle-4.10.2-all\9fahxiiecdb76a5g3aw9oi8rv\gradle-4.10.2\lib\gradle-launcher-4.10.2.jar org.gradle.launcher.daemon.bootstrap.GradleDaemon 4.10.2
+@visibleForTesting
+Iterable<RunningProcessInfo> processPowershellOutput(String output) sync* {
+  if (output == null) {
+    return;
+  }
+
+  const int processIdHeaderSize = 'ProcessId'.length;
+  const int creationDateHeaderStart = processIdHeaderSize + 1;
+  int creationDateHeaderEnd;
+  int commandLineHeaderStart;
+  bool inTableBody = false;
+  for (String line in output.split('\n')) {
+    if (line.startsWith('ProcessId')) {
+      commandLineHeaderStart = line.indexOf('CommandLine');
+      creationDateHeaderEnd = commandLineHeaderStart - 1;
+    }
+    if (line.startsWith('--------- ------------')) {
+      inTableBody = true;
+      continue;
+    }
+    if (!inTableBody || line.isEmpty) {
+      continue;
+    }
+    if (line.length < commandLineHeaderStart) {
+      continue;
+    }
+
+    // 3/11/2019 11:01:54 AM
+    // 12/11/2019 11:01:54 AM
+    String rawTime = line.substring(
+      creationDateHeaderStart,
+      creationDateHeaderEnd,
+    ).trim();
+
+    if (rawTime[1] == '/') {
+      rawTime = '0$rawTime';
+    }
+    if (rawTime[4] == '/') {
+      rawTime = rawTime.substring(0, 3) + '0' + rawTime.substring(3);
+    }
+    final String year = rawTime.substring(6, 10);
+    final String month = rawTime.substring(3, 5);
+    final String day = rawTime.substring(0, 2);
+    String time = rawTime.substring(11, 19);
+    if (time[7] == ' ') {
+      time = '0$time'.trim();
+    }
+    if (rawTime.endsWith('PM')) {
+      final int hours = int.parse(time.substring(0, 2));
+      time = '${hours + 12}${time.substring(2)}';
+    }
+
+    final int pid = int.parse(line.substring(0, processIdHeaderSize).trim());
+    final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
+    final String commandLine = line.substring(commandLineHeaderStart).trim();
+    yield RunningProcessInfo(pid, creationDate, commandLine);
+  }
+}
+
+@visibleForTesting
+Stream<RunningProcessInfo> posixRunningProcesses(
+  String processName,
+  ProcessManager processManager,
+) async* {
+  // Cirrus is missing this in Linux for some reason.
+  if (!processManager.canRun('ps')) {
+    print('Cannot list processes on this system: `ps` not available.');
+    return;
+  }
+  final ProcessResult result = await processManager.run(<String>[
+    'ps',
+    '-eo',
+    'lstart,pid,command',
+  ]);
+  if (result.exitCode != 0) {
+    print('Could not list processes!');
+    print(result.stderr);
+    print(result.stdout);
+    return;
+  }
+  for (RunningProcessInfo info in processPsOutput(result.stdout, processName)) {
+    yield info;
+  }
+}
+
+/// Parses the output of the command in [posixRunningProcesses].
+///
+/// E.g.:
+///
+/// STARTED                        PID COMMAND
+/// Sat Mar  9 20:12:47 2019         1 /sbin/launchd
+/// Sat Mar  9 20:13:00 2019        49 /usr/sbin/syslogd
+@visibleForTesting
+Iterable<RunningProcessInfo> processPsOutput(
+  String output,
+  String processName,
+) sync* {
+  if (output == null) {
+    return;
+  }
+  bool inTableBody = false;
+  for (String line in output.split('\n')) {
+    if (line.trim().startsWith('STARTED')) {
+      inTableBody = true;
+      continue;
+    }
+    if (!inTableBody || line.isEmpty) {
+      continue;
+    }
+
+    if (processName != null && !line.contains(processName)) {
+      continue;
+    }
+    if (line.length < 25) {
+      continue;
+    }
+
+    // 'Sat Feb 16 02:29:55 2019'
+    // 'Sat Mar  9 20:12:47 2019'
+    const Map<String, String> months = <String, String>{
+      'Jan': '01',
+      'Feb': '02',
+      'Mar': '03',
+      'Apr': '04',
+      'May': '05',
+      'Jun': '06',
+      'Jul': '07',
+      'Aug': '08',
+      'Sep': '09',
+      'Oct': '10',
+      'Nov': '11',
+      'Dec': '12',
+    };
+    final String rawTime = line.substring(0, 24);
+
+    final String year = rawTime.substring(20, 24);
+    final String month = months[rawTime.substring(4, 7)];
+    final String day = rawTime.substring(8, 10).replaceFirst(' ', '0');
+    final String time = rawTime.substring(11, 19);
+
+    final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
+    line = line.substring(24).trim();
+    final int nextSpace = line.indexOf(' ');
+    final int pid = int.parse(line.substring(0, nextSpace));
+    final String commandLine = line.substring(nextSpace + 1);
+    yield RunningProcessInfo(pid, creationDate, commandLine);
+  }
+}
diff --git a/dev/devicelab/lib/tasks/run_without_leak.dart b/dev/devicelab/lib/tasks/run_without_leak.dart
new file mode 100644
index 0000000..eb9ff50
--- /dev/null
+++ b/dev/devicelab/lib/tasks/run_without_leak.dart
@@ -0,0 +1,61 @@
+// Copyright 2019 The Flutter 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:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+
+import '../framework/adb.dart';
+import '../framework/framework.dart';
+import '../framework/utils.dart';
+
+TaskFunction createRunWithoutLeakTest(dynamic dir) {
+  return () async {
+    final Device device = await devices.workingDevice;
+    await device.unlock();
+    final List<String> options = <String>[
+      '-d', device.deviceId, '--verbose',
+    ];
+    setLocalEngineOptionIfNecessary(options);
+    int exitCode;
+    await inDirectory<void>(dir, () async {
+      final Process process = await startProcess(
+          path.join(flutterDirectory.path, 'bin', 'flutter'),
+          <String>['run']..addAll(options),
+          environment: null,
+      );
+      final Completer<void> stdoutDone = Completer<void>();
+      final Completer<void> stderrDone = Completer<void>();
+      process.stdout
+          .transform<String>(utf8.decoder)
+          .transform<String>(const LineSplitter())
+          .listen((String line) {
+        if (line.contains('\] For a more detailed help message, press "h". To detach, press "d"; to quit, press "q"')) {
+          process.stdin.writeln('q');
+        }
+        print('stdout: $line');
+      }, onDone: () {
+        stdoutDone.complete();
+      });
+      process.stderr
+          .transform<String>(utf8.decoder)
+          .transform<String>(const LineSplitter())
+          .listen((String line) {
+        print('stderr: $line');
+      }, onDone: () {
+        stderrDone.complete();
+      });
+
+      await Future.wait<void>(
+          <Future<void>>[stdoutDone.future, stderrDone.future]);
+      exitCode = await process.exitCode;
+    });
+
+    return exitCode == 0
+        ? TaskResultCheckProcesses()
+        : TaskResult.failure('Failed to run $dir');
+  };
+}
diff --git a/dev/devicelab/manifest.yaml b/dev/devicelab/manifest.yaml
index 3402f11..a5db41a 100644
--- a/dev/devicelab/manifest.yaml
+++ b/dev/devicelab/manifest.yaml
@@ -486,6 +486,13 @@
     stage: devicelab_win
     required_agent_capabilities: ["windows/android"]
 
+  run_without_leak_win:
+    description: >
+      Checks that `flutter run` does not leak dart.exe on Windows.
+    stage: devicelab_win
+    required_agent_capabilities: ["windows/android"]
+    flaky: true
+
   # Tests running on Linux hosts
 
   hot_mode_dev_cycle_linux__benchmark:
@@ -551,6 +558,13 @@
     stage: devicelab
     required_agent_capabilities: ["linux/android"]
 
+  run_without_leak_linux:
+    description: >
+      Checks that `flutter run` does not leak dart on Linux.
+    stage: devicelab
+    required_agent_capabilities: ["linux/android"]
+    flaky: true
+
   flutter_gallery_ios32__start_up:
     description: >
       Measures the startup time of the Flutter Gallery app on 32-bit iOS.
@@ -563,3 +577,10 @@
       32-bit iOS.
     stage: devicelab_ios
     required_agent_capabilities: ["mac/ios32"]
+
+  run_without_leak_mac:
+    description: >
+      Checks that `flutter run` does not leak dart on macOS.
+    stage: devicelab
+    required_agent_capabilities: ["mac/android"]
+    flaky: true
diff --git a/dev/devicelab/test/running_processes_test.dart b/dev/devicelab/test/running_processes_test.dart
new file mode 100644
index 0000000..395ada4
--- /dev/null
+++ b/dev/devicelab/test/running_processes_test.dart
@@ -0,0 +1,68 @@
+// Copyright 2018 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 'package:flutter_devicelab/framework/running_processes.dart';
+import 'common.dart';
+
+void main() {
+  test('Parse PowerShell result', () {
+    const String powershellOutput = r'''
+
+ProcessId CreationDate         CommandLine
+--------- ------------         -----------
+     6552 3/7/2019 5:00:27 PM  "C:\tools\dart-sdk\bin\dart.exe" .\bin\agent.dart ci
+     6553 3/7/2019 10:00:27 PM "C:\tools\dart-sdk1\bin\dart.exe" .\bin\agent.dart ci
+     6554 3/7/2019 11:00:27 AM "C:\tools\dart-sdk2\bin\dart.exe" .\bin\agent.dart ci
+
+
+''';
+    final List<RunningProcessInfo> results =
+        processPowershellOutput(powershellOutput).toList();
+    expect(results.length, 3);
+    expect(
+        results,
+        equals(<RunningProcessInfo>[
+          RunningProcessInfo(
+            6552,
+            DateTime(2019, 7, 3, 17, 0, 27),
+            r'"C:\tools\dart-sdk\bin\dart.exe" .\bin\agent.dart ci',
+          ),
+          RunningProcessInfo(
+            6553,
+            DateTime(2019, 7, 3, 22, 0, 27),
+            r'"C:\tools\dart-sdk1\bin\dart.exe" .\bin\agent.dart ci',
+          ),
+          RunningProcessInfo(
+            6554,
+            DateTime(2019, 7, 3, 11, 0, 27),
+            r'"C:\tools\dart-sdk2\bin\dart.exe" .\bin\agent.dart ci',
+          ),
+        ]));
+  });
+
+  test('Parse Posix output', () {
+    const String psOutput = r'''STARTED                        PID COMMAND
+Sat Mar  9 20:12:47 2019         1 /sbin/launchd
+Sat Mar  9 20:13:00 2019        49 /usr/sbin/syslogd
+''';
+
+    final List<RunningProcessInfo> results =
+        processPsOutput(psOutput, null).toList();
+    expect(results.length, 2);
+    expect(
+        results,
+        equals(<RunningProcessInfo>[
+          RunningProcessInfo(
+            1,
+            DateTime(2019, 3, 9, 20, 12, 47),
+            '/sbin/launchd',
+          ),
+          RunningProcessInfo(
+            49,
+            DateTime(2019, 3, 9, 20, 13, 00),
+            '/usr/sbin/syslogd',
+          ),
+        ]));
+  });
+}