File: MSBuildSession.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using Microsoft.Build.Evaluation;
using Microsoft.Build.Execution;
using Microsoft.Build.Framework;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;

namespace Microsoft.DotNet.Cli;

/// <summary>
/// Runs every MSBuild target a single CLI command needs to invoke on top of the build - for example
/// <c>Restore</c>, <c>ComputeAvailableDevices</c>, <c>DeployToDevice</c> and <c>ComputeRunArguments</c> -
/// inside one MSBuild build session, so the binary log behind <c>-bl</c> holds a single well formed build.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ProjectInstance.Build(string[], IEnumerable{ILogger})"/> runs a complete MSBuild build
/// (begin/request/end) on every call. Each of those builds creates its own logging service, so the
/// <see cref="BuildEventContext"/> ids it hands out - project context ids, target ids, task ids -
/// restart from zero, and every build emits its own build-started/build-finished pair.
/// Attaching the same binary logger to several of those builds, which is what <c>dotnet test -bl</c>
/// does when a run contains more than one test project or performs device selection, produces a binlog
/// holding several builds with colliding ids: readers attribute every project's targets to whichever
/// project claimed the id first, and the other projects appear to have executed no targets at all.
/// The underlying engine limitation is tracked by
/// <see href="https://github.com/dotnet/msbuild/issues/14609"/>.
/// </para>
/// <para>
/// Sending all the requests to a single <see cref="BuildManager"/> session instead yields one build
/// with unique ids, so the binlog is well formed and shows the targets executed for each project.
/// MSBuild requires every project instance built in one session to come from the same
/// <see cref="Evaluation.ProjectCollection"/>, so the session owns the collection every project of the
/// command has to be evaluated in - see <see cref="ProjectCollection"/>.
/// </para>
/// <para>
/// The session is started lazily and owns a dedicated <see cref="BuildManager"/> rather than using
/// <see cref="BuildManager.DefaultBuildManager"/>, so it can stay open across the in-process MSBuild
/// invocations the CLI makes to build or restore the projects it is working on (which go through the
/// default build manager and write their own binary log).
/// </para>
/// </remarks>
internal sealed class MSBuildSession : IDisposable
{
    private readonly MSBuildArgs _msbuildArgs;
    private readonly FacadeLogger? _logger;
    private readonly Lock _lock = new();

    private BuildManager? _buildManager;
    private bool _disposed;

    public MSBuildSession(MSBuildArgs msbuildArgs, FacadeLogger? logger)
    {
        _msbuildArgs = msbuildArgs;
        _logger = logger;
        using var _ = MSBuildForwardingAppWithoutLogging.SetMSBuildRequiredEnvironmentVariables();

        // Deliberately without global properties: MSBuild merges the global properties of a collection
        // into every project loaded from it, and the projects of a command do not all want the same ones
        // (restore, for instance, must run without the TargetFramework the rest of the command uses -
        // see https://github.com/dotnet/sdk/issues/53488). Every project passes its own instead.
        ProjectCollection = new ProjectCollection(
            globalProperties: null,
            loggers: logger is null ? null : [logger],
            toolsetDefinitionLocations: ToolsetDefinitionLocations.Default);
    }

    /// <summary>
    /// The collection every project built in this session must be evaluated in. MSBuild rejects a build
    /// whose submissions mix project instances coming from different collections ("All build submissions
    /// in a build must use project instances originating from the same project collection.").
    /// </summary>
    /// <remarks>
    /// The collection carries no global properties of its own: each project passes the ones it needs -
    /// its target framework, device or runtime identifier - when it is evaluated.
    /// </remarks>
    public ProjectCollection ProjectCollection { get; }

    /// <summary>
    /// Builds the given targets of an already evaluated project in the shared build session.
    /// The results are applied to <paramref name="project"/> itself, so properties produced by the
    /// targets can be read from it afterwards.
    /// </summary>
    public bool Build(ProjectInstance project, string[] targets)
        => Build(project, targets, out _);

    /// <inheritdoc cref="Build(ProjectInstance, string[])"/>
    /// <param name="targetOutputs">The outputs of the requested targets.</param>
    [UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")]
    public bool Build(ProjectInstance project, string[] targets, out IDictionary<string, TargetResult> targetOutputs)
    {
        // The MSBuild build APIs cannot be called in parallel, even for different projects:
        // BuildManager throws "The operation cannot be completed because a build is already in progress."
        // Every project of the command shares this session, so the requests are serialized here.
        lock (_lock)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            using var _ = MSBuildForwardingAppWithoutLogging.SetMSBuildRequiredEnvironmentVariables();

            // Every ProjectInstance.Build call used to be its own build, which meant a fresh results
            // cache each time. Within a single session MSBuild would instead serve a second request
            // for the same project from the results cached by the first one, so targets shared by
            // DeployToDevice and ComputeRunArguments would silently stop re-running.
            // ReplaceExistingProjectInstance re-points the configuration at this instance and clears
            // its cached results, which keeps the previous per-request behavior.
            var requestData = new BuildRequestData(
                project,
                targets,
                hostServices: null,
                BuildRequestDataFlags.ReplaceExistingProjectInstance);

            BuildResult result = GetOrStartBuildManager().BuildRequest(requestData);
            targetOutputs = result.ResultsByTarget;
            return result.OverallResult == BuildResultCode.Success;
        }
    }

