Skip to content

Commit ecfdd7e

Browse files
authored
Detect and cleanup leaky processes (flutter#29196)
* Detect and cleanup leaky processes * Add flaky tests for detecting leaked processes
1 parent fa2fd11 commit ecfdd7e

File tree

8 files changed

+489
-1
lines changed

8 files changed

+489
-1
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:flutter_devicelab/framework/utils.dart';
8+
import 'package:flutter_devicelab/tasks/run_without_leak.dart';
9+
import 'package:flutter_devicelab/framework/framework.dart';
10+
import 'package:path/path.dart' as path;
11+
12+
Future<void> main() async {
13+
await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:flutter_devicelab/framework/utils.dart';
8+
import 'package:flutter_devicelab/tasks/run_without_leak.dart';
9+
import 'package:flutter_devicelab/framework/framework.dart';
10+
import 'package:path/path.dart' as path;
11+
12+
Future<void> main() async {
13+
await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
7+
import 'package:flutter_devicelab/framework/utils.dart';
8+
import 'package:flutter_devicelab/tasks/run_without_leak.dart';
9+
import 'package:flutter_devicelab/framework/framework.dart';
10+
import 'package:path/path.dart' as path;
11+
12+
Future<void> main() async {
13+
await task(createRunWithoutLeakTest(path.join(flutterDirectory.path, 'examples', 'hello_world')));
14+
}

dev/devicelab/lib/framework/framework.dart

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'dart:isolate';
1111
import 'package:logging/logging.dart';
1212
import 'package:stack_trace/stack_trace.dart';
1313

14+
import 'running_processes.dart';
1415
import 'utils.dart';
1516

1617
/// Maximum amount of time a single task is allowed to take to run.
@@ -82,7 +83,37 @@ class _TaskRunner {
8283
try {
8384
_taskStarted = true;
8485
print('Running task.');
85-
final TaskResult result = await _performTask().timeout(taskTimeout);
86+
final String exe = Platform.isWindows ? '.exe' : '';
87+
section('Checking running Dart$exe processes');
88+
final Set<RunningProcessInfo> beforeRunningDartInstances = await getRunningProcesses(
89+
processName: 'dart$exe',
90+
).toSet();
91+
beforeRunningDartInstances.forEach(print);
92+
93+
TaskResult result = await _performTask().timeout(taskTimeout);
94+
95+
section('Checking running Dart$exe processes after task...');
96+
final List<RunningProcessInfo> afterRunningDartInstances = await getRunningProcesses(
97+
processName: 'dart$exe',
98+
).toList();
99+
for (final RunningProcessInfo info in afterRunningDartInstances) {
100+
if (!beforeRunningDartInstances.contains(info)) {
101+
print('$info was leaked by this test.');
102+
// TODO(dnfield): remove this special casing after https://github.com/flutter/flutter/issues/29141 is resolved.
103+
if (result is TaskResultCheckProcesses) {
104+
result = TaskResult.failure('This test leaked dart processes');
105+
} else {
106+
result = TaskResult.success(null);
107+
}
108+
final bool killed = await killProcess(info.pid);
109+
if (!killed) {
110+
print('Failed to kill process ${info.pid}.');
111+
} else {
112+
print('Killed process id ${info.pid}.');
113+
}
114+
}
115+
}
116+
86117
_completer.complete(result);
87118
return result;
88119
} on TimeoutException catch (_) {
@@ -231,3 +262,7 @@ class TaskResult {
231262
return json;
232263
}
233264
}
265+
266+
class TaskResultCheckProcesses extends TaskResult {
267+
TaskResultCheckProcesses() : super.success(null);
268+
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright 2019 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
import 'package:meta/meta.dart';
8+
import 'package:process/process.dart';
9+
10+
@immutable
11+
class RunningProcessInfo {
12+
const RunningProcessInfo(this.pid, this.creationDate, this.commandLine)
13+
: assert(pid != null),
14+
assert(commandLine != null);
15+
16+
final String commandLine;
17+
final int pid;
18+
final DateTime creationDate;
19+
20+
@override
21+
bool operator ==(Object other) {
22+
return other is RunningProcessInfo &&
23+
other.pid == pid &&
24+
other.commandLine == commandLine &&
25+
other.creationDate == creationDate;
26+
}
27+
28+
@override
29+
int get hashCode {
30+
// TODO(dnfield): Replace this when Object.hashValues lands.
31+
int hash = 17;
32+
if (pid != null) {
33+
hash = hash * 23 + pid.hashCode;
34+
}
35+
if (commandLine != null) {
36+
hash = hash * 23 + commandLine.hashCode;
37+
}
38+
if (creationDate != null) {
39+
hash = hash * 23 + creationDate.hashCode;
40+
}
41+
return hash;
42+
}
43+
44+
@override
45+
String toString() {
46+
return 'RunningProcesses{pid: $pid, commandLine: $commandLine, creationDate: $creationDate}';
47+
}
48+
}
49+
50+
Future<bool> killProcess(int pid, {ProcessManager processManager}) async {
51+
assert(pid != null, 'Must specify a pid to kill');
52+
processManager ??= const LocalProcessManager();
53+
ProcessResult result;
54+
if (Platform.isWindows) {
55+
result = await processManager.run(<String>[
56+
'taskkill.exe',
57+
'/pid',
58+
pid.toString(),
59+
'/f',
60+
]);
61+
} else {
62+
result = await processManager.run(<String>[
63+
'kill',
64+
'-9',
65+
pid.toString(),
66+
]);
67+
}
68+
return result.exitCode == 0;
69+
}
70+
71+
Stream<RunningProcessInfo> getRunningProcesses({
72+
String processName,
73+
ProcessManager processManager,
74+
}) {
75+
processManager ??= const LocalProcessManager();
76+
if (Platform.isWindows) {
77+
return windowsRunningProcesses(processName);
78+
}
79+
return posixRunningProcesses(processName, processManager);
80+
}
81+
82+
@visibleForTesting
83+
Stream<RunningProcessInfo> windowsRunningProcesses(String processName) async* {
84+
// PowerShell script to get the command line arguments and create time of
85+
// a process.
86+
// See: https://docs.microsoft.com/en-us/windows/desktop/cimwin32prov/win32-process
87+
final String script = processName != null
88+
? '"Get-CimInstance Win32_Process -Filter \\\"name=\'$processName\'\\\" | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"'
89+
: '"Get-CimInstance Win32_Process | Select-Object ProcessId,CreationDate,CommandLine | Format-Table -AutoSize | Out-String -Width 4096"';
90+
// Unfortunately, there doesn't seem to be a good way to get ProcessManager to
91+
// run this. May be a bug in Dart.
92+
// TODO(dnfield): fix this when https://github.com/dart-lang/sdk/issues/36175 is resolved.
93+
final ProcessResult result = await Process.run(
94+
'powershell -command $script',
95+
<String>[],
96+
);
97+
if (result.exitCode != 0) {
98+
print('Could not list processes!');
99+
print(result.stderr);
100+
print(result.stdout);
101+
return;
102+
}
103+
for (RunningProcessInfo info in processPowershellOutput(result.stdout)) {
104+
yield info;
105+
}
106+
}
107+
108+
/// Parses the output of the PowerShell script from [windowsRunningProcesses].
109+
///
110+
/// E.g.:
111+
/// ProcessId CreationDate CommandLine
112+
/// --------- ------------ -----------
113+
/// 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
114+
@visibleForTesting
115+
Iterable<RunningProcessInfo> processPowershellOutput(String output) sync* {
116+
if (output == null) {
117+
return;
118+
}
119+
120+
const int processIdHeaderSize = 'ProcessId'.length;
121+
const int creationDateHeaderStart = processIdHeaderSize + 1;
122+
int creationDateHeaderEnd;
123+
int commandLineHeaderStart;
124+
bool inTableBody = false;
125+
for (String line in output.split('\n')) {
126+
if (line.startsWith('ProcessId')) {
127+
commandLineHeaderStart = line.indexOf('CommandLine');
128+
creationDateHeaderEnd = commandLineHeaderStart - 1;
129+
}
130+
if (line.startsWith('--------- ------------')) {
131+
inTableBody = true;
132+
continue;
133+
}
134+
if (!inTableBody || line.isEmpty) {
135+
continue;
136+
}
137+
if (line.length < commandLineHeaderStart) {
138+
continue;
139+
}
140+
141+
// 3/11/2019 11:01:54 AM
142+
// 12/11/2019 11:01:54 AM
143+
String rawTime = line.substring(
144+
creationDateHeaderStart,
145+
creationDateHeaderEnd,
146+
).trim();
147+
148+
if (rawTime[1] == '/') {
149+
rawTime = '0$rawTime';
150+
}
151+
if (rawTime[4] == '/') {
152+
rawTime = rawTime.substring(0, 3) + '0' + rawTime.substring(3);
153+
}
154+
final String year = rawTime.substring(6, 10);
155+
final String month = rawTime.substring(3, 5);
156+
final String day = rawTime.substring(0, 2);
157+
String time = rawTime.substring(11, 19);
158+
if (time[7] == ' ') {
159+
time = '0$time'.trim();
160+
}
161+
if (rawTime.endsWith('PM')) {
162+
final int hours = int.parse(time.substring(0, 2));
163+
time = '${hours + 12}${time.substring(2)}';
164+
}
165+
166+
final int pid = int.parse(line.substring(0, processIdHeaderSize).trim());
167+
final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
168+
final String commandLine = line.substring(commandLineHeaderStart).trim();
169+
yield RunningProcessInfo(pid, creationDate, commandLine);
170+
}
171+
}
172+
173+
@visibleForTesting
174+
Stream<RunningProcessInfo> posixRunningProcesses(
175+
String processName,
176+
ProcessManager processManager,
177+
) async* {
178+
// Cirrus is missing this in Linux for some reason.
179+
if (!processManager.canRun('ps')) {
180+
print('Cannot list processes on this system: `ps` not available.');
181+
return;
182+
}
183+
final ProcessResult result = await processManager.run(<String>[
184+
'ps',
185+
'-eo',
186+
'lstart,pid,command',
187+
]);
188+
if (result.exitCode != 0) {
189+
print('Could not list processes!');
190+
print(result.stderr);
191+
print(result.stdout);
192+
return;
193+
}
194+
for (RunningProcessInfo info in processPsOutput(result.stdout, processName)) {
195+
yield info;
196+
}
197+
}
198+
199+
/// Parses the output of the command in [posixRunningProcesses].
200+
///
201+
/// E.g.:
202+
///
203+
/// STARTED PID COMMAND
204+
/// Sat Mar 9 20:12:47 2019 1 /sbin/launchd
205+
/// Sat Mar 9 20:13:00 2019 49 /usr/sbin/syslogd
206+
@visibleForTesting
207+
Iterable<RunningProcessInfo> processPsOutput(
208+
String output,
209+
String processName,
210+
) sync* {
211+
if (output == null) {
212+
return;
213+
}
214+
bool inTableBody = false;
215+
for (String line in output.split('\n')) {
216+
if (line.trim().startsWith('STARTED')) {
217+
inTableBody = true;
218+
continue;
219+
}
220+
if (!inTableBody || line.isEmpty) {
221+
continue;
222+
}
223+
224+
if (processName != null && !line.contains(processName)) {
225+
continue;
226+
}
227+
if (line.length < 25) {
228+
continue;
229+
}
230+
231+
// 'Sat Feb 16 02:29:55 2019'
232+
// 'Sat Mar 9 20:12:47 2019'
233+
const Map<String, String> months = <String, String>{
234+
'Jan': '01',
235+
'Feb': '02',
236+
'Mar': '03',
237+
'Apr': '04',
238+
'May': '05',
239+
'Jun': '06',
240+
'Jul': '07',
241+
'Aug': '08',
242+
'Sep': '09',
243+
'Oct': '10',
244+
'Nov': '11',
245+
'Dec': '12',
246+
};
247+
final String rawTime = line.substring(0, 24);
248+
249+
final String year = rawTime.substring(20, 24);
250+
final String month = months[rawTime.substring(4, 7)];
251+
final String day = rawTime.substring(8, 10).replaceFirst(' ', '0');
252+
final String time = rawTime.substring(11, 19);
253+
254+
final DateTime creationDate = DateTime.parse('$year-$month-${day}T$time');
255+
line = line.substring(24).trim();
256+
final int nextSpace = line.indexOf(' ');
257+
final int pid = int.parse(line.substring(0, nextSpace));
258+
final String commandLine = line.substring(nextSpace + 1);
259+
yield RunningProcessInfo(pid, creationDate, commandLine);
260+
}
261+
}

0 commit comments

Comments
 (0)