Avoid race condition for hosted source on package extraction (#3277)
diff --git a/lib/src/command/cache_repair.dart b/lib/src/command/cache_repair.dart index 41bae2b..74968be 100644 --- a/lib/src/command/cache_repair.dart +++ b/lib/src/command/cache_repair.dart
@@ -23,6 +23,8 @@ @override Future<void> runProtected() async { + // Delete any eventual temp-files left in the cache. + cache.deleteTempDir(); // Repair every cached source. final repairResults = (await Future.wait( cache.sources.all.map(cache.source).map((source) async {
diff --git a/lib/src/io.dart b/lib/src/io.dart index d351e2b..6d0bf83 100644 --- a/lib/src/io.dart +++ b/lib/src/io.dart
@@ -361,52 +361,54 @@ /// when we try to delete or move something while it's being scanned. To /// mitigate that, on Windows, this will retry the operation a few times if it /// fails. -void _attempt(String description, void Function() operation) { +/// +/// For some operations it makes sense to handle ERROR_DIR_NOT_EMPTY +/// differently. They can pass [ignoreEmptyDir] = `true`. +void _attempt(String description, void Function() operation, + {bool ignoreEmptyDir = false}) { if (!Platform.isWindows) { operation(); return; } String? getErrorReason(FileSystemException error) { + // ERROR_ACCESS_DENIED if (error.osError?.errorCode == 5) { return 'access was denied'; } + // ERROR_SHARING_VIOLATION if (error.osError?.errorCode == 32) { return 'it was in use by another process'; } - if (error.osError?.errorCode == 145) { + // ERROR_DIR_NOT_EMPTY + if (!ignoreEmptyDir && isDirectoryNotEmptyException(error)) { return 'of dart-lang/sdk#25353'; } return null; } - for (var i = 0; i < 2; i++) { + for (var i = 0; i < 3; i++) { try { operation(); - return; + break; } on FileSystemException catch (error) { var reason = getErrorReason(error); if (reason == null) rethrow; - log.io('Pub failed to $description because $reason. ' - 'Retrying in 50ms.'); - sleep(Duration(milliseconds: 50)); + if (i < 2) { + log.io('Pub failed to $description because $reason. ' + 'Retrying in 50ms.'); + sleep(Duration(milliseconds: 50)); + } else { + fail('Pub failed to $description because $reason.\n' + 'This may be caused by a virus scanner or having a file\n' + 'in the directory open in another application.'); + } } } - - try { - operation(); - } on FileSystemException catch (error) { - var reason = getErrorReason(error); - if (reason == null) rethrow; - - fail('Pub failed to $description because $reason.\n' - 'This may be caused by a virus scanner or having a file\n' - 'in the directory open in another application.'); - } } /// Deletes whatever's at [path], whether it's a file, directory, or symlink. @@ -451,14 +453,25 @@ void renameDir(String from, String to) { _attempt('rename directory', () { log.io('Renaming directory $from to $to.'); - try { - Directory(from).renameSync(to); - } on IOException { - // Ensure that [to] isn't left in an inconsistent state. See issue 12436. - if (entryExists(to)) deleteEntry(to); - rethrow; - } - }); + Directory(from).renameSync(to); + }, ignoreEmptyDir: true); +} + +bool isDirectoryNotEmptyException(FileSystemException e) { + final errorCode = e.osError?.errorCode; + return + // On Linux rename will fail with ENOTEMPTY if directory exists: + // https://man7.org/linux/man-pages/man2/rename.2.html + // #define ENOTEMPTY 39 /* Directory not empty */ + // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/asm-generic/errno.h#n20 + (Platform.isLinux && errorCode == 39) || + // On Windows this may fail with ERROR_DIR_NOT_EMPTY + // https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499- + (Platform.isWindows && errorCode == 145) || + // On MacOS rename will fail with ENOTEMPTY if directory exists. + // #define ENOTEMPTY 66 /* Directory not empty */ + // https://github.com/apple-oss-distributions/xnu/blob/bb611c8fecc755a0d8e56e2fa51513527c5b7a0e/bsd/sys/errno.h#L190 + (Platform.isMacOS && errorCode == 66); } /// Creates a new symlink at path [symlink] that points to [target].
diff --git a/lib/src/source/hosted.dart b/lib/src/source/hosted.dart index 1cb1950..e371363 100644 --- a/lib/src/source/hosted.dart +++ b/lib/src/source/hosted.dart
@@ -704,6 +704,7 @@ packages.map((package) async { var id = source.idFor(package.name, package.version, url: url); try { + deleteEntry(package.dir); await _download(id, package.dir); return RepairResult(id, success: true); } catch (error, stackTrace) { @@ -812,14 +813,23 @@ var tempDir = systemCache.createTempDir(); await extractTarGz(readBinaryFileAsSream(archivePath), tempDir); - // Remove the existing directory if it exists. This will happen if - // we're forcing a download to repair the cache. - if (dirExists(destPath)) deleteEntry(destPath); - // Now that the get has succeeded, move it to the real location in the - // cache. This ensures that we don't leave half-busted ghost - // directories in the user's pub cache if a get fails. - renameDir(tempDir, destPath); + // cache. + // + // If this fails with a "directory not empty" exception we assume that + // another pub process has installed the same package version while we + // downloaded. + try { + renameDir(tempDir, destPath); + } on io.FileSystemException catch (e) { + tryDeleteEntry(tempDir); + if (!isDirectoryNotEmptyException(e)) { + rethrow; + } + log.fine(''' +Destination directory $destPath already existed. +Assuming a concurrent pub invocation installed it.'''); + } }); }