File: Lifecycle\DotnetBuildCommandEventingSubscriber.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.ExceptionServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Eventing;
using Aspire.Hosting.Utils;
 
namespace Aspire.Hosting.Lifecycle;
 
internal sealed class DotnetBuildCommandEventingSubscriber(
    IDotnetSdkVersionProvider versionProvider) : IDistributedApplicationEventingSubscriber
{
    // Aspire.Hosting.Dotnet intentionally consumes only the public Aspire.Hosting surface. Its coordinated build
    // resources therefore use reserved internal names that core hosting can recognize without internals visibility.
    private const string CoordinatedBuildResourceName = "__dotnet-project-build";
 
    public Task SubscribeAsync(
        IDistributedApplicationEventing eventing,
        DistributedApplicationExecutionContext executionContext,
        CancellationToken cancellationToken)
    {
        eventing.Subscribe<BeforeResourceStartedEvent>(ConfigureMultiThreadedBuildAsync);
        return Task.CompletedTask;
    }
 
    private Task ConfigureMultiThreadedBuildAsync(
        BeforeResourceStartedEvent @event,
        CancellationToken cancellationToken)
    {
        if (!IsAspireManagedDotnetBuild(@event.Resource) ||
            @event.Resource.TryGetLastAnnotation<MultiThreadedBuildConfiguredAnnotation>(out _))
        {
            return Task.CompletedTask;
        }
 
        var executable = (ExecutableResource)@event.Resource;
        lock (executable.Annotations)
        {
            if (executable.TryGetLastAnnotation<MultiThreadedBuildConfiguredAnnotation>(out _))
            {
                return Task.CompletedTask;
            }
 
            // DCP clears cached argument callback results before recreating a resource. Resolve SDK support inside
            // the callback so changes to global.json are observed when arguments are reevaluated for the next start.
            executable.Annotations.Add(new CommandLineArgsCallbackAnnotation(async context =>
            {
                var args = context.Args;
                if (args.Count < 2 ||
                    args[1] is not string buildTarget)
                {
                    return;
                }
 
                // DCP gathers arguments before environment variables. Resolve only the environment callbacks here so
                // the SDK probe sees the same PATH and host overrides as the build process. The later environment
                // gatherer reuses the cached annotation results instead of invoking those callbacks a second time.
                var executionConfiguration = await ExecutionConfigurationBuilder.Create(executable)
                    .WithEnvironmentVariablesConfig()
                    .BuildAsync(
                        context.ExecutionContext,
                        context.Logger,
                        context.CancellationToken)
                    .ConfigureAwait(false);
                if (executionConfiguration.Exception is { } exception)
                {
                    ExceptionDispatchInfo.Throw(exception);
                }
 
                var comparer = OperatingSystem.IsWindows()
                    ? StringComparer.OrdinalIgnoreCase
                    : StringComparer.Ordinal;
                var buildEnvironment = new Dictionary<string, string>(comparer);
                foreach (var (name, value) in executionConfiguration.EnvironmentVariables)
                {
                    buildEnvironment[name] = value;
                }
 
                // File-based apps need a later SDK because .NET 11 RC1 lacks both safe -mt forwarding and the
                // compiler-client mutex mitigation. DotnetSdkUtils owns the verified compatibility boundaries.
                var supportsMultiThreadedBuild = buildTarget.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)
                    ? await versionProvider.SupportsFileBasedMultiThreadedBuildAsync(
                        executable.WorkingDirectory,
                        buildEnvironment,
                        context.CancellationToken).ConfigureAwait(false)
                    : await versionProvider.SupportsMultiThreadedBuildAsync(
                        executable.WorkingDirectory,
                        buildEnvironment,
                        context.CancellationToken).ConfigureAwait(false);
                if (!supportsMultiThreadedBuild)
                {
                    return;
                }
 
                args.Insert(Math.Min(2, args.Count), "-mt");
            }));
            executable.Annotations.Add(MultiThreadedBuildConfiguredAnnotation.Instance);
        }
 
        return Task.CompletedTask;
    }
 
    private static bool IsAspireManagedDotnetBuild(IResource resource)
    {
        if (resource is not ExecutableResource { Command: "dotnet" })
        {
            return false;
        }
 
        return resource is ProjectRebuilderResource ||
            resource.Name == CoordinatedBuildResourceName ||
            resource.Name.StartsWith($"{CoordinatedBuildResourceName}-", StringComparison.Ordinal);
    }
 
    private sealed class MultiThreadedBuildConfiguredAnnotation : IResourceAnnotation
    {
        public static MultiThreadedBuildConfiguredAnnotation Instance { get; } = new();
    }
}