[ Tool ] Only process a single unhandled tool exception (#178335)
Without this change, if multiple asynchronous exceptions are thrown
while processing an exception, multiple exception analytics events can
be sent for a single process crash, skewing crashlytics data.
Fixes https://github.com/flutter/flutter/issues/178318
diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart
index 75c1090..4e89497 100644
--- a/packages/flutter_tools/lib/runner.dart
+++ b/packages/flutter_tools/lib/runner.dart
@@ -46,6 +46,9 @@
args.removeWhere((String option) => option == '-vv' || option == '-v' || option == '--verbose');
}
+ // Reset this on each run to ensure we don't leak state across tests.
+ _alreadyHandlingToolError = null;
+
final bool usingLocalEngine = args.any((a) => a.startsWith('--local-engine'));
return runInContext<int>(() async {
@@ -149,6 +152,13 @@
}, overrides: overrides);
}
+/// Track if we're actively processing an error so we don't try and process
+/// additional asynchronous exceptions while we're trying to shut down.
+///
+/// NOTE: This state is cleared at the beginning of [run] to ensure state
+/// doesn't leak when running tests.
+Future<int>? _alreadyHandlingToolError;
+
Future<int> _handleToolError(
Object error,
StackTrace? stackTrace,
@@ -160,6 +170,30 @@
required bool usingLocalEngine,
required FeatureFlags featureFlags,
}) async {
+ return _alreadyHandlingToolError ??= _handleToolErrorImpl(
+ error,
+ stackTrace,
+ verbose,
+ args,
+ reportCrashes,
+ getFlutterVersion,
+ shutdownHooks,
+ usingLocalEngine: usingLocalEngine,
+ featureFlags: featureFlags,
+ );
+}
+
+Future<int> _handleToolErrorImpl(
+ Object error,
+ StackTrace? stackTrace,
+ bool verbose,
+ List<String> args,
+ bool reportCrashes,
+ String Function() getFlutterVersion,
+ ShutdownHooks shutdownHooks, {
+ required bool usingLocalEngine,
+ required FeatureFlags featureFlags,
+}) async {
if (error is UsageException) {
globals.printError('${error.message}\n');
globals.printError(
diff --git a/packages/flutter_tools/test/general.shard/runner/runner_test.dart b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
index dfe6f03..7e4a7e1 100644
--- a/packages/flutter_tools/test/general.shard/runner/runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
@@ -23,6 +23,7 @@
import 'package:flutter_tools/src/reporting/crash_reporting.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'package:test/fake.dart';
+import 'package:unified_analytics/testing.dart';
import 'package:unified_analytics/unified_analytics.dart';
import '../../src/common.dart';
@@ -290,6 +291,67 @@
);
testUsingContext(
+ "doesn't send multiple events for additional asynchronous exceptions "
+ 'thrown during shutdown',
+ () async {
+ // Regression test for https://github.com/flutter/flutter/issues/178318.
+ final command = MultipleExceptionCrashingFlutterCommand();
+ var exceptionCount = 0;
+ unawaited(
+ runZonedGuarded<Future<void>?>(
+ () {
+ unawaited(
+ runner.run(
+ <String>['crash'],
+ () => <FlutterCommand>[command],
+ // This flutterVersion disables crash reporting.
+ flutterVersion: '[user-branch]/',
+ reportCrashes: true,
+ shutdownHooks: ShutdownHooks(),
+ ),
+ );
+ return null;
+ },
+ (Object error, StackTrace stack) {
+ // Keep track of the number of exceptions thrown to ensure that
+ // the count matches the number of exceptions we expect.
+ exceptionCount++;
+ },
+ ),
+ );
+ await command.doneThrowing;
+
+ // This is the main check of this test.
+ //
+ // We are checking that, even though multiple asynchronous errors were
+ // thrown, only a single crash report is sent. This ensures that a
+ // single process crash can't result in multiple crash events.
+
+ // This test only makes sense if we've thrown more than one exception.
+ expect(exceptionCount, greaterThan(1));
+ expect(exceptionCount, command.exceptionCount);
+
+ // Ensure only a single exception analytics event was sent.
+ final List<Event> exceptionEvents = fakeAnalytics.sentEvents
+ .where((e) => e.eventName == DashEvent.exception)
+ .toList();
+ expect(exceptionEvents, hasLength(1));
+ },
+ overrides: <Type, Generator>{
+ Analytics: () => fakeAnalytics,
+ Platform: () => FakePlatform(
+ environment: <String, String>{'FLUTTER_ANALYTICS_LOG_FILE': 'test', 'FLUTTER_ROOT': '/'},
+ ),
+ FileSystem: () => fileSystem,
+ ProcessManager: () => FakeProcessManager.any(),
+ CrashReporter: () => WaitingCrashReporter(Future<void>.value()),
+ Artifacts: () => Artifacts.test(),
+ HttpClientFactory: () =>
+ () => FakeHttpClient.any(),
+ },
+ );
+
+ testUsingContext(
'create local report',
() async {
// Since crash reporting calls the doctor, which checks for the devtools
@@ -819,6 +881,34 @@
}
}
+class MultipleExceptionCrashingFlutterCommand extends FlutterCommand {
+ final _completer = Completer<void>();
+
+ @override
+ String get description => '';
+
+ @override
+ String get name => 'crash';
+
+ Future<void> get doneThrowing => _completer.future;
+
+ var exceptionCount = 0;
+
+ @override
+ Future<FlutterCommandResult> runCommand() async {
+ Timer.periodic(const Duration(milliseconds: 10), (timer) {
+ exceptionCount++;
+ if (exceptionCount < 5) {
+ throw Exception('ERROR: $exceptionCount');
+ }
+ timer.cancel();
+ _completer.complete();
+ });
+
+ return FlutterCommandResult.success();
+ }
+}
+
class _GitNotFoundFlutterCommand extends FlutterCommand {
@override
String get description => '';