File: BackEnd\BuildManager\MultiThreadedStrictModeScope.cs
Web Access
Project: Microsoft.Build.csproj (Microsoft.Build)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Threading;
using Microsoft.Build.BackEnd.Logging;
using Microsoft.Build.Construction;
using Microsoft.Build.Eventing;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;

namespace Microsoft.Build.Execution;

/// <summary>
/// Owns the process current directory during a strict multi-threaded build.
/// </summary>
internal sealed class MultiThreadedStrictModeScope
{
    internal const string SentinelDirectoryName = "MT-sentinel-CWD";

    // Serializes scope installation, restoration and directory repair. Never log under this lock.
    private static readonly LockType s_stateLock = new();
    private static MultiThreadedStrictModeScope? s_activeScope;

    private readonly object _reportedEntriesLock = new();
    private readonly HashSet<string> _reportedEntries = new(FileUtilities.PathComparer);
    private readonly string _directoryToRestore;
    private readonly string _temporaryDirectory;

    private MultiThreadedStrictModeScope(int buildId, string sentinelDirectory, string directoryToRestore, string temporaryDirectory)
    {
        BuildId = buildId;
        SentinelDirectory = sentinelDirectory;
        _directoryToRestore = directoryToRestore;
        _temporaryDirectory = temporaryDirectory;
    }

    internal int BuildId { get; }

    internal string SentinelDirectory { get; }

    internal static MultiThreadedStrictModeScope? ActiveScope => Volatile.Read(ref s_activeScope);

    internal static CurrentDirectorySnapshot CaptureCurrentDirectory()
    {
        lock (s_stateLock)
        {
            return new(Directory.GetCurrentDirectory(), s_activeScope);
        }
    }

    internal static MultiThreadedStrictModeScope Enter(int buildId) => Enter(buildId, CaptureCurrentDirectory());

    internal static MultiThreadedStrictModeScope Enter(int buildId, CurrentDirectorySnapshot snapshot)
        => Enter(buildId, snapshot, Directory.SetCurrentDirectory);

    // The test callback runs under the CWD lock and must not wait for engine work.
    internal static MultiThreadedStrictModeScope Enter(
        int buildId,
        CurrentDirectorySnapshot snapshot,
        Action<string> setCurrentDirectory)
    {
        lock (s_stateLock)
        {
            if (s_activeScope is not null)
            {
                throw new InvalidOperationException(ResourceUtilities.GetResourceString("MultiThreadedStrictModeAlreadyActive"));
            }

            string directoryToRestore = snapshot.RestoreTarget;
            string temporaryDirectory = FileUtilities.GetTemporaryDirectory(createDirectory: false);
            string sentinelDirectory = Path.Combine(temporaryDirectory, SentinelDirectoryName);
            bool entered = false;
            try
            {
                Directory.CreateDirectory(sentinelDirectory);
                // Resolve the owned path, never a mutable process CWD that another thread could replace.
                sentinelDirectory = NativeMethodsShared.IsWindows
                    ? NativeMethodsShared.GetLongFilePath(sentinelDirectory)
                    : NativeMethodsShared.RealPath(sentinelDirectory) ?? throw new Win32Exception();
                string canonicalTemporaryDirectory = NativeMethodsShared.IsWindows
                    ? NativeMethodsShared.GetLongFilePath(temporaryDirectory)
                    : NativeMethodsShared.RealPath(temporaryDirectory) ?? throw new Win32Exception();
                Assumed.True(
                    FileUtilities.PathComparer.Equals(Path.GetDirectoryName(sentinelDirectory), canonicalTemporaryDirectory),
                    "The strict-mode sentinel must remain within its temporary directory.");
                MultiThreadedStrictModeScope scope = new(buildId, sentinelDirectory, directoryToRestore, temporaryDirectory);
                setCurrentDirectory(sentinelDirectory);
                Volatile.Write(ref s_activeScope, scope);
                entered = true;
                return scope;
            }
            catch (Exception entryFailure) when (!ExceptionHandling.IsCriticalException(entryFailure))
            {
                try
                {
                    Directory.SetCurrentDirectory(directoryToRestore);
                }
                catch (Exception restorationFailure) when (!ExceptionHandling.IsCriticalException(restorationFailure))
                {
                    throw new AggregateException(entryFailure, restorationFailure);
                }

                throw;
            }
            finally
            {
                if (!entered)
                {
                    FileUtilities.TryDeleteFileOrDirectory(temporaryDirectory);
                }
            }
        }
    }

    internal readonly struct CurrentDirectorySnapshot(string directory, MultiThreadedStrictModeScope? owner)
    {
        internal string RestoreTarget =>
            owner is not null && FileUtilities.PathComparer.Equals(directory, owner.SentinelDirectory)
                ? owner._directoryToRestore
                : directory;
    }

    internal void Exit()
    {
        // When both locks are needed, take the scan lock before the CWD lock.
        lock (_reportedEntriesLock)
        {
            lock (s_stateLock)
            {
                if (!ReferenceEquals(s_activeScope, this))
                {
                    return;
                }

                try
                {
                    Directory.SetCurrentDirectory(_directoryToRestore);
                }
                finally
                {
                    Volatile.Write(ref s_activeScope, null);
                }
            }

            FileUtilities.TryDeleteFileOrDirectory(_temporaryDirectory);
        }
    }

