File: ConcurrencyUtilities.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Common\NuGet.Common.csproj (NuGet.Common)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using NuGet.Shared;

namespace NuGet.Common
{
    public static class ConcurrencyUtilities
    {
        private const int NumberOfRetries = 3000;
        // To maintain SHA-1 backwards compatibility with respect to the length of the hex-encoded hash, the hash will be truncated to a length of 20 bytes.
        private const int HashLength = 20;
        private static readonly TimeSpan SleepDuration = TimeSpan.FromMilliseconds(10);
        private static readonly KeyedLock PerFileLock = new KeyedLock();
        private static bool? _useDeleteOnClose = null;

        // FileOptions.DeleteOnClose causes concurrency issues on Mac OS X and Linux.
        // These are fixed in .NET 7 (https://github.com/dotnet/runtime/pull/55327).
        // To continue working in parallel with older versions of .NET,
        // we cannot use DeleteOnClose by default until .NET 6 goes EOL (Nov 2024).
        private static bool UseDeleteOnClose => _useDeleteOnClose ??= RuntimeEnvironmentHelper.IsWindows ||
                                               EnvironmentVariableReader.GetEnvironmentVariable("NUGET_ConcurrencyUtils_DeleteOnClose") == "1"; // opt-in.

        internal static IEnvironmentVariableReader EnvironmentVariableReader { get; set; } = EnvironmentVariableWrapper.Instance;

        public async static Task<T> ExecuteWithFileLockedAsync<T>(string filePath,
            Func<CancellationToken, Task<T>> action,
            CancellationToken token)
        {
            if (string.IsNullOrEmpty(filePath))
            {
                throw new ArgumentNullException(nameof(filePath));
            }

            await PerFileLock.EnterAsync(filePath, token);
            try
            {
                // limit the number of unauthorized, this should be around 30 seconds.
                var unauthorizedAttemptsLeft = NumberOfRetries;

                while (true)
                {
                    FileStream? fs = null;
                    var lockPath = string.Empty;

                    try
                    {
                        try
                        {
                            lockPath = FileLockPath(filePath);

                            fs = AcquireFileStream(lockPath);
                        }
                        catch (DirectoryNotFoundException)
                        {
                            throw;
                        }
                        catch (PathTooLongException)
                        {
                            throw;
                        }
                        catch (UnauthorizedAccessException)
                        {
                            token.ThrowIfCancellationRequested();

                            if (unauthorizedAttemptsLeft < 1)
                            {
                                if (string.IsNullOrEmpty(lockPath))
                                {
                                    lockPath = BasePath;
                                }

                                var message = string.Format(
                                    CultureInfo.CurrentCulture,
                                    Strings.UnauthorizedLockFail,
                                    lockPath,
                                    filePath);

                                throw new InvalidOperationException(message);
                            }

                            unauthorizedAttemptsLeft--;

                            // This can occur when the file is being deleted
                            // Or when an admin user has locked the file
                            await Task.Delay(SleepDuration, token);
                            continue;
                        }
                        catch (IOException)
                        {
                            token.ThrowIfCancellationRequested();

                            await Task.Delay(SleepDuration, token);
                            continue;
                        }

                        // Run the action within the lock
                        return await action(token);
                    }
                    finally
                    {
                        if (fs != null)
                        {
                            // Dispose of the stream, this will cause a delete
                            fs.Dispose();
                        }
                    }
                }
            }
            finally
            {
                await PerFileLock.ExitAsync(filePath);
            }
        }

        public static void ExecuteWithFileLocked(string filePath, Action action)
        {
            ExecuteWithFileLocked(filePath, action, AcquireFileStream, NumberOfRetries);
        }

        internal static void ExecuteWithFileLocked(string filePath, Action action, Func<string, FileStream> acquireFileStream, int numberOfRetries)
        {
            if (string.IsNullOrEmpty(filePath))
            {
                throw new ArgumentNullException(nameof(filePath));
            }

            PerFileLock.Enter(filePath);
            try
            {
                // limit the number of unauthorized, this should be around 30 seconds.
                var unauthorizedAttemptsLeft = numberOfRetries;

                while (true)
                {
                    FileStream? fs = null;
                    var lockPath = string.Empty;
                    try
                    {
                        try
                        {
                            lockPath = FileLockPath(filePath);

                            fs = acquireFileStream(lockPath);
                        }
                        catch (DirectoryNotFoundException)
                        {
                            throw;
                        }
                        catch (PathTooLongException)
                        {
                            throw;
                        }
                        catch (UnauthorizedAccessException)
                        {
                            if (unauthorizedAttemptsLeft < 1)
                            {
                                if (string.IsNullOrEmpty(lockPath))
                                {
                                    lockPath = BasePath;
                                }

                                var message = string.Format(
                                    CultureInfo.CurrentCulture,
                                    Strings.UnauthorizedLockFail,
                                    lockPath,
                                    filePath);

                                throw new InvalidOperationException(message);
                            }

                            unauthorizedAttemptsLeft--;

                            // This can occur when the file is being deleted
                            // Or when an admin user has locked the file
                            Thread.Sleep(SleepDuration);
                            continue;
                        }
                        catch (FileLoadException)
                        {
                            throw;
                        }
                        catch (IOException)
                        {
                            Thread.Sleep(SleepDuration);
                            continue;
                        }

                        // Run the action within the lock
                        action();
                        return;
                    }
                    finally
                    {
                        // Dispose of the stream, this will cause a delete
                        fs?.Dispose();
                    }
                }
            }
            finally
            {
                PerFileLock.Exit(filePath);
            }
        }

        private static FileStream AcquireFileStream(string lockPath)
        {
            // Sync operations have shown much better performance than FileOptions.Asynchronous
            return new FileStream(
                lockPath,
                FileMode.OpenOrCreate,
                FileAccess.ReadWrite,
                FileShare.None,
                bufferSize: 32,
                options: UseDeleteOnClose ? FileOptions.DeleteOnClose : FileOptions.None);
        }

        private static string? _basePath;
        private static string BasePath
        {
            get
            {
                if (_basePath != null)
                {
                    return _basePath;
                }

                _basePath = Path.Combine(NuGetEnvironment.GetFolderPath(NuGetFolderPath.Temp), "lock");

                Directory.CreateDirectory(_basePath);

                return _basePath;
            }
        }

        private static string FileLockPath(string filePath)
        {
            // In case the directory was cleaned up, we can choose to fix it (at a cost of another roundtrip to disk
            // or fail, starting with the more expensive path, and we might have to get rid of it if it becomes too hot.
            Directory.CreateDirectory(BasePath);

            return Path.Combine(BasePath, FilePathToLockName(filePath));
        }

        private static string FilePathToLockName(string filePath)
        {
            // If we use a file path directly as the name of a semaphore,
            // the ctor of semaphore looks for the file and throws an IOException
            // when the file doesn't exist. So we need a conversion from a file path
            // to a unique lock name.
            using (var sha = SHA256.Create())
            {
                // To avoid conflicts on package id casing a case-insensitive lock is used.
                var fullPath = Path.IsPathRooted(filePath) ? Path.GetFullPath(filePath) : filePath;
                var normalizedPath = fullPath.ToUpperInvariant();

                var hash = sha.ComputeHash(Encoding.UTF32.GetBytes(normalizedPath));

                return EncodingUtility.ToHex(hash, HashLength);
            }
        }
    }
}