From a03480cfb3095758bf6aa008b27a2ac4b849d56b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 20 Jul 2026 16:42:49 -0700 Subject: [PATCH 1/2] Retry transient bundle directory moves Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d0bf13f7-53de-4d9d-951d-cf6bcde7e820 --- src/Aspire.Cli/Bundles/BundleService.cs | 57 ++++++++++++++++++- .../BundleServiceIntegrationTests.cs | 29 ++++++++++ tests/Aspire.Cli.Tests/BundleServiceTests.cs | 28 +++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index 023fce598a3..ebc027bb7ae 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,7 @@ private async Task ExtractVersionedLayoutAsync( try { - Directory.Move(tempDir, activeVersionDir); + await MoveDirectoryWithRetryAsync(tempDir, activeVersionDir, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -377,6 +388,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() { From 1b83899b91e5aef6ae0a3a0faf4a5304f8a81345 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 20 Jul 2026 18:29:30 -0700 Subject: [PATCH 2/2] Clean up canceled bundle promotion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d0bf13f7-53de-4d9d-951d-cf6bcde7e820 --- src/Aspire.Cli/Bundles/BundleService.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index ebc027bb7ae..65de6f98d1d 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -362,6 +362,11 @@ private async Task ExtractVersionedLayoutAsync( { await MoveDirectoryWithRetryAsync(tempDir, activeVersionDir, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) + { + FileDeleteHelper.TryDeleteDirectory(tempDir); + throw; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { logger.LogError(ex, "Failed to promote {TempDir} to {ActiveDir}.", tempDir, activeVersionDir);