    internal bool VerifyAndReportCurrentDirectory(
        TaskLoggingContext taskLoggingContext,
        string taskName,
        ElementLocation taskLocation,
        bool convertErrorsToWarnings)
    {
        string? unexpectedDirectory;
        try
        {
            unexpectedDirectory = DetectCurrentDirectoryViolation();
        }
        catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
        {
            if (RecoverSentinelDirectory(taskLocation, e))
            {
                taskLoggingContext.LogWarning(null, new BuildEventFileInfo(taskLocation),
                    "MultiThreadedStrictModeSentinelMissing", SentinelDirectory);
            }

            return false;
        }

        if (unexpectedDirectory is null)
        {
            return false;
        }

        if (convertErrorsToWarnings)
        {
            taskLoggingContext.LogWarning(null, new BuildEventFileInfo(taskLocation),
                "MultiThreadedStrictModeCurrentDirectoryChanged", taskName, unexpectedDirectory, SentinelDirectory);
            taskLoggingContext.LogComment(MessageImportance.Normal, "ErrorConvertedIntoWarning");
        }
        else
        {
            taskLoggingContext.LogError(new BuildEventFileInfo(taskLocation),
                "MultiThreadedStrictModeCurrentDirectoryChanged", taskName, unexpectedDirectory, SentinelDirectory);
        }

        return true;
    }

    internal string? DetectCurrentDirectoryViolation()
    {
        string? unexpectedDirectory = null;
        string currentDirectory = Directory.GetCurrentDirectory();
        if (!FileUtilities.PathComparer.Equals(currentDirectory, SentinelDirectory))
        {
            lock (s_stateLock)
            {
                if (ReferenceEquals(s_activeScope, this))
                {
                    // Another checker may have repaired the directory while this one waited for the lock.
                    currentDirectory = Directory.GetCurrentDirectory();
                    if (!FileUtilities.PathComparer.Equals(currentDirectory, SentinelDirectory))
                    {
                        Directory.SetCurrentDirectory(SentinelDirectory);
                        unexpectedDirectory = currentDirectory;
                    }
                }
            }
        }

        return unexpectedDirectory;
    }

    internal string? VerifyUnresolvedPathWrites(ElementLocation location, out bool recovered)
    {
        recovered = false;
        bool trace = MSBuildEventSource.Log.IsEnabled();
        if (trace)
        {
            MSBuildEventSource.Log.StrictModeDirectoryScanStart(BuildId, location.File);
        }

        try
        {
            return DetectUnresolvedPathWrites();
        }
        catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
        {
            recovered = RecoverSentinelDirectory(location, e);
            return null;
        }
        finally
        {
            if (trace)
            {
                MSBuildEventSource.Log.StrictModeDirectoryScanStop(BuildId, location.File);
            }
        }
    }

    private bool RecoverSentinelDirectory(ElementLocation location, Exception failure)
    {
        lock (_reportedEntriesLock)
        {
            lock (s_stateLock)
            {
                if (!ReferenceEquals(s_activeScope, this))
                {
                    return false;
                }

                try
                {
                    bool sentinelExists = Directory.Exists(SentinelDirectory);
                    if (failure is not (DirectoryNotFoundException or FileNotFoundException) && sentinelExists)
                    {
                        ProjectErrorUtilities.ThrowInvalidProject(location,
                            "MultiThreadedStrictModeSentinelRecoveryFailed", SentinelDirectory, failure.Message);
                    }

                    string? currentDirectory = null;
                    if (sentinelExists)
                    {
                        try
                        {
                            currentDirectory = Directory.GetCurrentDirectory();
                        }
                        catch (Exception e) when (e is DirectoryNotFoundException or FileNotFoundException)
                        {
                            // A removed Unix CWD can remain unlinked even after the path is recreated.
                        }
                    }

                    // Another observer may have completed recovery while this one waited.
                    if (sentinelExists && FileUtilities.PathComparer.Equals(currentDirectory, SentinelDirectory))
                    {
                        return false;
                    }

                    Directory.CreateDirectory(SentinelDirectory);
                    Directory.SetCurrentDirectory(SentinelDirectory);
                    _reportedEntries.Clear();
                    return true;
                }
                catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e))
                {
                    ProjectErrorUtilities.ThrowInvalidProject(location,
                        "MultiThreadedStrictModeSentinelRecoveryFailed", SentinelDirectory, e.Message);
                    throw;
                }
            }
        }
    }

    internal string? DetectUnresolvedPathWrites()
    {
        if (!ReferenceEquals(ActiveScope, this))
        {
            return null;
        }

        lock (_reportedEntriesLock)
        {
            if (!ReferenceEquals(ActiveScope, this))
            {
                return null;
            }

            // Finish enumeration before deleting entries from the directory.
            string[] snapshot = Directory.GetFileSystemEntries(SentinelDirectory);
            HashSet<string>? noLongerPresent = _reportedEntries.Count == 0 ? null : new(_reportedEntries, FileUtilities.PathComparer);
            List<string>? entries = null;
            foreach (string entry in snapshot)
            {
                string name = Path.GetFileName(entry);
                noLongerPresent?.Remove(name);
                if (_reportedEntries.Add(name))
                {
                    (entries ??= []).Add(name);
                }

                // Retry locked leftovers without duplicate diagnostics.
                if (FileUtilities.TryDeleteFileOrDirectory(entry))
                {
                    _reportedEntries.Remove(name);
                }
            }

            if (noLongerPresent is not null)
            {
                _reportedEntries.ExceptWith(noLongerPresent);
            }

            return entries is null ? null : string.Join(", ", entries);
        }
    }
}