File: Build\ProjectBuildManager.cs
Web Access
Project: src\src\Workspaces\MSBuild\BuildHost\Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.csproj (Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.Build.Framework;
using Roslyn.Utilities;
using MSB = Microsoft.Build;
 
namespace Microsoft.CodeAnalysis.MSBuild;
 
internal sealed class ProjectBuildManager
{
    private static readonly XmlReaderSettings s_xmlReaderSettings = new()
    {
        DtdProcessing = DtdProcessing.Prohibit,
        XmlResolver = null
    };
 
    private static readonly ImmutableDictionary<string, string> s_defaultGlobalProperties = new Dictionary<string, string>()
    {
        // this will tell msbuild to not build the dependent projects
        { PropertyNames.DesignTimeBuild, bool.TrueString },
 
        // this will force CoreCompile task to execute even if all inputs and outputs are up to date
#if NET
        { PropertyNames.NonExistentFile, "__NonExistentSubDir__\\__NonExistentFile__" },
#else
        // Setting `BuildingInsideVisualStudio` indirectly sets NonExistentFile:
        // https://github.com/microsoft/msbuild/blob/ab9b2f36a5ff7a85f842b205d5529e77fdc9d7ab/src/Tasks/Microsoft.Common.CurrentVersion.targets#L3462-L3470
        { PropertyNames.BuildingInsideVisualStudio, bool.TrueString },
#endif
 
        { PropertyNames.BuildProjectReferences, bool.FalseString },
        { PropertyNames.BuildingProject, bool.FalseString },
 
        // retrieve the command-line arguments to the compiler
        { PropertyNames.ProvideCommandLineArgs, bool.TrueString },
 
        // don't actually run the compiler
        { PropertyNames.SkipCompilerExecution, bool.TrueString },
 
        { PropertyNames.ContinueOnError, PropertyValues.ErrorAndContinue },
 
        // this ensures that the parent project's configuration and platform will be used for
        // referenced projects. So, setting Configuration=Release will also cause any project
        // references to also be built with Configuration=Release. This is necessary for getting
        // a more-likely-to-be-correct output path from project references.
        { PropertyNames.ShouldUnsetParentConfigurationAndPlatform, bool.FalseString }
    }.ToImmutableDictionary();
 
    private readonly ImmutableDictionary<string, string> _additionalGlobalProperties;
    private readonly ILogger? _msbuildLogger;
    private MSB.Evaluation.ProjectCollection? _batchBuildProjectCollection;
    private MSBuildDiagnosticLogger? _batchBuildLogger;
 
    ~ProjectBuildManager()
    {
        if (BatchBuildStarted)
        {
            throw new InvalidOperationException($"{nameof(ProjectBuildManager)}.{nameof(EndBatchBuild)} not called.");
        }
    }
 
    public ProjectBuildManager(ImmutableDictionary<string, string> additionalGlobalProperties, ILogger? msbuildLogger = null)
    {
        _additionalGlobalProperties = additionalGlobalProperties ?? ImmutableDictionary<string, string>.Empty;
        _msbuildLogger = msbuildLogger;
    }
 
    private ImmutableDictionary<string, string> AllGlobalProperties
        => s_defaultGlobalProperties.AddRange(_additionalGlobalProperties);
 
    private static async Task<(MSB.Evaluation.Project? project, DiagnosticLog log)> LoadProjectAsync(
        string path, MSB.Evaluation.ProjectCollection? projectCollection, CancellationToken cancellationToken)
    {
        var log = new DiagnosticLog();
 
        try
        {
            var loadedProjects = projectCollection?.GetLoadedProjects(path);
            if (loadedProjects != null && loadedProjects.Count > 0)
            {
                Debug.Assert(loadedProjects.Count == 1);
 
                return (loadedProjects.First(), log);
            }
 
            using var stream = FileUtilities.OpenAsyncRead(path);
            using var readStream = await SerializableBytes.CreateReadableStreamAsync(stream, cancellationToken).ConfigureAwait(false);
            using var xmlReader = XmlReader.Create(readStream, s_xmlReaderSettings);
            var xml = MSB.Construction.ProjectRootElement.Create(xmlReader, projectCollection);
 
            // When constructing a project from an XmlReader, MSBuild cannot determine the project file path.  Setting the
            // path explicitly is necessary so that the reserved properties like $(MSBuildProjectDirectory) will work.
            xml.FullPath = path;
 
            // Roughly matches the VS project load settings for their design time builds.
            var projectLoadSettings = MSB.Evaluation.ProjectLoadSettings.RejectCircularImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreEmptyImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreMissingImports
                | MSB.Evaluation.ProjectLoadSettings.IgnoreInvalidImports
                | MSB.Evaluation.ProjectLoadSettings.DoNotEvaluateElementsWithFalseCondition
                | MSB.Evaluation.ProjectLoadSettings.FailOnUnresolvedSdk;
 
            var project = new MSB.Evaluation.Project(
                xml,
                globalProperties: null,
                toolsVersion: null,
                projectCollection,
                projectLoadSettings);
 
            return (project, log);
        }
        catch (Exception e)
        {
            log.Add(e, path);
            return (project: null, log);
        }
    }
 
    public Task<(MSB.Evaluation.Project? project, DiagnosticLog log)> LoadProjectAsync(
        string path, CancellationToken cancellationToken)
    {
        if (BatchBuildStarted)
        {
            return LoadProjectAsync(path, _batchBuildProjectCollection, cancellationToken);
        }
        else
        {
            var projectCollection = new MSB.Evaluation.ProjectCollection(
                AllGlobalProperties,
                _msbuildLogger != null ? [_msbuildLogger] : ImmutableArray<MSB.Framework.ILogger>.Empty,
                MSB.Evaluation.ToolsetDefinitionLocations.Default);
            try
            {
                return LoadProjectAsync(path, projectCollection, cancellationToken);
            }
            finally
            {
                // unload project so collection will release global strings
                projectCollection.UnloadAllProjects();
            }
        }
    }
 
    public async Task<string?> TryGetOutputFilePathAsync(
        string path, CancellationToken cancellationToken)
    {
        Debug.Assert(BatchBuildStarted);
 
        // This tries to get the project output path and retrieving the evaluated $(TargetPath) property.
 
        var (project, _) = await LoadProjectAsync(path, cancellationToken).ConfigureAwait(false);
        return project?.GetPropertyValue(PropertyNames.TargetPath);
    }
 
    public bool BatchBuildStarted { get; private set; }
 
    public void StartBatchBuild(IDictionary<string, string>? globalProperties = null)
    {
        if (BatchBuildStarted)
        {
            throw new InvalidOperationException();
        }
 
        globalProperties ??= ImmutableDictionary<string, string>.Empty;
        var allProperties = s_defaultGlobalProperties.RemoveRange(globalProperties.Keys).AddRange(globalProperties);
 
        _batchBuildLogger = new MSBuildDiagnosticLogger()
        {
            Verbosity = MSB.Framework.LoggerVerbosity.Normal
        };
 
        // Pass in the binlog (if any) to the ProjectCollection to ensure evaluation results are included in it.
        //
        // We do not need to include the _batchBuildLogger in the ProjectCollection - it just collects the
        // DiagnosticLog from the build steps, but evaluation already separately reports the DiagnosticLog.
        var loggers = _msbuildLogger is not null
            ? [_msbuildLogger]
            : ImmutableArray<MSB.Framework.ILogger>.Empty;
 
        _batchBuildProjectCollection = new MSB.Evaluation.ProjectCollection(allProperties, loggers, MSB.Evaluation.ToolsetDefinitionLocations.Default);
 
        var buildParameters = new MSB.Execution.BuildParameters(_batchBuildProjectCollection)
        {
            // The loggers are not inherited from the project collection, so specify both the
            // binlog logger and the _batchBuildLogger for the build steps.
            Loggers = loggers.Add(_batchBuildLogger),
            // If we have an additional logger and it's diagnostic, then we need to opt into task inputs globally, or otherwise
            // it won't get any log events. This logic matches https://github.com/dotnet/msbuild/blob/fa6710d2720dcf1230a732a8858ffe71bcdbe110/src/Build/Instance/ProjectInstance.cs#L2365-L2371
            LogTaskInputs = _msbuildLogger is not null && _msbuildLogger.Verbosity == LoggerVerbosity.Diagnostic
        };
 
        MSB.Execution.BuildManager.DefaultBuildManager.BeginBuild(buildParameters);
 
        BatchBuildStarted = true;
    }
 
    public void EndBatchBuild()
    {
        if (!BatchBuildStarted)
        {
            throw new InvalidOperationException();
        }
 
        MSB.Execution.BuildManager.DefaultBuildManager.EndBuild();
 
        // unload project so collection will release global strings
        _batchBuildProjectCollection?.UnloadAllProjects();
        _batchBuildProjectCollection = null;
        _batchBuildLogger = null;
        BatchBuildStarted = false;
    }
 
    public Task<MSB.Execution.ProjectInstance> BuildProjectAsync(
        MSB.Evaluation.Project project, DiagnosticLog log, CancellationToken cancellationToken)
    {
        Debug.Assert(BatchBuildStarted);
 
        var requiredTargets = new[] { TargetNames.Compile, TargetNames.CoreCompile };
        var optionalTargets = new[] { TargetNames.DesignTimeMarkupCompilation };
 
        return BuildProjectAsync(project, requiredTargets, optionalTargets, log, cancellationToken);
    }
 
    private async Task<MSB.Execution.ProjectInstance> BuildProjectAsync(
        MSB.Evaluation.Project project, string[] requiredTargets, string[] optionalTargets, DiagnosticLog log, CancellationToken cancellationToken)
    {
        // create a project instance to be executed by build engine.
        // The executed project will hold the final model of the project after execution via msbuild.
        var projectInstance = project.CreateProjectInstance();
 
        // Verify targets
        foreach (var target in requiredTargets)
        {
            if (!projectInstance.Targets.ContainsKey(target))
            {
                log.Add(string.Format(WorkspaceMSBuildBuildHostResources.Project_does_not_contain_0_target, target), projectInstance.FullPath);
                return projectInstance;
            }
        }
 
        var targets = new List<string>(requiredTargets);
        foreach (var target in optionalTargets)
        {
            if (projectInstance.Targets.ContainsKey(target))
            {
                targets.Add(target);
            }
        }
 
        _batchBuildLogger?.SetProjectAndLog(projectInstance.FullPath, log);
 
        var buildRequestData = new MSB.Execution.BuildRequestData(projectInstance, [.. targets]);
 
        var result = await BuildAsync(buildRequestData, cancellationToken).ConfigureAwait(false);
 
        if (result.OverallResult == MSB.Execution.BuildResultCode.Failure)
        {
            if (result.Exception != null)
            {
                log.Add(result.Exception, projectInstance.FullPath);
            }
        }
 
        return projectInstance;
    }
 
    // this lock is static because we are using the default build manager, and there is only one per process
    private static readonly SemaphoreSlim s_buildManagerLock = new(initialCount: 1);
 
    private static async Task<MSB.Execution.BuildResult> BuildAsync(MSB.Execution.BuildRequestData requestData, CancellationToken cancellationToken)
    {
        // only allow one build to use the default build manager at a time
        using (await s_buildManagerLock.DisposableWaitAsync(cancellationToken).ConfigureAwait(false))
        {
            return await BuildAsync(MSB.Execution.BuildManager.DefaultBuildManager, requestData, cancellationToken).ConfigureAwait(false);
        }
    }
 
    private static Task<MSB.Execution.BuildResult> BuildAsync(MSB.Execution.BuildManager buildManager, MSB.Execution.BuildRequestData requestData, CancellationToken cancellationToken)
    {
        var taskSource = new TaskCompletionSource<MSB.Execution.BuildResult>();
 
        // enable cancellation of build
        CancellationTokenRegistration registration = default;
        if (cancellationToken.CanBeCanceled)
        {
            registration = cancellationToken.Register(() =>
            {
                // Note: We only ever expect that a single submission is being built,
                // even though we're calling CancelAllSubmissions(). If MSBuildWorkspace is
                // ever updated to support parallel builds, we'll likely need to update this code.
 
                taskSource.TrySetCanceled();
                buildManager.CancelAllSubmissions();
                registration.Dispose();
            });
        }
 
        // execute build async
        try
        {
            buildManager.PendBuildRequest(requestData).ExecuteAsync(sub =>
            {
                // when finished
                try
                {
                    var result = sub.BuildResult;
                    registration.Dispose();
                    taskSource.TrySetResult(result);
                }
                catch (Exception e)
                {
                    taskSource.TrySetException(e);
                }
            }, null);
        }
        catch (Exception e)
        {
            taskSource.SetException(e);
        }
 
        return taskSource.Task;
    }
}