Skip to content

Commit fac41dd

Browse files
[flutter_tools] catch SocketException writing to ios-deploy stdin (#139784)
Fixes flutter/flutter#139709 This adds a static helper method `ProcessUtils.writelnToStdinGuarded()`, which will asynchronously write to a sub-process's STDIN `IOSink` and catch errors. In talking with Brian, it sounds like this is the best and most reliable way to catch `SocketException`s during these writes *to sub-process file descriptors* specifically (with a "real" hard drive file, the future returned by `.flush()` should complete with the write error). Also, as I note in the dartdoc to `writelnToStdinGuarded()`, the behavior seems to be different between macOS and linux. Moving forward, in any place where we want to catch exceptions writing to STDIN, we will want to use this new helper.
1 parent 815dc96 commit fac41dd

File tree

4 files changed

+139
-20
lines changed

4 files changed

+139
-20
lines changed

packages/flutter_tools/lib/src/base/process.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,52 @@ abstract class ProcessUtils {
233233
List<String> cli, {
234234
Map<String, String>? environment,
235235
});
236+
237+
/// Write [line] to [stdin] and catch any errors with [onError].
238+
///
239+
/// Specifically with [Process] file descriptors, an exception that is
240+
/// thrown as part of a write can be most reliably caught with a
241+
/// [ZoneSpecification] error handler.
242+
///
243+
/// On some platforms, the following code appears to work:
244+
///
245+
/// ```dart
246+
/// stdin.writeln(line);
247+
/// try {
248+
/// await stdin.flush(line);
249+
/// } catch (err) {
250+
/// // handle error
251+
/// }
252+
/// ```
253+
///
254+
/// However it did not catch a [SocketException] on Linux.
255+
static Future<void> writelnToStdinGuarded({
256+
required IOSink stdin,
257+
required String line,
258+
required void Function(Object, StackTrace) onError,
259+
}) async {
260+
final Completer<void> completer = Completer<void>();
261+
262+
void writeFlushAndComplete() {
263+
stdin.writeln(line);
264+
stdin.flush().whenComplete(() {
265+
if (!completer.isCompleted) {
266+
completer.complete();
267+
}
268+
});
269+
}
270+
271+
runZonedGuarded(
272+
writeFlushAndComplete,
273+
(Object error, StackTrace stackTrace) {
274+
onError(error, stackTrace);
275+
if (!completer.isCompleted) {
276+
completer.complete();
277+
}
278+
},
279+
);
280+
return completer.future;
281+
}
236282
}
237283

