diff --git a/packages/flutter_tools/lib/src/base/error_handling_file_system.dart b/packages/flutter_tools/lib/src/base/error_handling_file_system.dart new file mode 100644 index 0000000000..325cbf63d2 --- /dev/null +++ b/packages/flutter_tools/lib/src/base/error_handling_file_system.dart @@ -0,0 +1,241 @@ +// Copyright 2014 The Flutter 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:convert'; +import 'dart:io' as io show Directory, File, Link; + +import 'package:file/file.dart'; +import 'package:meta/meta.dart'; + +import 'common.dart' show throwToolExit; +import 'platform.dart'; + +// The Flutter tool hits file system errors that only the end-user can address. +// We would like these errors to not hit crash logging. In these cases, we +// should exit gracefully and provide potentially useful advice. For example, if +// a write fails because the target device is full, we can explain that with a +// ToolExit and a message that is more clear than the FileSystemException by +// itself. + +/// A [FileSystem] that throws a [ToolExit] on certain errors. +/// +/// If a [FileSystem] error is not caused by the Flutter tool, and can only be +/// addressed by the user, it should be caught by this [FileSystem] and thrown +/// as a [ToolExit] using [throwToolExit]. +/// +/// Cf. If there is some hope that the tool can continue when an operation fails +/// with an error, then that error/operation should not be handled here. For +/// example, the tool should gernerally be able to continue executing even if it +/// fails to delete a file. +class ErrorHandlingFileSystem extends ForwardingFileSystem { + ErrorHandlingFileSystem(FileSystem delegate) : super(delegate); + + @visibleForTesting + FileSystem get fileSystem => delegate; + + @override + File file(dynamic path) => ErrorHandlingFile(delegate, delegate.file(path)); +} + +class ErrorHandlingFile + extends ForwardingFileSystemEntity + with ForwardingFile { + ErrorHandlingFile(this.fileSystem, this.delegate); + + @override + final io.File delegate; + + @override + final FileSystem fileSystem; + + @override + File wrapFile(io.File delegate) => + ErrorHandlingFile(fileSystem, delegate); + + @override + Directory wrapDirectory(io.Directory delegate) => + ErrorHandlingDirectory(fileSystem, delegate); + + @override + Link wrapLink(io.Link delegate) => + ErrorHandlingLink(fileSystem, delegate); + + @override + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) async { + return _run( + () async => wrap(await delegate.writeAsBytes( + bytes, + mode: mode, + flush: flush, + )), + failureMessage: 'Flutter failed to write to a file at "${delegate.path}"', + ); + } + + @override + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) { + _runSync( + () => delegate.writeAsBytesSync(bytes, mode: mode, flush: flush), + failureMessage: 'Flutter failed to write to a file at "${delegate.path}"', + ); + } + + @override + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) async { + return _run( + () async => wrap(await delegate.writeAsString( + contents, + mode: mode, + encoding: encoding, + flush: flush, + )), + failureMessage: 'Flutter failed to write to a file at "${delegate.path}"', + ); + } + + @override + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + _runSync( + () => delegate.writeAsStringSync( + contents, + mode: mode, + encoding: encoding, + flush: flush, + ), + failureMessage: 'Flutter failed to write to a file at "${delegate.path}"', + ); + } + + Future _run(Future Function() op, { String failureMessage }) async { + try { + return await op(); + } on FileSystemException catch (e) { + if (platform.isWindows) { + _handleWindowsException(e, failureMessage); + } + rethrow; + } + } + + T _runSync(T Function() op, { String failureMessage }) { + try { + return op(); + } on FileSystemException catch (e) { + if (platform.isWindows) { + _handleWindowsException(e, failureMessage); + } + rethrow; + } + } + + void _handleWindowsException(FileSystemException e, String message) { + // From: + // https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes + const int kDeviceFull = 112; + const int kUserMappedSectionOpened = 1224; + final int errorCode = e.osError?.errorCode ?? 0; + // Catch errors and bail when: + switch (errorCode) { + case kDeviceFull: + throwToolExit( + '$message. The target device is full.' + '\n$e\n' + 'Free up space and try again.', + ); + break; + case kUserMappedSectionOpened: + throwToolExit( + '$message. The file is being used by another program.' + '\n$e\n' + 'Do you have an antivirus program running? ' + 'Try disabling your antivirus program and try again.', + ); + break; + default: + // Caller must rethrow the exception. + break; + } + } +} + +class ErrorHandlingDirectory + extends ForwardingFileSystemEntity + with ForwardingDirectory { + ErrorHandlingDirectory(this.fileSystem, this.delegate); + + @override + final io.Directory delegate; + + @override + final FileSystem fileSystem; + + @override + File wrapFile(io.File delegate) => + ErrorHandlingFile(fileSystem, delegate); + + @override + Directory wrapDirectory(io.Directory delegate) => + ErrorHandlingDirectory(fileSystem, delegate); + + @override + Link wrapLink(io.Link delegate) => + ErrorHandlingLink(fileSystem, delegate); + + // For the childEntity methods, we first obtain an instance of the entity + // from the underlying file system, then invoke childEntity() on it, then + // wrap in the ErrorHandling version. + @override + Directory childDirectory(String basename) => + wrapDirectory(fileSystem.directory(delegate).childDirectory(basename)); + + @override + File childFile(String basename) => + wrapFile(fileSystem.directory(delegate).childFile(basename)); + + @override + Link childLink(String basename) => + wrapLink(fileSystem.directory(delegate).childLink(basename)); +} + +class ErrorHandlingLink + extends ForwardingFileSystemEntity + with ForwardingLink { + ErrorHandlingLink(this.fileSystem, this.delegate); + + @override + final io.Link delegate; + + @override + final FileSystem fileSystem; + + @override + File wrapFile(io.File delegate) => + ErrorHandlingFile(fileSystem, delegate); + + @override + Directory wrapDirectory(io.Directory delegate) => + ErrorHandlingDirectory(fileSystem, delegate); + + @override + Link wrapLink(io.Link delegate) => + ErrorHandlingLink(fileSystem, delegate); +} diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart index b8d7bc0f82..b9e927a183 100644 --- a/packages/flutter_tools/lib/src/base/file_system.dart +++ b/packages/flutter_tools/lib/src/base/file_system.dart @@ -9,6 +9,7 @@ import 'package:meta/meta.dart'; import 'common.dart' show throwToolExit; import 'context.dart'; +import 'error_handling_file_system.dart'; import 'platform.dart'; export 'package:file/file.dart'; @@ -20,7 +21,9 @@ const FileSystem _kLocalFs = LocalFileSystem(); /// /// By default it uses local disk-based implementation. Override this in tests /// with [MemoryFileSystem]. -FileSystem get fs => context.get() ?? _kLocalFs; +FileSystem get fs => ErrorHandlingFileSystem( + context.get() ?? _kLocalFs, +); /// Create the ancestor directories of a file path if they do not already exist. void ensureDirectoryExists(String filePath) { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/precache_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/precache_test.dart index 4575ee92b8..d8bc857cc4 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/precache_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/precache_test.dart @@ -174,6 +174,7 @@ void main() { FlutterVersion: () => flutterVersion, FeatureFlags: () => TestFeatureFlags(isWebEnabled: false), }); + testUsingContext('precache downloads artifacts when --force is provided', () async { when(cache.isUpToDate()).thenReturn(true); final PrecacheCommand command = PrecacheCommand(); diff --git a/packages/flutter_tools/test/general.shard/base/error_handling_file_system_test.dart b/packages/flutter_tools/test/general.shard/base/error_handling_file_system_test.dart new file mode 100644 index 0000000000..1c9e6c9c90 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/base/error_handling_file_system_test.dart @@ -0,0 +1,99 @@ +// Copyright 2014 The Flutter 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 'package:file/file.dart'; +import 'package:flutter_tools/src/base/error_handling_file_system.dart'; +import 'package:mockito/mockito.dart'; +import 'package:platform/platform.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; +import '../../src/testbed.dart'; + +class MockFile extends Mock implements File {} +class MockFileSystem extends Mock implements FileSystem {} +class MockPlatform extends Mock implements Platform {} + +void main() { + group('throws ToolExit on Windows', () { + const int kDeviceFull = 112; + const int kUserMappedSectionOpened = 1224; + Testbed testbed; + MockFileSystem mockFileSystem; + MockPlatform windowsPlatform; + ErrorHandlingFileSystem fs; + + setUp(() { + mockFileSystem = MockFileSystem(); + fs = ErrorHandlingFileSystem(mockFileSystem); + + windowsPlatform = MockPlatform(); + when(windowsPlatform.isWindows).thenReturn(true); + when(windowsPlatform.isLinux).thenReturn(false); + when(windowsPlatform.isMacOS).thenReturn(false); + testbed = Testbed(overrides: { + Platform: () => windowsPlatform, + }); + }); + + void writeTests({ + String testName, + int errorCode, + String expectedMessage, + }) { + test(testName, () => testbed.run(() async { + final MockFile mockFile = MockFile(); + when(mockFileSystem.file(any)).thenReturn(mockFile); + when(mockFile.writeAsBytes( + any, + mode: anyNamed('mode'), + flush: anyNamed('flush'), + )).thenAnswer((_) async { + throw FileSystemException('', '', OSError('', errorCode)); + }); + when(mockFile.writeAsString( + any, + mode: anyNamed('mode'), + encoding: anyNamed('encoding'), + flush: anyNamed('flush'), + )).thenAnswer((_) async { + throw FileSystemException('', '', OSError('', errorCode)); + }); + when(mockFile.writeAsBytesSync( + any, + mode: anyNamed('mode'), + flush: anyNamed('flush'), + )).thenThrow(FileSystemException('', '', OSError('', errorCode))); + when(mockFile.writeAsStringSync( + any, + mode: anyNamed('mode'), + encoding: anyNamed('encoding'), + flush: anyNamed('flush'), + )).thenThrow(FileSystemException('', '', OSError('', errorCode))); + + final File file = fs.file('file'); + + expect(() async => await file.writeAsBytes([0]), + throwsToolExit(message: expectedMessage)); + expect(() async => await file.writeAsString(''), + throwsToolExit(message: expectedMessage)); + expect(() => file.writeAsBytesSync([0]), + throwsToolExit(message: expectedMessage)); + expect(() => file.writeAsStringSync(''), + throwsToolExit(message: expectedMessage)); + })); + } + + writeTests( + testName: 'when writing to a full device', + errorCode: kDeviceFull, + expectedMessage: 'The target device is full', + ); + writeTests( + testName: 'when the file is being used by another program', + errorCode: kUserMappedSectionOpened, + expectedMessage: 'The file is being used by another program', + ); + }); +} diff --git a/packages/flutter_tools/test/general.shard/forbidden_imports_test.dart b/packages/flutter_tools/test/general.shard/forbidden_imports_test.dart index babe4260fb..4819c3e1cc 100644 --- a/packages/flutter_tools/test/general.shard/forbidden_imports_test.dart +++ b/packages/flutter_tools/test/general.shard/forbidden_imports_test.dart @@ -39,6 +39,7 @@ void main() { test('no unauthorized imports of dart:io', () { final List whitelistedPaths = [ fs.path.join(flutterTools, 'lib', 'src', 'base', 'io.dart'), + fs.path.join(flutterTools, 'lib', 'src', 'base', 'error_handling_file_system.dart'), ]; bool _isNotWhitelisted(FileSystemEntity entity) => whitelistedPaths.every((String path) => path != entity.path); @@ -83,6 +84,7 @@ void main() { test('no unauthorized imports of dart:convert', () { final List whitelistedPaths = [ fs.path.join(flutterTools, 'lib', 'src', 'convert.dart'), + fs.path.join(flutterTools, 'lib', 'src', 'base', 'error_handling_file_system.dart'), ]; bool _isNotWhitelisted(FileSystemEntity entity) => whitelistedPaths.every((String path) => path != entity.path); diff --git a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart index fa2125e77c..6bd474ffa6 100644 --- a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart +++ b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'dart:io' as io; import 'package:flutter_tools/src/base/common.dart'; +import 'package:flutter_tools/src/base/error_handling_file_system.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/signals.dart'; import 'package:flutter_tools/src/base/time.dart'; @@ -65,6 +67,16 @@ void main() { Cache: () => cache, }); + testUsingContext('uses the error handling file system', () async { + final DummyFlutterCommand flutterCommand = DummyFlutterCommand( + commandFunction: () async { + expect(fs, isA()); + return const FlutterCommandResult(ExitStatus.success); + } + ); + await flutterCommand.run(); + }); + void testUsingCommandContext(String testName, dynamic Function() testBody) { testUsingContext(testName, testBody, overrides: { ProcessInfo: () => mockProcessInfo, diff --git a/packages/flutter_tools/test/general.shard/testbed_test.dart b/packages/flutter_tools/test/general.shard/testbed_test.dart index a2d0274cac..7d1c396ecf 100644 --- a/packages/flutter_tools/test/general.shard/testbed_test.dart +++ b/packages/flutter_tools/test/general.shard/testbed_test.dart @@ -5,9 +5,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/context.dart'; import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/error_handling_file_system.dart'; import '../src/common.dart'; import '../src/testbed.dart'; @@ -23,7 +25,9 @@ void main() { localFileSystem = fs; }); - expect(localFileSystem, isA()); + expect(localFileSystem, isA()); + expect((localFileSystem as ErrorHandlingFileSystem).fileSystem, + isA()); }); test('Can provide setup interfaces', () async {