[flutter_tool] Re-try sending the first crash report (#37198)
diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart index f63553c..6999486 100644 --- a/packages/flutter_tools/lib/runner.dart +++ b/packages/flutter_tools/lib/runner.dart
@@ -56,17 +56,25 @@ ); String getVersion() => flutterVersion ?? FlutterVersion.instance.getVersionString(redactUnknownBranches: true); + Object firstError; + StackTrace firstStackTrace; return await runZoned<Future<int>>(() async { try { await runner.run(args); return await _exit(0); } catch (error, stackTrace) { + firstError = error; + firstStackTrace = stackTrace; return await _handleToolError( error, stackTrace, verbose, args, reportCrashes, getVersion); } }, onError: (Object error, StackTrace stackTrace) async { - await _handleToolError( - error, stackTrace, verbose, args, reportCrashes, getVersion); + // If sending a crash report throws an error into the zone, we don't want + // to re-try sending the crash report with *that* error. Rather, we want + // to send the original error that triggered the crash report. + final Object e = firstError ?? error; + final StackTrace s = firstStackTrace ?? stackTrace; + await _handleToolError(e, s, verbose, args, reportCrashes, getVersion); }); }, overrides: overrides); }
diff --git a/packages/flutter_tools/test/general.shard/runner/runner_test.dart b/packages/flutter_tools/test/general.shard/runner/runner_test.dart new file mode 100644 index 0000000..2889d0a --- /dev/null +++ b/packages/flutter_tools/test/general.shard/runner/runner_test.dart
@@ -0,0 +1,154 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// 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:file/memory.dart'; +import 'package:flutter_tools/runner.dart' as runner; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart' as io; +import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/reporting/reporting.dart'; +import 'package:flutter_tools/src/runner/flutter_command.dart'; +import 'package:platform/platform.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + group('runner', () { + setUp(() { + runner.crashFileSystem = MemoryFileSystem(); + // Instead of exiting with dart:io exit(), this causes an exception to + // be thrown, which we catch with the onError callback in the zone below. + io.setExitFunctionForTests((int _) { throw 'test exit';}); + Cache.disableLocking(); + }); + + tearDown(() { + runner.crashFileSystem = const LocalFileSystem(); + io.restoreExitFunction(); + Cache.enableLocking(); + }); + + testUsingContext('error handling', () async { + final Completer<void> completer = Completer<void>(); + // runner.run() asynchronously calls the exit function set above, so we + // catch it in a zone. + unawaited(runZoned<Future<void>>(() { + unawaited(runner.run( + <String>['test'], + <FlutterCommand>[ + CrashingFlutterCommand(), + ], + // This flutterVersion disables crash reporting. + flutterVersion: '[user-branch]/', + reportCrashes: true, + )); + return null; + }, + onError: (Object error) { + expect(error, 'test exit'); + completer.complete(); + })); + await completer.future; + + // This is the main check of this test. + // + // We are checking that, even though crash reporting failed with an + // exception on the first attempt, the second attempt tries to report the + // *original* crash, and not the crash from the first crash report + // attempt. + final CrashingUsage crashingUsage = flutterUsage; + expect(crashingUsage.sentException, 'runCommand'); + }, overrides: <Type, Generator>{ + Platform: () => FakePlatform(environment: <String, String>{ + 'FLUTTER_ANALYTICS_LOG_FILE': 'test', + 'FLUTTER_ROOT': '/', + }), + FileSystem: () => MemoryFileSystem(), + Usage: () => CrashingUsage(), + }); + }); +} + +class CrashingFlutterCommand extends FlutterCommand { + @override + String get description => null; + + @override + String get name => 'test'; + + @override + Future<FlutterCommandResult> runCommand() async { + throw 'runCommand'; + } +} + +class CrashingUsage implements Usage { + CrashingUsage() : _impl = Usage(versionOverride: '[user-branch]'); + + final Usage _impl; + + dynamic get sentException => _sentException; + dynamic _sentException; + + bool _firstAttempt = true; + + // Crash while crashing. + @override + void sendException(dynamic exception) { + if (_firstAttempt) { + _firstAttempt = false; + throw 'sendException'; + } + _sentException = exception; + } + + @override + bool get isFirstRun => _impl.isFirstRun; + + @override + bool get suppressAnalytics => _impl.suppressAnalytics; + + @override + set suppressAnalytics(bool value) { + _impl.suppressAnalytics = value; + } + + @override + bool get enabled => _impl.enabled; + + @override + set enabled(bool value) { + _impl.enabled = value; + } + + @override + String get clientId => _impl.clientId; + + @override + void sendCommand(String command, {Map<String, String> parameters}) => + _impl.sendCommand(command, parameters: parameters); + + @override + void sendEvent(String category, String parameter, { + Map<String, String> parameters + }) => _impl.sendEvent(category, parameter, parameters: parameters); + + @override + void sendTiming(String category, String variableName, Duration duration, { + String label + }) => _impl.sendTiming(category, variableName, duration, label: label); + + @override + Stream<Map<String, dynamic>> get onSend => _impl.onSend; + + @override + Future<void> ensureAnalyticsSent() => _impl.ensureAnalyticsSent(); + + @override + void printWelcome() => _impl.printWelcome(); +}