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.''');
+      }
     });
   }