| File: src\Shared\HeldFileLease.cs | Web Access |
| Project: src\src\Aspire.Hosting.Dotnet\Aspire.Hosting.Dotnet.csproj (Aspire.Hosting.Dotnet) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // This file is source-linked into multiple projects. // Do not add project-specific dependencies. namespace Aspire.Shared; /// <summary> /// Describes whether a directory contains a lease whose OS handle is still held. /// </summary> internal enum HeldFileLeaseProbeResult { None, Active, Unknown } /// <summary> /// Holds a unique lease file open exclusively until disposal. /// </summary> /// <remarks> /// A verified exclusive OS handle is authoritative. A lease whose exclusive lock cannot be /// verified is marked in its file name so probes retain it conservatively. /// </remarks> internal sealed class HeldFileLease : IDisposable { private const string UnverifiedLockMarker = ".unverified-lock"; private const int WindowsSharingViolationHResult = unchecked((int)0x80070020); private const int WindowsLockViolationHResult = unchecked((int)0x80070021); private const int LinuxWouldBlockHResult = 11; private const int MacOsWouldBlockHResult = 35; private readonly FileStream _stream; private HeldFileLease(string leasePath, FileStream stream) { LeasePath = leasePath; _stream = stream; } /// <summary> /// Gets the path of the held lease file. /// </summary> public string LeasePath { get; } /// <summary> /// Gets the held stream so a caller can write advisory metadata. /// </summary> public FileStream Stream => _stream; /// <summary> /// Creates and exclusively holds a uniquely named lease file. /// </summary> public static HeldFileLease Acquire(string leaseDirectory, string fileNamePrefix, string fileNameExtension) { ArgumentException.ThrowIfNullOrWhiteSpace(leaseDirectory); ArgumentNullException.ThrowIfNull(fileNamePrefix); ArgumentException.ThrowIfNullOrWhiteSpace(fileNameExtension); var fullLeaseDirectory = Path.GetFullPath(leaseDirectory); Directory.CreateDirectory(fullLeaseDirectory); var leaseFileName = string.Concat(fileNamePrefix, Guid.NewGuid().ToString("N")); var verifiedLeasePath = Path.Combine( fullLeaseDirectory, string.Concat(leaseFileName, fileNameExtension)); var leasePath = OperatingSystem.IsWindows() ? verifiedLeasePath : Path.Combine( fullLeaseDirectory, string.Concat(leaseFileName, UnverifiedLockMarker, fileNameExtension)); var stream = new FileStream( leasePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, bufferSize: 4096, OperatingSystem.IsWindows() ? FileOptions.DeleteOnClose : FileOptions.None); if (!OperatingSystem.IsWindows() && IsExclusiveLockEnforced(leasePath)) { try { File.Move(leasePath, verifiedLeasePath); leasePath = verifiedLeasePath; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { // Keep the marker so other processes retain this lease conservatively. } } return new HeldFileLease(leasePath, stream); } /// <summary> /// Probes lease files and reclaims any orphan files that can be opened exclusively. /// </summary> /// <remarks> /// Probing has a documented side effect: an orphan is removed after exclusive access is /// acquired and, on Unix, verified with a second open of the same file. /// Unverified leases and enumeration or access failures are reported as /// <see cref="HeldFileLeaseProbeResult.Unknown"/> rather than being mistaken for inactive leases. /// </remarks> public static HeldFileLeaseProbeResult Probe(string leaseDirectory, string fileNameExtension) { ArgumentException.ThrowIfNullOrWhiteSpace(leaseDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(fileNameExtension); string[] leasePaths; try { leasePaths = Directory.GetFiles(leaseDirectory, string.Concat("*", fileNameExtension)); } catch (DirectoryNotFoundException) { return File.Exists(leaseDirectory) ? HeldFileLeaseProbeResult.Unknown : HeldFileLeaseProbeResult.None; } catch (IOException) { return HeldFileLeaseProbeResult.Unknown; } catch (UnauthorizedAccessException) { return HeldFileLeaseProbeResult.Unknown; } catch (System.Security.SecurityException) { return HeldFileLeaseProbeResult.Unknown; } var result = HeldFileLeaseProbeResult.None; foreach (var leasePath in leasePaths) { var fileResult = ProbeLeaseFile(leasePath, fileNameExtension); if (fileResult is HeldFileLeaseProbeResult.Active) { return HeldFileLeaseProbeResult.Active; } if (fileResult is HeldFileLeaseProbeResult.Unknown) { result = HeldFileLeaseProbeResult.Unknown; } } return result; } /// <inheritdoc/> public void Dispose() { if (!OperatingSystem.IsWindows()) { try { // Unix has no kernel-backed DeleteOnClose, and an unverified lease may be renamed // after opening. Remove the path explicitly while any verified lock is still held. File.Delete(LeasePath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { // Retaining a stale lease is safer than making cleanup race a live holder. } } _stream.Dispose(); } private static HeldFileLeaseProbeResult ProbeLeaseFile(string leasePath, string fileNameExtension) { if (leasePath.EndsWith(string.Concat(UnverifiedLockMarker, fileNameExtension), StringComparison.Ordinal)) { return HeldFileLeaseProbeResult.Unknown; } try { using var stream = new FileStream( leasePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, OperatingSystem.IsWindows() ? FileOptions.DeleteOnClose : FileOptions.None); if (!OperatingSystem.IsWindows()) { if (!IsExclusiveLockEnforced(leasePath)) { return HeldFileLeaseProbeResult.Unknown; } try { File.Delete(leasePath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { return HeldFileLeaseProbeResult.Unknown; } } return HeldFileLeaseProbeResult.None; } catch (FileNotFoundException) { return HeldFileLeaseProbeResult.None; } catch (DirectoryNotFoundException) { return HeldFileLeaseProbeResult.None; } catch (IOException ex) when (IsLeaseContention(ex)) { return HeldFileLeaseProbeResult.Active; } catch (IOException) { return HeldFileLeaseProbeResult.Unknown; } catch (UnauthorizedAccessException) { return HeldFileLeaseProbeResult.Unknown; } catch (System.Security.SecurityException) { return HeldFileLeaseProbeResult.Unknown; } } private static bool IsExclusiveLockEnforced(string path) { try { // FileStream ignores Unix flock failures other than EWOULDBLOCK, including ENOTSUP. // A second open while the first handle is held distinguishes an enforced exclusive lock // from a successful open that acquired no lock. See: // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs. using var verificationStream = new FileStream( path, FileMode.Open, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.None); return false; } catch (IOException ex) when (IsLeaseContention(ex)) { return true; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { return false; } } private static bool IsLeaseContention(IOException exception) { if (OperatingSystem.IsWindows()) { return exception.HResult is WindowsSharingViolationHResult or WindowsLockViolationHResult; } // On Unix, FileStream implements FileShare.None with a non-blocking flock and exposes // EWOULDBLOCK as the raw errno in IOException.HResult: 11 on Linux and 35 on macOS. // See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs. return exception.HResult is LinuxWouldBlockHResult or MacOsWouldBlockHResult; } }