238284
class _DefaultProcessUtils implements ProcessUtils {

packages/flutter_tools/lib/src/ios/ios_deploy.dart

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -598,29 +598,66 @@ class IOSDeployDebugger {
598598
if (!debuggerAttached) {
599599
return;
600600
}
601-
try {
602-
// Stop the app, which will prompt the backtrace to be printed for all threads in the stdoutSubscription handler.
603-
_iosDeployProcess?.stdin.writeln(_signalStop);
604-
} on SocketException catch (error) {
605-
// Best effort, try to detach, but maybe the app already exited or already detached.
606-
_logger.printTrace('Could not stop app from debugger: $error');
607-
}
601+
// Stop the app, which will prompt the backtrace to be printed for all
602+
// threads in the stdoutSubscription handler.
603+
await stdinWriteln(
604+
_signalStop,
605+
onError: (Object error, _) {
606+
_logger.printTrace('Could not stop the app: $error');
607+
},
608+
);
609+
608610
// Wait for logging to finish on process exit.
609611
return logLines.drain();
610612
}
611613

612-
void detach() {
613-
if (!debuggerAttached) {
614+
Future<void>? _stdinWriteFuture;
615+
616+
/// Queue write of [line] to STDIN of [_iosDeployProcess].
617+
///
618+
/// No-op if [_iosDeployProcess] is null.
619+
///
620+
/// This write will not happen until the flush of any previous writes have
621+
/// completed, because calling [IOSink.flush()] before a previous flush has
622+
/// completed will throw a [StateError].
623+
///
624+
/// This method needs to keep track of the [_stdinWriteFuture] from previous
625+
/// calls because the future returned by [detach] is not always await-ed.
626+
Future<void> stdinWriteln(String line, {required void Function(Object, StackTrace) onError}) async {
627+
final Process? process = _iosDeployProcess;
628+
if (process == null) {
614629
return;
615630
}
616631

617-
try {
618-
// Detach lldb from the app process.
619-
_iosDeployProcess?.stdin.writeln('process detach');
620-
} on SocketException catch (error) {
621-
// Best effort, try to detach, but maybe the app already exited or already detached.
622-
_logger.printTrace('Could not detach from debugger: $error');
632+
Future<void> writeln() {
633+
return ProcessUtils.writelnToStdinGuarded(
634+
stdin: process.stdin,
635+
line: line,
636+
onError: onError,
637+
);
638+
}
639+
640+
if (_stdinWriteFuture != null) {
641+
_stdinWriteFuture = _stdinWriteFuture!.then<void>((_) => writeln());
642+
} else {
643+
_stdinWriteFuture = writeln();
623644
}
645+
646+
return _stdinWriteFuture;
647+
}
648+
649+
Future<void> detach() async {
650+
if (!debuggerAttached) {
651+
return;
652+
}
653+
654+
return stdinWriteln(
655+
'process detach',
656+
onError: (Object error, _) {
657+
// Best effort, try to detach, but maybe the app already exited or already detached.
658+
_logger.printTrace('Could not detach from debugger: $error');
659+
}
660+
);
624661
}
625662
}
626663

packages/flutter_tools/test/general.shard/ios/ios_deploy_test.dart

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import 'package:file/memory.dart';
99
import 'package:file_testing/file_testing.dart';
1010
import 'package:flutter_tools/src/artifacts.dart';
1111
import 'package:flutter_tools/src/base/file_system.dart';
12+
import 'package:flutter_tools/src/base/io.dart';
1213
import 'package:flutter_tools/src/base/logger.dart';
1314
import 'package:flutter_tools/src/base/platform.dart';
1415
import 'package:flutter_tools/src/base/process.dart';
1516
import 'package:flutter_tools/src/cache.dart';
1617
import 'package:flutter_tools/src/device.dart';
1718
import 'package:flutter_tools/src/ios/ios_deploy.dart';
19+
import 'package:test/fake.dart';
1820

1921
import '../../src/common.dart';
2022
import '../../src/fake_process_manager.dart';
@@ -386,7 +388,26 @@ void main () {
386388
);
387389
expect(stdin.stream.transform<String>(const Utf8Decoder()), emits('process detach'));
388390
await iosDeployDebugger.launchAndAttach();
389-
iosDeployDebugger.detach();
391+
await iosDeployDebugger.detach();
392+
});
393+
394+
testWithoutContext('detach handles broken pipe', () async {
395+
final StreamSink<List<int>> stdinSink = _ClosedStdinController();
396+
final FakeProcessManager processManager = FakeProcessManager.list(<FakeCommand>[
397+
FakeCommand(
398+
command: const <String>['ios-deploy'],
399+
stdout: '(lldb) run\nsuccess',
400+
stdin: IOSink(stdinSink),
401+
),
402+
]);
403+
final BufferLogger logger = BufferLogger.test();
404+
final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test(
405+
processManager: processManager,
406+
logger: logger,
407+
);
408+
await iosDeployDebugger.launchAndAttach();
409+
await iosDeployDebugger.detach();
410+
expect(logger.traceText, contains('Could not detach from debugger'));
390411
});
391412

392413
testWithoutContext('stop with backtrace', () async {
@@ -399,18 +420,28 @@ void main () {
399420
],
400421
stdout:
401422
'(lldb) run\nsuccess\nLog on attach\n(lldb) Process 6156 stopped\n* thread #1, stop reason = Assertion failed:\n(lldb) Process 6156 detached',
402-
stdin: IOSink(stdin.sink),
423+
stdin: IOSink(stdin),
403424
),
404425
]);
405426
final IOSDeployDebugger iosDeployDebugger = IOSDeployDebugger.test(
406427
processManager: processManager,
407428
);
408429
await iosDeployDebugger.launchAndAttach();
409-
await iosDeployDebugger.stopAndDumpBacktrace();
410-
expect(await stdinStream.take(3).toList(), <String>[
430+
List<String>? stdinLines;
431+
432+
// These two futures will deadlock if await-ed sequentially
433+
await Future.wait(<Future<void>>[
434+
iosDeployDebugger.stopAndDumpBacktrace(),
435+
stdinStream.take(5).toList().then<void>(
436+
(List<String> lines) => stdinLines = lines,
437+
),
438+
]);
439+
expect(stdinLines, const <String>[
411440
'thread backtrace all',
412441
'\n',
413442
'process detach',
443+
'\n',
444+
'process signal SIGSTOP',
414445
]);
415446
});
416447

@@ -616,6 +647,11 @@ process continue
616647
});
617648
}
618649

650+
class _ClosedStdinController extends Fake implements StreamSink<List<int>> {
651+
@override
652+
Future<Object?> addStream(Stream<List<int>> stream) async => throw const SocketException('Bad pipe');
653+
}
654+
619655
IOSDeploy setUpIOSDeploy(ProcessManager processManager, {
620656
Artifacts? artifacts,
621657
}) {

packages/flutter_tools/test/general.shard/ios/ios_device_logger_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,7 @@ class FakeIOSDeployDebugger extends Fake implements IOSDeployDebugger {
10011001
Stream<String> logLines = const Stream<String>.empty();
10021002

10031003
@override
1004-
void detach() {
1004+
Future<void> detach() async {
10051005
detached = true;
10061006
}
10071007
}

0 commit comments

Comments
 (0)