Commit 8738eb97 authored by Yegor's avatar Yegor Committed by GitHub

add crash reporting without enabling it (#8518)

* add crash reporting without enabling it

* do not drop futures on the floor

* return exitCode from executable run

* debug travis

* remove unnecessary todo

* rename local fs getter
parent 46c4c5f5
......@@ -5,7 +5,9 @@
import 'dart:async';
import 'package:args/command_runner.dart';
import 'package:flutter_tools/src/version.dart';
import 'package:intl/intl_standalone.dart' as intl;
import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:stack_trace/stack_trace.dart';
......@@ -42,6 +44,7 @@ import 'src/commands/test.dart';
import 'src/commands/trace.dart';
import 'src/commands/update_packages.dart';
import 'src/commands/upgrade.dart';
import 'src/crash_reporting.dart';
import 'src/devfs.dart';
import 'src/device.dart';
import 'src/doctor.dart';
......@@ -49,10 +52,10 @@ import 'src/globals.dart';
import 'src/ios/mac.dart';
import 'src/ios/simulators.dart';
import 'src/run_hot.dart';
import 'src/runner/flutter_command.dart';
import 'src/runner/flutter_command_runner.dart';
import 'src/usage.dart';
/// Main entry point for commands.
///
/// This function is intended to be used from the `flutter` command line tool.
......@@ -62,40 +65,53 @@ Future<Null> main(List<String> args) async {
(args.isNotEmpty && args.first == 'help') || (args.length == 1 && verbose);
bool verboseHelp = help && verbose;
await run(args, <FlutterCommand>[
new AnalyzeCommand(verboseHelp: verboseHelp),
new BuildCommand(verboseHelp: verboseHelp),
new ChannelCommand(),
new ConfigCommand(),
new CreateCommand(),
new DaemonCommand(hidden: !verboseHelp),
new DevicesCommand(),
new DoctorCommand(),
new DriveCommand(),
new FormatCommand(),
new InstallCommand(),
new LogsCommand(),
new PackagesCommand(),
new PrecacheCommand(),
new RunCommand(verboseHelp: verboseHelp),
new ScreenshotCommand(),
new StopCommand(),
new TestCommand(),
new TraceCommand(),
new UpdatePackagesCommand(hidden: !verboseHelp),
new UpgradeCommand(),
], verbose: verbose, verboseHelp: verboseHelp);
}
Future<int> run(List<String> args, List<FlutterCommand> subCommands, {
bool verbose: false,
bool verboseHelp: false,
bool reportCrashes,
String flutterVersion,
}) async {
reportCrashes ??= !isRunningOnBot;
if (verboseHelp) {
// Remove the verbose option; for help, users don't need to see verbose logs.
args = new List<String>.from(args);
args.removeWhere((String option) => option == '-v' || option == '--verbose');
}
FlutterCommandRunner runner = new FlutterCommandRunner(verboseHelp: verboseHelp)
..addCommand(new AnalyzeCommand(verboseHelp: verboseHelp))
..addCommand(new BuildCommand(verboseHelp: verboseHelp))
..addCommand(new ChannelCommand())
..addCommand(new ConfigCommand())
..addCommand(new CreateCommand())
..addCommand(new DaemonCommand(hidden: !verboseHelp))
..addCommand(new DevicesCommand())
..addCommand(new DoctorCommand())
..addCommand(new DriveCommand())
..addCommand(new FormatCommand())
..addCommand(new InstallCommand())
..addCommand(new LogsCommand())
..addCommand(new PackagesCommand())
..addCommand(new PrecacheCommand())
..addCommand(new RunCommand(verboseHelp: verboseHelp))
..addCommand(new ScreenshotCommand())
..addCommand(new StopCommand())
..addCommand(new TestCommand())
..addCommand(new TraceCommand())
..addCommand(new UpdatePackagesCommand(hidden: !verboseHelp))
..addCommand(new UpgradeCommand());
FlutterCommandRunner runner = new FlutterCommandRunner(verboseHelp: verboseHelp);
subCommands.forEach(runner.addCommand);
// Construct a context.
AppContext _executableContext = new AppContext();
// Make the context current.
await _executableContext.runInZone(() async {
return await _executableContext.runInZone(() async {
// Initialize the context with some defaults.
// NOTE: Similar lists also exist in `bin/fuchsia_builder.dart` and
// `test/src/context.dart`. If you update this list of defaults, look
......@@ -124,70 +140,100 @@ Future<Null> main(List<String> args) async {
// Initialize the system locale.
await intl.findSystemLocale();
return Chain.capture<Future<Null>>(() async {
Completer<int> runCompleter = new Completer<int>();
Chain.capture<Future<Null>>(() async {
await runner.run(args);
await _exit(0);
runCompleter.complete(0);
}, onError: (dynamic error, Chain chain) {
if (error is UsageException) {
stderr.writeln(error.message);
stderr.writeln();
flutterVersion ??= FlutterVersion.getVersionString();
_handleToolError(error, chain, verbose, args, reportCrashes, flutterVersion)
.then(runCompleter.complete, onError: runCompleter.completeError);
});
return runCompleter.future;
});
}
Future<int> _handleToolError(
dynamic error,
Chain chain,
bool verbose,
List<String> args,
bool reportCrashes,
String flutterVersion,
) async {
if (error is UsageException) {
stderr.writeln(error.message);
stderr.writeln();
stderr.writeln(
"Run 'flutter -h' (or 'flutter <command> -h') for available "
"flutter commands and options."
);
// Argument error exit code.
return _exit(64);
} else if (error is ToolExit) {
if (error.message != null)
stderr.writeln(error.message);
if (verbose) {
stderr.writeln();
stderr.writeln(chain.terse.toString());
stderr.writeln();
}
return _exit(error.exitCode ?? 1);
} else if (error is ProcessExit) {
// We've caught an exit code.
return _exit(error.exitCode);
} else {
// We've crashed; emit a log report.
stderr.writeln();
flutterUsage.sendException(error, chain);
if (!reportCrashes) {
// Print the stack trace on the bots - don't write a crash report.
stderr.writeln('$error');
stderr.writeln(chain.terse.toString());
return _exit(1);
} else {
if (error is String)
stderr.writeln('Oops; flutter has exited unexpectedly: "$error".');
else
stderr.writeln('Oops; flutter has exited unexpectedly.');
await CrashReportSender.instance.sendReport(
error: error,
stackTrace: chain,
flutterVersion: flutterVersion,
);
try {
File file = await _createLocalCrashReport(args, error, chain);
stderr.writeln(
"Run 'flutter -h' (or 'flutter <command> -h') for available "
"flutter commands and options."
'Crash report written to ${file.path};\n'
'please let us know at https://github.com/flutter/flutter/issues.',
);
// Argument error exit code.
_exit(64);
} else if (error is ToolExit) {
if (error.message != null)
stderr.writeln(error.message);
if (verbose) {
stderr.writeln();
stderr.writeln(chain.terse.toString());
stderr.writeln();
}
_exit(error.exitCode ?? 1);
} else if (error is ProcessExit) {
// We've caught an exit code.
_exit(error.exitCode);
} else {
// We've crashed; emit a log report.
stderr.writeln();
flutterUsage.sendException(error, chain);
if (isRunningOnBot) {
// Print the stack trace on the bots - don't write a crash report.
stderr.writeln('$error');
stderr.writeln(chain.terse.toString());
_exit(1);
} else {
if (error is String)
stderr.writeln('Oops; flutter has exited unexpectedly: "$error".');
else
stderr.writeln('Oops; flutter has exited unexpectedly.');
_createCrashReport(args, error, chain).then<Null>((File file) {
stderr.writeln(
'Crash report written to ${file.path};\n'
'please let us know at https://github.com/flutter/flutter/issues.',
);
_exit(1);
}).catchError((dynamic error) {
stderr.writeln(
'Unable to generate crash report due to secondary error: $error\n'
'please let us know at https://github.com/flutter/flutter/issues.',
);
_exit(1);
});
}
return _exit(1);
} catch (error) {
stderr.writeln(
'Unable to generate crash report due to secondary error: $error\n'
'please let us know at https://github.com/flutter/flutter/issues.',
);
return _exit(1);
}
});
});
}
}
}
Future<File> _createCrashReport(List<String> args, dynamic error, Chain chain) async {
FileSystem fs = const LocalFileSystem();
File crashFile = getUniqueFile(fs.currentDirectory, 'flutter', 'log');
/// File system used by the crash reporting logic.
///
/// We do not want to use the file system stored in the context because it may
/// be recording. Additionally, in the case of a crash we do not trust the
/// integrity of the [AppContext].
@visibleForTesting
FileSystem crashFileSystem = new LocalFileSystem();
/// Saves the crash report to a local file.
Future<File> _createLocalCrashReport(List<String> args, dynamic error, Chain chain) async {
File crashFile = getUniqueFile(crashFileSystem.currentDirectory, 'flutter', 'log');
StringBuffer buffer = new StringBuffer();
......@@ -207,7 +253,7 @@ Future<File> _createCrashReport(List<String> args, dynamic error, Chain chain) a
await crashFile.writeAsString(buffer.toString());
} on FileSystemException catch (_) {
// Fallback to the system temporary directory.
crashFile = getUniqueFile(fs.systemTempDirectory, 'flutter', 'log');
crashFile = getUniqueFile(crashFileSystem.systemTempDirectory, 'flutter', 'log');
try {
await crashFile.writeAsString(buffer.toString());
} on FileSystemException catch (e) {
......@@ -234,7 +280,7 @@ Future<String> _doctorText() async {
}
}
Future<Null> _exit(int code) async {
Future<int> _exit(int code) async {
if (flutterUsage.isFirstRun)
flutterUsage.printUsage();
......@@ -259,4 +305,5 @@ Future<Null> _exit(int code) async {
});
await completer.future;
return code;
}
// Copyright 2017 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 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:stack_trace/stack_trace.dart';
import 'base/io.dart';
import 'globals.dart';
import 'usage.dart';
/// Tells crash backend that the error is from the Flutter CLI.
String _kProductId = 'Flutter_Tools';
/// Tells crash backend that this is a Dart error as opposed to, say, Java.
String _kDartTypeId = 'DartError';
/// Crash backend host.
String _kCrashServerHost = 'clients2.google.com';
/// Path to the crash servlet.
// TODO(yjbanov): switch to non-staging when production is ready.
String _kCrashEndpointPath = '/cr/staging_report';
/// The field corresponding to the multipart/form-data file attachment where
/// crash backend expects to find the Dart stack trace.
String _kStackTraceFileField = 'DartError';
/// The name of the file attached as [_kStackTraceFileField].
///
/// The precise value is not important. It is ignored by the crash back end, but
/// it must be supplied in the request.
String _kStackTraceFilename = 'stacktrace_file';
bool _testing = false;
/// Sends crash reports to Google.
class CrashReportSender {
static final Uri _defaultBaseUri = new Uri(
scheme: 'https',
host: _kCrashServerHost,
port: 443,
path: _kCrashEndpointPath,
);
static Uri _baseUri = _defaultBaseUri;
static CrashReportSender _instance;
CrashReportSender._(this._client);
static CrashReportSender get instance => _instance ?? new CrashReportSender._(new http.Client());
/// Overrides the default [http.Client] with [client] for testing purposes.
@visibleForTesting
static void initializeWith(http.Client client) {
_instance = new CrashReportSender._(client);
}
final http.Client _client;
final Usage _usage = Usage.instance;
/// Sends one crash report.
///
/// The report is populated from data in [error] and [stackTrace].
Future<Null> sendReport({
@required dynamic error,
@required dynamic stackTrace,
@required String flutterVersion,
}) async {
try {
// TODO(yjbanov): we only actually send crash reports in tests. When we
// iron out the process, we will remove this guard and report crashes
// when !flutterUsage.suppressAnalytics.
if (!_testing)
return null;
if (_usage.suppressAnalytics)
return null;
printStatus('Sending crash report to Google.');
Uri uri = _baseUri.replace(
queryParameters: <String, String>{
'product': _kProductId,
'version': flutterVersion,
},
);
_MultipartRequest req = new _MultipartRequest('POST', uri);
req.fields['product'] = _kProductId;
req.fields['version'] = flutterVersion;
req.fields['type'] = _kDartTypeId;
req.fields['error_runtime_type'] = '${error.runtimeType}';
Chain chain = stackTrace is StackTrace
? new Chain.forTrace(stackTrace)
: new Chain.parse(stackTrace.toString());
req.files.add(new http.MultipartFile.fromString(
_kStackTraceFileField,
'${chain.terse}',
filename: _kStackTraceFilename,
));
http.StreamedResponse resp = await _client.send(req);
if (resp.statusCode == 200) {
String reportId = await new http.ByteStream(resp.stream)
.bytesToString();
printStatus('Crash report sent (report ID: $reportId)');
} else {
printError('Failed to send crash report. Server responded with HTTP status code ${resp.statusCode}');
}
} catch (sendError, sendStackTrace) {
if (sendError is SocketException) {
printError('Failed to send crash report due to a network error: $sendError');
} else {
// If the sender itself crashes, just print. We did our best.
printError('Crash report sender itself crashed: $sendError\n$sendStackTrace');
}
}
}
}
/// Overrides [CrashReportSender._baseUri] for testing and enables testing
/// mode.
@visibleForTesting
void overrideBaseCrashUrlForTesting(Uri uri) {
CrashReportSender._baseUri = uri;
_testing = true;
}
/// Resets [CrashReportSender._baseUri] back to the original value and disables
/// test mode.
@visibleForTesting
void resetBaseCrashUrlForTesting() {
CrashReportSender._baseUri = CrashReportSender._defaultBaseUri;
_testing = false;
}
// Below is a patched version of the MultipartRequest class from package:http
// made to conform to Flutter's style guide and to comply with the crash
// reporting backend. Specifically, the backend does not correctly handle quoted
// boundary values. The implementation below:
// - reduces boundary character set to those that do not need quotes
// - compensates for the smaller set by generating a longer boundary value
final RegExp _newlineRegExp = new RegExp(r"\r\n|\r|\n");
/// A `multipart/form-data` request. Such a request has both string [fields],
/// which function as normal form fields, and (potentially streamed) binary
/// [files].
///
/// This request automatically sets the Content-Type header to
/// `multipart/form-data`. This value will override any value set by the user.
///
/// var uri = Uri.parse("http://pub.dartlang.org/packages/create");
/// var request = new http.MultipartRequest("POST", url);
/// request.fields['user'] = 'nweiz@google.com';
/// request.files.add(new http.MultipartFile.fromFile(
/// 'package',
/// new File('build/package.tar.gz'),
/// contentType: new MediaType('application', 'x-tar'));
/// request.send().then((response) {
/// if (response.statusCode == 200) print("Uploaded!");
/// });
class _MultipartRequest extends http.BaseRequest {
/// The total length of the multipart boundaries used when building the
/// request body. According to http://tools.ietf.org/html/rfc1341.html, this
/// can't be longer than 70.
static const int _BOUNDARY_LENGTH = 70;
static final Random _random = new Random();
/// The form fields to send for this request.
final Map<String, String> fields;
/// The private version of [files].
final List<http.MultipartFile> _files;
/// Creates a new [MultipartRequest].
_MultipartRequest(String method, Uri url)
: fields = <String, String>{},
_files = <http.MultipartFile>[],
super(method, url);
/// The list of files to upload for this request.
List<http.MultipartFile> get files => _files;
/// The total length of the request body, in bytes. This is calculated from
/// [fields] and [files] and cannot be set manually.
@override
int get contentLength {
int length = 0;
fields.forEach((String name, String value) {
length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
UTF8.encode(_headerForField(name, value)).length +
UTF8.encode(value).length + "\r\n".length;
});
for (http.MultipartFile file in _files) {
length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
UTF8.encode(_headerForFile(file)).length +
file.length + "\r\n".length;
}
return length + "--".length + _BOUNDARY_LENGTH + "--\r\n".length;
}
@override
set contentLength(int value) {
throw new UnsupportedError("Cannot set the contentLength property of "
"multipart requests.");
}
/// Freezes all mutable fields and returns a single-subscription [ByteStream]
/// that will emit the request body.
@override
http.ByteStream finalize() {
String boundary = _boundaryString();
headers['content-type'] = 'multipart/form-data; boundary=$boundary';
super.finalize();
StreamController<List<int>> controller = new StreamController<List<int>>(sync: true);
void writeAscii(String string) {
controller.add(UTF8.encode(string));
}
void writeUtf8(String string) {
controller.add(UTF8.encode(string));
}
void writeLine() {
controller.add(<int>[13, 10]); // \r\n
}
fields.forEach((String name, String value) {
writeAscii('--$boundary\r\n');
writeAscii(_headerForField(name, value));
writeUtf8(value);
writeLine();
});
Future.forEach(_files, (http.MultipartFile file) {
writeAscii('--$boundary\r\n');
writeAscii(_headerForFile(file));
return writeStreamToSink(file.finalize(), controller)
.then((dynamic _) => writeLine());
}).then<Null>((dynamic _) {
writeAscii('--$boundary--\r\n');
controller.close();
});
return new http.ByteStream(controller.stream);
}
/// Valid boundary character codes that do not need to be quoted. From
/// http://tools.ietf.org/html/rfc2046#section-5.1.1.
static const List<int> _BOUNDARY_CHARACTERS = const <int>[
// Digits
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
// Capital letters
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
// Small letters
97,
98,
99,
100,
101,
102,
103,
104,
105,
106,
107,
108,
109,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
];
/// Returns the header string for a field. The return value is guaranteed to
/// contain only ASCII characters.
String _headerForField(String name, String value) {
String header =
'content-disposition: form-data; name="${_browserEncode(name)}"';
if (!isPlainAscii(value)) {
header = '$header\r\n'
'content-type: text/plain; charset=utf-8\r\n'
'content-transfer-encoding: binary';
}
return '$header\r\n\r\n';
}
/// Returns the header string for a file. The return value is guaranteed to
/// contain only ASCII characters.
String _headerForFile(http.MultipartFile file) {
String header = 'content-type: ${file.contentType}\r\n'
'content-disposition: form-data; name="${_browserEncode(file.field)}"';
if (file.filename != null) {
header = '$header; filename="${_browserEncode(file.filename)}"';
}
return '$header\r\n\r\n';
}
/// Encode [value] in the same way browsers do.
String _browserEncode(String value) {
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
return value.replaceAll(_newlineRegExp, "%0D%0A").replaceAll('"', "%22");
}
/// Returns a randomly-generated multipart boundary string
String _boundaryString() {
String prefix = "dart-";
List<int> list = new List<int>.generate(_BOUNDARY_LENGTH - prefix.length,
(int index) =>
_BOUNDARY_CHARACTERS[_random.nextInt(_BOUNDARY_CHARACTERS.length)],
growable: false);
return "$prefix${new String.fromCharCodes(list)}";
}
}
/// Pipes all data and errors from [stream] into [sink]. Completes [Future] once
/// [stream] is done. Unlike [store], [sink] remains open after [stream] is
/// done.
Future<Null> writeStreamToSink<O, I extends O>(Stream<I> stream, EventSink<O> sink) {
Completer<Null> completer = new Completer<Null>();
stream.listen(sink.add,
onError: sink.addError,
onDone: () => completer.complete());
return completer.future;
}
/// A regular expression that matches strings that are composed entirely of
/// ASCII-compatible characters.
final RegExp _kAsciiOnly = new RegExp(r"^[\x00-\x7F]+$");
/// Returns whether [string] is composed entirely of ASCII-compatible
/// characters.
bool isPlainAscii(String string) => _kAsciiOnly.hasMatch(string);
......@@ -23,6 +23,7 @@ import 'bug_report_test.dart' as bug_report_test;
import 'channel_test.dart' as channel_test;
import 'config_test.dart' as config_test;
import 'context_test.dart' as context_test;
import 'crash_reporting_test.dart' as crash_reporting_test;
import 'create_test.dart' as create_test;
import 'daemon_test.dart' as daemon_test;
import 'dart_dependencies_test.dart' as dart_dependencies_test;
......@@ -64,6 +65,7 @@ void main() {
channel_test.main();
config_test.main();
context_test.main();
crash_reporting_test.main();
create_test.main();
daemon_test.main();
dart_dependencies_test.main();
......
// Copyright 2017 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/file.dart';
import 'package:file/local.dart';
import 'package:file/memory.dart';
import 'package:http/http.dart';
import 'package:http/testing.dart';
import 'package:test/test.dart';
import 'package:flutter_tools/executable.dart' as tools;
import 'package:flutter_tools/src/base/context.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/os.dart' as os;
import 'package:flutter_tools/src/crash_reporting.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'src/context.dart';
void main() {
group('crash reporting', () {
int testPort;
setUp(() async {
tools.crashFileSystem = new MemoryFileSystem();
setExitFunctionForTests((_) { });
testPort = await os.findAvailablePort();
overrideBaseCrashUrlForTesting(Uri.parse('http://localhost:$testPort/test-path'));
});
tearDown(() {
tools.crashFileSystem = new LocalFileSystem();
restoreExitFunction();
resetBaseCrashUrlForTesting();
});
testUsingContext('should send crash reports', () async {
String method;
Uri uri;
CrashReportSender.initializeWith(new MockClient((Request request) async {
method = request.method;
uri = request.url;
return new Response(
'test-report-id',
200
);
}));
int exitCode = await tools.run(
<String>['crash'],
<FlutterCommand>[new _CrashCommand()],
reportCrashes: true,
flutterVersion: 'test-version',
);
expect(exitCode, 1);
// Verify that we sent the crash report.
expect(method, 'POST');
expect(uri, new Uri(
scheme: 'http',
host: 'localhost',
port: testPort,
path: '/test-path',
queryParameters: <String, String>{
'product': 'Flutter_Tools',
'version' : 'test-version',
},
));
BufferLogger logger = context[Logger];
expect(logger.statusText, 'Sending crash report to Google.\n'
'Crash report sent (report ID: test-report-id)\n');
// Verify that we've written the crash report to disk.
List<String> writtenFiles =
(await tools.crashFileSystem.directory('/').list(recursive: true).toList())
.map((FileSystemEntity e) => e.path).toList();
expect(writtenFiles, hasLength(1));
expect(writtenFiles, contains('flutter_01.log'));
});
});
}
/// Throws a random error to simulate a CLI crash.
class _CrashCommand extends FlutterCommand {
@override
String get description => 'Simulates a crash';
@override
String get name => 'crash';
@override
Future<Null> runCommand() async {
void fn1() {
throw new StateError('Test bad state error');
}
void fn2() {
fn1();
}
void fn3() {
fn2();
}
fn3();
}
}
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