Unverified Commit 5501a1c1 authored by sjindel-google's avatar sjindel-google Committed by GitHub

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").
parent fa65ddf5
......@@ -129,6 +129,11 @@ Future<Process> runCommand(
/// 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 @@ Future<int> runCommandAndStreamOutput(
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 @@ Future<int> runCommandAndStreamOutput(
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 @@ Future<int> runCommandAndStreamOutput(
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>(),
]);
await waitGroup<void>(<Future<void>>[
stdoutSubscription.cancel(),
stderrSubscription.cancel(),
]);
// 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(),
]);
// Complete the future if the we did not detach the process yet.
if (!result.isCompleted) {
result.complete(process.exitCode);
}
}
return await process.exitCode;
unawaited(readOutput());
return result.future;
}
/// Runs the [command] interactively, connecting the stdin/stdout/stderr
......
......@@ -48,7 +48,7 @@ class IOSDeploy {
'--bundle',
bundlePath,
'--no-wifi',
'--justlaunch',
'--noninteractive',
];
if (launchArguments.isNotEmpty) {
launchCommand.add('--args');
......@@ -66,11 +66,14 @@ class IOSDeploy {
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.*')
);
}
......
......@@ -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 @@ void main() {
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 {}
......@@ -199,6 +199,9 @@ class MockStream<T> implements Stream<T> {
@override
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>();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment