File: Utils\FileDeleteHelper.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Cli.Utils;
 
/// <summary>
/// Helpers for deleting files and directories that may be locked by another
/// process (e.g. <c>aspire-managed.exe</c> still running from a previous CLI
/// session).
///
/// On Windows a running process holds an exclusive lock on its executable,
/// so <see cref="Directory.Delete(string, bool)"/> throws
/// <see cref="UnauthorizedAccessException"/>. Rather than failing the entire
/// operation, these helpers rename the locked item to
/// <c>{name}.old.{tickcount}</c> and let the caller proceed. The stale
/// renamed items are best-effort cleaned up on subsequent invocations via
/// <see cref="TryCleanupOldItems"/>.
/// </summary>
internal static class FileDeleteHelper
{
    /// <summary>
    /// Attempts to delete a directory recursively. If deletion fails because
    /// a file inside the directory is locked by another process, the directory
    /// is renamed to <c>{path}.old.{tickcount}</c> so the caller can create a
    /// fresh replacement. Returns <see langword="true"/> if the directory was
    /// deleted or renamed, <see langword="false"/> if it did not exist.
    /// </summary>
    internal static bool TryDeleteDirectory(string path)
    {
        if (!Directory.Exists(path))
        {
            return false;
        }
 
        try
        {
            Directory.Delete(path, recursive: true);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // A process still has a file open inside this directory
            // (e.g. aspire-managed.exe from a previous CLI session).
            // Rename it so the caller can proceed with a fresh directory.
            try
            {
                var renamedPath = $"{path}.old.{Environment.TickCount64}";
                Directory.Move(path, renamedPath);
            }
            catch (Exception)
            {
                // Rename also failed — nothing more we can do.
                // The caller's extraction will fail and surface a
                // "run aspire setup --force" message.
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Attempts to delete a file. If deletion fails because the file is locked
    /// by another process, the file is renamed to <c>{path}.old.{tickcount}</c>
    /// so the caller can write a fresh replacement. Returns
    /// <see langword="true"/> if the file was deleted or renamed,
    /// <see langword="false"/> if it did not exist.
    /// </summary>
    internal static bool TryDeleteFile(string path)
    {
        if (!File.Exists(path))
        {
            return false;
        }
 
        try
        {
            File.Delete(path);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            try
            {
                var renamedPath = $"{path}.old.{Environment.TickCount64}";
                File.Move(path, renamedPath);
            }
            catch (Exception)
            {
                // Rename also failed — nothing more we can do.
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Best-effort cleanup of stale <c>.old.{tickcount}</c> items (both files
    /// and directories) that were left behind by previous
    /// <see cref="TryDeleteDirectory"/> or <see cref="TryDeleteFile"/> calls.
    /// Items that are still locked are silently skipped — they will be retried
    /// on the next invocation.
    /// </summary>
    internal static void TryCleanupOldItems(string directory, string name)
    {
        if (!Directory.Exists(directory))
        {
            return;
        }
 
        var pattern = $"{name}.old.*";
 
        foreach (var oldDir in Directory.EnumerateDirectories(directory, pattern))
        {
            try
            {
                Directory.Delete(oldDir, recursive: true);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Still in use — leave it for the next attempt
            }
        }
 
        foreach (var oldFile in Directory.EnumerateFiles(directory, pattern))
        {
            try
            {
                File.Delete(oldFile);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Still in use
            }
        }
    }
}