diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs
index 023fce598a3..65de6f98d1d 100644
--- a/src/Aspire.Cli/Bundles/BundleService.cs
+++ b/src/Aspire.Cli/Bundles/BundleService.cs
@@ -49,6 +49,17 @@ internal sealed class BundleService(
///
internal const string BadSuffixPrefix = ".bad.";
+ // Windows scanners can briefly open freshly extracted files without delete sharing, causing
+ // Directory.Move to fail with one of these HRESULTs. Unix permits renames while files are open,
+ // and the exact error list avoids delaying deterministic failures such as ERROR_DISK_FULL.
+ // See https://learn.microsoft.com/windows/win32/debug/system-error-codes--0-499-
+ private const int AccessDeniedHResult = unchecked((int)0x80070005);
+ private const int SharingViolationHResult = unchecked((int)0x80070020);
+ private const int LockViolationHResult = unchecked((int)0x80070021);
+
+ private static readonly TimeSpan s_directoryMoveMaxRetryElapsed = TimeSpan.FromSeconds(3);
+ private static readonly TimeSpan s_directoryMoveMaxRetryDelay = TimeSpan.FromSeconds(1);
+
///
public bool IsBundle => payloadProvider.HasPayload;
@@ -349,7 +360,12 @@ private async Task ExtractVersionedLayoutAsync(
try
{
- Directory.Move(tempDir, activeVersionDir);
+ await MoveDirectoryWithRetryAsync(tempDir, activeVersionDir, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ FileDeleteHelper.TryDeleteDirectory(tempDir);
+ throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -377,6 +393,50 @@ private async Task ExtractVersionedLayoutAsync(
return true;
}
+ ///
+ /// Moves a directory, retrying transient Windows file-lock failures with bounded backoff.
+ ///
+ internal async Task MoveDirectoryWithRetryAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken)
+ {
+ var delay = TimeSpan.FromMilliseconds(100);
+ var retryCount = 0;
+ var stopwatch = Stopwatch.StartNew();
+
+ while (true)
+ {
+ try
+ {
+ Directory.Move(sourcePath, destinationPath);
+ return;
+ }
+ catch (Exception ex) when (IsRetryableDirectoryMoveException(ex, environment.IsWindows()) && stopwatch.Elapsed < s_directoryMoveMaxRetryElapsed)
+ {
+ retryCount++;
+ logger.LogDebug(
+ "Directory move from {SourcePath} to {DestinationPath} failed with HRESULT {HResult}; retrying in {DelayMs}ms (retry {RetryCount}).",
+ sourcePath,
+ destinationPath,
+ ex.HResult,
+ delay.TotalMilliseconds,
+ retryCount);
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ delay = TimeSpan.FromMilliseconds(Math.Min(
+ delay.TotalMilliseconds * 2,
+ s_directoryMoveMaxRetryDelay.TotalMilliseconds));
+ }
+ }
+ }
+
+ ///
+ /// Determines whether a directory move exception represents a transient Windows file lock.
+ ///
+ internal static bool IsRetryableDirectoryMoveException(Exception exception, bool isWindows)
+ {
+ return isWindows &&
+ exception is IOException or UnauthorizedAccessException &&
+ exception.HResult is AccessDeniedHResult or SharingViolationHResult or LockViolationHResult;
+ }
+
///
public string? GetDefaultExtractDir(string processPath)
=> ComputeDefaultExtractDir(processPath, logger);
diff --git a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs
index a437bb3de50..8439204c8e1 100644
--- a/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs
+++ b/tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs
@@ -196,6 +196,35 @@ public async Task ExtractAsync_CleansUpStaleVersionDirectories()
}
}
+ [Fact]
+ [SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Windows file sharing semantics are required.")]
+ public async Task MoveDirectoryWithRetryAsync_FileTemporarilyLocked_RetriesUntilReleased()
+ {
+ using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
+ var sourcePath = workspace.CreateDirectory("source").FullName;
+ var destinationPath = Path.Combine(workspace.WorkspaceRoot.FullName, "destination");
+ var lockedFilePath = Path.Combine(sourcePath, "locked.dll");
+ File.WriteAllText(lockedFilePath, "locked");
+
+ var service = CreateService(
+ new TestBundlePayloadProvider(CreateFakeBundlePayload()),
+ new TestLayoutDiscovery(workspace.WorkspaceRoot.FullName));
+
+ using var lockedFile = new FileStream(lockedFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ var moveTask = service.MoveDirectoryWithRetryAsync(
+ sourcePath,
+ destinationPath,
+ TestContext.Current.CancellationToken);
+
+ Assert.False(moveTask.IsCompleted);
+
+ lockedFile.Dispose();
+ await moveTask;
+
+ Assert.False(Directory.Exists(sourcePath));
+ Assert.True(Directory.Exists(destinationPath));
+ }
+
[Fact]
public async Task EnsureExtractedAndAcquireLayoutAsync_ReturnsVersionRootedLayoutAndSkipsLeasedCleanup()
{
diff --git a/tests/Aspire.Cli.Tests/BundleServiceTests.cs b/tests/Aspire.Cli.Tests/BundleServiceTests.cs
index 9fac054b680..66501dd3030 100644
--- a/tests/Aspire.Cli.Tests/BundleServiceTests.cs
+++ b/tests/Aspire.Cli.Tests/BundleServiceTests.cs
@@ -149,6 +149,34 @@ public void IsVersionedLayoutValid_RequiresDcpDirectory()
Assert.False(BundleService.IsVersionedLayoutValid(dir));
}
+ [Theory]
+ [InlineData(unchecked((int)0x80070005), true)] // ERROR_ACCESS_DENIED
+ [InlineData(unchecked((int)0x80070020), true)] // ERROR_SHARING_VIOLATION
+ [InlineData(unchecked((int)0x80070021), true)] // ERROR_LOCK_VIOLATION
+ [InlineData(unchecked((int)0x80070027), false)] // ERROR_HANDLE_DISK_FULL
+ [InlineData(unchecked((int)0x80070070), false)] // ERROR_DISK_FULL
+ [InlineData(unchecked((int)0x800700B7), false)] // ERROR_ALREADY_EXISTS
+ public void IsRetryableDirectoryMoveException_OnlyRetriesTransientWindowsLockErrors(int hresult, bool expected)
+ {
+ var exception = new IOException("Directory move failed.", hresult);
+
+ Assert.Equal(expected, BundleService.IsRetryableDirectoryMoveException(exception, isWindows: true));
+ }
+
+ [Fact]
+ public void IsRetryableDirectoryMoveException_RetriesUnauthorizedAccess()
+ {
+ Assert.True(BundleService.IsRetryableDirectoryMoveException(new UnauthorizedAccessException(), isWindows: true));
+ }
+
+ [Fact]
+ public void IsRetryableDirectoryMoveException_DoesNotRetryOnNonWindows()
+ {
+ var exception = new IOException("Directory move failed.", unchecked((int)0x80070020));
+
+ Assert.False(BundleService.IsRetryableDirectoryMoveException(exception, isWindows: false));
+ }
+
[Fact]
public void TryCleanupStaleVersions_RemovesNonActiveVersionsAndStaleTempDirs()
{