Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/Aspire.Cli/Bundles/BundleService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ internal sealed class BundleService(
/// </summary>
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);

/// <inheritdoc/>
public bool IsBundle => payloadProvider.HasPayload;

Expand Down Expand Up @@ -349,7 +360,12 @@ private async Task<bool> ExtractVersionedLayoutAsync(

try
{
Directory.Move(tempDir, activeVersionDir);
await MoveDirectoryWithRetryAsync(tempDir, activeVersionDir, cancellationToken).ConfigureAwait(false);
Comment thread
danegsta marked this conversation as resolved.
}
catch (OperationCanceledException)
{
FileDeleteHelper.TryDeleteDirectory(tempDir);
throw;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Expand Down Expand Up @@ -377,6 +393,50 @@ private async Task<bool> ExtractVersionedLayoutAsync(
return true;
}

/// <summary>
/// Moves a directory, retrying transient Windows file-lock failures with bounded backoff.
/// </summary>
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));
}
}
}

/// <summary>
/// Determines whether a directory move exception represents a transient Windows file lock.
/// </summary>
internal static bool IsRetryableDirectoryMoveException(Exception exception, bool isWindows)
{
return isWindows &&
exception is IOException or UnauthorizedAccessException &&
exception.HResult is AccessDeniedHResult or SharingViolationHResult or LockViolationHResult;
}

/// <inheritdoc/>
public string? GetDefaultExtractDir(string processPath)
=> ComputeDefaultExtractDir(processPath, logger);
Expand Down
29 changes: 29 additions & 0 deletions tests/Aspire.Cli.Tests/BundleServiceIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Aspire.Cli.Tests/BundleServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down