Keep LLDB connection to iOS device alive while running from CLI. (#36194)
## Description
Instead of detaching from the spawned App process on the device immediately, keep the LLDB client connection open (in autopilot mode) until the App quits or the server connection is lost.
This replicates the behavior of Xcode, which also keeps a debugger attached to the App after launching it.
## Tests
This change will be covered by all running benchmarks (which are launched via "flutter run"/"flutter drive"), and probably be covered by all tests as well.
I also tested the workflow locally -- including cases where the App or Flutter CLI is terminated first.
## Breaking Change
I don't believe this should introduce any breaking changes. The LLDB client automatically exits when the app dies or the device is disconnected, so there shouldn't even be any user-visible changes to the behavior of the tool (besides the output of "-v").
diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart
index 1dd5765..ed7084f 100644
--- a/packages/flutter_tools/lib/src/base/process.dart
+++ b/packages/flutter_tools/lib/src/base/process.dart
@@ -129,6 +129,11 @@
/// If [filter] is non-null, all lines that do not match it are removed. If
/// [mapFunction] is present, all lines that match [filter] are also forwarded
/// to [mapFunction] for further processing.
+///
+/// If [detachFilter] is non-null, the returned future will complete with exit code `0`
+/// when the process outputs something matching [detachFilter] to stderr. The process will
+/// continue in the background, and the final exit code will not be reported. [filter] is
+/// not considered on lines matching [detachFilter].
Future<int> runCommandAndStreamOutput(
List<String> cmd, {
String workingDirectory,
@@ -138,7 +143,9 @@
RegExp filter,
StringConverter mapFunction,
Map<String, String> environment,
+ RegExp detachFilter,
}) async {
+ final Completer<int> result = Completer<int>();
final Process process = await runCommand(
cmd,
workingDirectory: workingDirectory,
@@ -148,6 +155,15 @@
final StreamSubscription<String> stdoutSubscription = process.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
+ .map((String line) {
+ if (detachFilter != null && detachFilter.hasMatch(line) && !result.isCompleted) {
+ // Detach from the process, assuming it will eventually complete successfully.
+ // Output printed after detaching (incl. stdout and stderr) will still be
+ // processed by [filter] and [mapFunction].
+ result.complete(0);
+ }
+ return line;
+ })
.where((String line) => filter == null || filter.hasMatch(line))
.listen((String line) {
if (mapFunction != null)
@@ -171,19 +187,28 @@
printError('$prefix$line', wrap: false);
});
- // Wait for stdout to be fully processed
- // because process.exitCode may complete first causing flaky tests.
- await waitGroup<void>(<Future<void>>[
- stdoutSubscription.asFuture<void>(),
- stderrSubscription.asFuture<void>(),
- ]);
+ // Wait for stdout to be fully processed before completing with the exit code (non-detached case),
+ // because process.exitCode may complete first causing flaky tests. If the process detached,
+ // we at least have a predictable output for stdout, although (unavoidably) not for stderr.
+ Future<void> readOutput() async {
+ await waitGroup<void>(<Future<void>>[
+ stdoutSubscription.asFuture<void>(),
+ stderrSubscription.asFuture<void>(),
+ ]);
- await waitGroup<void>(<Future<void>>[
- stdoutSubscription.cancel(),
- stderrSubscription.cancel(),
- ]);
+ await waitGroup<void>(<Future<void>>[
+ stdoutSubscription.cancel(),
+ stderrSubscription.cancel(),
+ ]);
- return await process.exitCode;
+ // Complete the future if the we did not detach the process yet.
+ if (!result.isCompleted) {
+ result.complete(process.exitCode);
+ }
+ }
+
+ unawaited(readOutput());
+ return result.future;
}
/// Runs the [command] interactively, connecting the stdin/stdout/stderr
diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart
index 33e520d..b38e2d6 100644
--- a/packages/flutter_tools/lib/src/ios/devices.dart
+++ b/packages/flutter_tools/lib/src/ios/devices.dart
@@ -48,7 +48,7 @@
'--bundle',
bundlePath,
'--no-wifi',
- '--justlaunch',
+ '--noninteractive',
];
if (launchArguments.isNotEmpty) {
launchCommand.add('--args');
@@ -66,11 +66,14 @@
iosDeployEnv['PATH'] = '/usr/bin:${iosDeployEnv['PATH']}';
iosDeployEnv.addEntries(<MapEntry<String, String>>[cache.dyLdLibEntry]);
+ // Detach from the ios-deploy process once it' outputs 'autoexit', signaling that the
+ // App has been started and LLDB is in "autopilot" mode.
return await runCommandAndStreamOutput(
launchCommand,
mapFunction: _monitorInstallationFailure,
trace: true,
environment: iosDeployEnv,
+ detachFilter: RegExp('.*autoexit.*')
);
}
diff --git a/packages/flutter_tools/test/general.shard/base/process_test.dart b/packages/flutter_tools/test/general.shard/base/process_test.dart
index 8ffca5e..14f7d38 100644
--- a/packages/flutter_tools/test/general.shard/base/process_test.dart
+++ b/packages/flutter_tools/test/general.shard/base/process_test.dart
@@ -2,17 +2,20 @@
// 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_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/process.dart';
import 'package:flutter_tools/src/base/terminal.dart';
+import 'package:flutter_tools/src/convert.dart';
import 'package:mockito/mockito.dart';
import 'package:process/process.dart';
import '../../src/common.dart';
import '../../src/context.dart';
-import '../../src/mocks.dart' show MockProcess, MockProcessManager;
+import '../../src/mocks.dart' show FakeProcess, MockProcess, MockProcessManager;
void main() {
group('process exceptions', () {
@@ -90,6 +93,43 @@
Platform: () => FakePlatform.fromPlatform(const LocalPlatform())..stdoutSupportsAnsi = false,
});
});
+
+ group('runCommandAndStreamOutput', () {
+ ProcessManager mockProcessManager;
+ const Utf8Encoder utf8 = Utf8Encoder();
+
+ setUp(() {
+ mockProcessManager = PlainMockProcessManager();
+ });
+
+ testUsingContext('detach after detachFilter matches', () async {
+ // Create a fake process which outputs three lines ("foo", "bar" and "baz")
+ // to stdout, nothing to stderr, and doesn't exit.
+ final Process fake = FakeProcess(
+ exitCode: Completer<int>().future,
+ stdout: Stream<List<int>>.fromIterable(
+ <String>['foo\n', 'bar\n', 'baz\n'].map(utf8.convert)),
+ stderr: const Stream<List<int>>.empty());
+
+ when(mockProcessManager.start(<String>['test1'])).thenAnswer((_) => Future<Process>.value(fake));
+
+ // Detach when we see "bar", and check that:
+ // - mapFunction still gets run on "baz",
+ // - we don't wait for the process to terminate (it never will), and
+ // - we get an exit-code of 0 back.
+ bool seenBaz = false;
+ String mapFunction(String line) {
+ seenBaz = seenBaz || line == 'baz';
+ return line;
+ }
+
+ final int exitCode = await runCommandAndStreamOutput(
+ <String>['test1'], mapFunction: mapFunction, detachFilter: RegExp('.*baz.*'));
+
+ expect(exitCode, 0);
+ expect(seenBaz, true);
+ }, overrides: <Type, Generator>{ProcessManager: () => mockProcessManager});
+ });
}
class PlainMockProcessManager extends Mock implements ProcessManager {}
diff --git a/packages/flutter_tools/test/general.shard/dart/pub_get_test.dart b/packages/flutter_tools/test/general.shard/dart/pub_get_test.dart
index 48093ca..6e918c9 100644
--- a/packages/flutter_tools/test/general.shard/dart/pub_get_test.dart
+++ b/packages/flutter_tools/test/general.shard/dart/pub_get_test.dart
@@ -200,6 +200,9 @@
Stream<T> where(bool test(T event)) => MockStream<T>();
@override
+ Stream<S> map<S>(S Function(T) _) => MockStream<S>();
+
+ @override
StreamSubscription<T> listen(void onData(T event), { Function onError, void onDone(), bool cancelOnError }) {
return MockStreamSubscription<T>();
}