    /// <summary>
    /// Ends the session, surfacing any failure MSBuild captured during it.
    /// </summary>
    /// <remarks>
    /// Call this on the success path. Unlike <see cref="ProjectInstance.Build(string[], IEnumerable{ILogger})"/>,
    /// which drains the engine's captured thread exception into the <see cref="BuildResult"/> it
    /// returns, the public <see cref="BuildManager.BuildRequest"/> leaves it in place and only
    /// <see cref="BuildManager.EndBuild"/> rethrows it. A failure that lands between requests or
    /// while the logging service is flushing - a binary logger failing to write, for example - is
    /// therefore reported here and nowhere else, so it must not be swallowed when the run otherwise
    /// succeeded.
    /// </remarks>
    [UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")]
    public void Complete()
    {
        lock (_lock)
        {
            if (TakeBuildManager() is not { } buildManager)
            {
                return;
            }

            try
            {
                buildManager.EndBuild();
            }
            finally
            {
                buildManager.Dispose();
                ProjectCollection.Dispose();
            }
        }
    }

    /// <summary>
    /// Ends the session without reporting failures, for the case where the run is already failing.
    /// </summary>
    public void Dispose()
    {
        lock (_lock)
        {
            if (TakeBuildManager() is not { } buildManager)
            {
                return;
            }

            try
            {
                // Reaching Dispose without Complete means an exception is unwinding through the
                // enclosing 'using'. EndBuild rethrows whatever the engine captured, which would
                // replace that exception - typically the GracefulException naming the project that
                // failed - with a raw MSBuild stack trace, so it is only logged here.
                buildManager.EndBuild();
            }
            catch (Exception ex)
            {
                Reporter.Verbose.WriteLine($"Failed to end the MSBuild session: {ex}");
            }
            finally
            {
                buildManager.Dispose();
                ProjectCollection.Dispose();
            }
        }
    }

    /// <summary>
    /// Marks the session finished and hands back the build manager to close, or <see langword="null"/>
    /// if it has already been closed. The collection is released even when the session was never
    /// started, because the projects of the command are evaluated in it whether or not a target ran.
    /// </summary>
    private BuildManager? TakeBuildManager()
    {
        if (_disposed)
        {
            return null;
        }

        _disposed = true;

        BuildManager? buildManager = _buildManager;
        _buildManager = null;

        if (buildManager is null)
        {
            ProjectCollection.Dispose();
        }

        return buildManager;
    }

    [UnconditionalSuppressMessage("AOT", "IL2026", Justification = "Temporary unblock for dotnet/msbuild#14064 (MSBuild build APIs are now [RequiresUnreferencedCode]). dotnet CLI runs MSBuild in-proc (not trimmed). Remove when dotnet/sdk#55225 is fixed.")]
    private BuildManager GetOrStartBuildManager()
    {
        if (_buildManager is { } existing)
        {
            return existing;
        }

        // A dedicated build manager is used instead of BuildManager.DefaultBuildManager so that keeping
        // this session open never conflicts with the in-process MSBuild invocations the CLI makes to
        // build or restore the projects it is working on.
        var buildManager = new BuildManager("dotnet-cli-session");
        try
        {
            var parameters = new BuildParameters(ProjectCollection)
            {
                Loggers = CreateLoggers(),
                // ProjectInstance.Build defaults to a single in-process node, keep that behavior.
                MaxNodeCount = 1,
                EnableNodeReuse = false,
            };

            if (_logger is not null)
            {
                // The facade logger forwards to a binary logger, which wants the full event stream.
                parameters.LogTaskInputs = true;
                parameters.EnableTargetOutputLogging = true;
            }

            buildManager.BeginBuild(parameters);
        }
        catch
        {
            buildManager.Dispose();
            throw;
        }

        _buildManager = buildManager;
        return buildManager;
    }

    /// <summary>
    /// Gets the loggers to attach to the session.
    /// </summary>
    /// <remarks>
    /// A console logger is attached (unless <c>-noConsoleLogger</c> was passed) so that MSBuild errors are
    /// actually reported to the user: the binary logger only forwards events to binlogs, and it is only
    /// created when <c>-bl</c> was passed, so without a console logger these builds fail silently and the user
    /// is only told to "fix the errors and warnings" without any error being printed anywhere.
    /// This mirrors what <c>dotnet run</c> does in <c>RunCommand.InvokeRunArgumentsTarget</c>.
    /// The loggers are created once per session rather than once per build, because a session
    /// initializes and shuts down its loggers exactly once.
    /// </remarks>
    private List<ILogger> CreateLoggers()
    {
        var loggers = new List<ILogger>(capacity: 2);

        if (_logger is not null)
        {
            loggers.Add(_logger);
        }

        if (!LoggerUtility.HasNoConsoleLoggerArgument(_msbuildArgs.OtherMSBuildArgs))
        {
            // These builds only select devices, deploy and compute run arguments, so keep them quiet -
            // at this verbosity MSBuild still reports errors and warnings.
            loggers.Add(CommonRunHelpers.GetConsoleLogger(
                _msbuildArgs.CloneWithExplicitArgs([$"--verbosity:{LoggerVerbosity.Quiet.ToString().ToLowerInvariant()}", .. _msbuildArgs.OtherMSBuildArgs])));
        }

        return loggers;
    }
}