File: DotnetProjectBuildCoordinator.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.
 
#pragma warning disable ASPIREDOTNETPROJECT001, ASPIREEXTENSION001, ASPIREPIPELINES001
 
using System.Globalization;
using System.Runtime.CompilerServices;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Pipelines;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Dotnet;
 
/// <summary>
/// Configures coordinated initial builds for path-based .NET resources.
/// </summary>
internal static class DotnetProjectBuildCoordinator
{
    private static readonly ConditionalWeakTable<IDistributedApplicationBuilder, CoordinatorState> s_states = new();
 
    internal const string BuildResourceName = "__dotnet-project-build";
    private const string DebugSessionPortConfigurationKey = "DEBUG_SESSION_PORT";
    private const string DebugSessionInfoConfigurationKey = "DEBUG_SESSION_INFO";
    private const string AspireStorePathConfigurationKey = "Aspire:Store:Path";
 
    public static CoordinatorState? Prepare(
        IDistributedApplicationBuilder builder,
        DotnetProjectMetadata projectMetadata)
    {
        if (!builder.ExecutionContext.IsRunMode || !ShouldCoordinateBuild(builder))
        {
            return null;
        }
 
        var state = s_states.GetValue(builder, static builder => new CoordinatorState(builder));
        if (IsSupportedPath(projectMetadata.ProjectPath))
        {
            projectMetadata.SuppressBuild = true;
            if (IsProjectFile(projectMetadata.ProjectPath))
            {
                projectMetadata.SetProjectPath(state.AddProject(projectMetadata.ProjectPath, projectMetadata.BuildConfiguration));
            }
            else
            {
                state.EnsureBuildResource(projectMetadata.BuildConfiguration);
            }
        }
 
        return state;
    }
 
    public static void Configure(
        IResourceBuilder<DotnetProjectResource> resourceBuilder,
        CoordinatorState? state)
    {
        if (state is null)
        {
            return;
        }
 
        state.AddResource(resourceBuilder.Resource);
 
        // Preserve the eagerly visible dependency used by model tests and tooling. BeforeStart replaces
        // the build plan after all resource environment callbacks and SDK roots are known, then adds the
        // final build barrier as an additional dependency.
        state.AddEagerBuildDependencies();
    }
 
    private static DotnetProjectBuildResource AddBuildResource(
        IDistributedApplicationBuilder builder,
        string name,
        string? configuration)
    {
        var aspireStoreRoot = builder.Configuration[AspireStorePathConfigurationKey];
        if (string.IsNullOrEmpty(aspireStoreRoot))
        {
            throw new InvalidOperationException(
                $"Could not determine the AppHost intermediate output path from '{AspireStorePathConfigurationKey}'.");
        }
 
        var buildDirectory = Path.Combine(aspireStoreRoot, ".aspire", "build");
        var buildResource = new DotnetProjectBuildResource(
            name,
            builder.AppHostDirectory,
            buildDirectory,
            TimeProvider.System);
        buildResource.SetBuildConfiguration(configuration);
        buildResource.Annotations.Add(NameValidationPolicyAnnotation.None);
 
        builder.AddResource(buildResource)
            .WithArgs(async context =>
            {
                var buildTargetPath = await buildResource.GetBuildTargetPathAsync(
                    context.Logger,
                    context.CancellationToken).ConfigureAwait(false);
 
                context.Args.Add("build");
                context.Args.Add(buildTargetPath);
 
                var buildConfiguration = buildResource.BuildConfiguration;
                if (!string.IsNullOrEmpty(buildConfiguration))
                {
                    context.Args.Add("--configuration");
                    context.Args.Add(buildConfiguration);
                }
            })
            .WithIconName("CodeCsRectangle")
            .ExcludeFromManifest()
            .WithHiddenOnCompletion(0);
 
        return buildResource;
    }
 
    private static Action? AddBuildDependency(
        IDistributedApplicationBuilder builder,
        DotnetProjectResource resource,
        DotnetProjectBuildResource buildResource)
    {
        if (resource.Annotations.OfType<WaitAnnotation>().Any(
            annotation => annotation.WaitType is WaitType.WaitForCompletion &&
                          ReferenceEquals(annotation.Resource, buildResource)))
        {
            return null;
        }
 
        var existingAnnotations = resource.Annotations.ToHashSet(ReferenceEqualityComparer.Instance);
        builder.CreateResourceBuilder(resource)
            .WaitForCompletion(builder.CreateResourceBuilder(buildResource));
        var addedAnnotations = resource.Annotations
            .Where(annotation => !existingAnnotations.Contains(annotation))
            .ToArray();
        var subscription = builder.Eventing.Subscribe<BeforeResourceStartedEvent>(
            resource,
            (@event, cancellationToken) =>
                WaitForSuccessfulBuildAsync(@event.Services, buildResource, cancellationToken));
 
        return () =>
        {
            builder.Eventing.Unsubscribe(subscription);
            foreach (var annotation in addedAnnotations)
            {
                resource.Annotations.Remove(annotation);
            }
        };
    }
 
    private static Action? AddBuildDependency(
        IDistributedApplicationBuilder builder,
        DotnetProjectBuildResource resource,
        DotnetProjectBuildResource dependency)
    {
        var existingAnnotations = resource.Annotations.ToHashSet(ReferenceEqualityComparer.Instance);
        builder.CreateResourceBuilder(resource)
            .WaitForCompletion(builder.CreateResourceBuilder(dependency));
        var addedAnnotations = resource.Annotations
            .Where(annotation => !existingAnnotations.Contains(annotation))
            .ToArray();
        var subscription = builder.Eventing.Subscribe<BeforeResourceStartedEvent>(
            resource,
            (@event, cancellationToken) =>
                WaitForSuccessfulBuildAsync(@event.Services, dependency, cancellationToken));
 
        return () =>
        {
            builder.Eventing.Unsubscribe(subscription);
            foreach (var annotation in addedAnnotations)
            {
                resource.Annotations.Remove(annotation);
            }
        };
    }
 
    private static async Task WaitForSuccessfulBuildAsync(
        IServiceProvider services,
        DotnetProjectBuildResource buildResource,
        CancellationToken cancellationToken)
    {
        var notificationService = services.GetRequiredService<ResourceNotificationService>();
        var buildEvent = await notificationService.WaitForResourceAsync(
            buildResource.Name,
            resourceEvent => IsSettledBuildSnapshot(resourceEvent.Snapshot),
            cancellationToken).ConfigureAwait(false);
 
        if (buildEvent.Snapshot.State?.Text == KnownResourceStates.FailedToStart ||
            buildEvent.Snapshot.ExitCode is not 0)
        {
            var exitCode = buildEvent.Snapshot.ExitCode?.ToString(CultureInfo.InvariantCulture) ?? "unavailable";
            throw new DistributedApplicationException(
                $"The coordinated .NET build failed with exit code {exitCode}. See resource '{buildResource.Name}' for build output.");
        }
    }
 
    internal static bool IsSettledBuildSnapshot(CustomResourceSnapshot snapshot) =>
        snapshot.State?.Text == KnownResourceStates.FailedToStart ||
        (KnownResourceStates.TerminalStates.Contains(snapshot.State?.Text) &&
         snapshot.ExitCode is not null);
 
    private static bool IsProjectFile(string path) =>
        path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase);
 
    private static bool IsSupportedPath(string path) =>
        IsProjectFile(path) || path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase);
 
    private static bool ShouldCoordinateBuild(IDistributedApplicationBuilder builder)
    {
        if (string.IsNullOrEmpty(builder.Configuration[DebugSessionPortConfigurationKey]))
        {
            return true;
        }
 
        return DebugSessionInfoParser.TryGetSupportedLaunchConfigurations(
                builder.Configuration[DebugSessionInfoConfigurationKey],
                out var supportedLaunchConfigurations)
            && supportedLaunchConfigurations?.Contains(
                KnownLaunchConfigurationTypes.ProjectWithExternalBuild) is true;
    }
 
    internal sealed class CoordinatorState : IDisposable
    {
        private readonly IDistributedApplicationBuilder _builder;
        private readonly List<ResourceRegistration> _registrations = [];
        private readonly List<DotnetProjectBuildResource> _ownedBuildResources = [];
        private readonly List<SharedBuildEnvironment> _sharedBuildEnvironments = [];
        private readonly Dictionary<DotnetProjectResource, Action> _eagerDependencyRollbacks =
            new(ReferenceEqualityComparer.Instance);
        private bool _materialized;
        private bool _disposed;
 
        public CoordinatorState(IDistributedApplicationBuilder builder)
        {
            _builder = builder;
            builder.Services.AddSingleton(_ => this);
            builder.Pipeline.WithFinalAction(
                WellKnownPipelineSteps.BeforeStart,
                stepContext => stepContext.Services
                    .GetRequiredService<CoordinatorState>()
                    .MaterializeBuildPlan(stepContext.Model, stepContext.Services));
        }
 
        public IReadOnlyList<DotnetProjectResource> Resources =>
            _registrations.Select(registration => registration.Resource).ToArray();
 
        public DotnetProjectBuildResource? PrimaryBuildResource { get; private set; }
 
        public string AddProject(string projectPath, string? configuration)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            PrimaryBuildResource ??= CreateBuildResource(configuration);
            return PrimaryBuildResource.AddProject(projectPath);
        }
 
        public void EnsureBuildResource(string? configuration)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            PrimaryBuildResource ??= CreateBuildResource(configuration);
        }
 
        public void AddResource(DotnetProjectResource resource)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            _registrations.Add(new ResourceRegistration(resource));
        }
 
        public Task WaitForBuildCompletionAsync(
            DotnetProjectResource resource,
            IServiceProvider services,
            CancellationToken cancellationToken)
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
 
            var registration = _registrations.Single(
                registration => ReferenceEquals(registration.Resource, resource));
            var finalBuildResource = registration.FinalBuildResource ?? throw new InvalidOperationException(
                $"The coordinated build plan for .NET project resource '{resource.Name}' has not been materialized.");
            return WaitForSuccessfulBuildAsync(services, finalBuildResource, cancellationToken);
        }
 
        public void AddEagerBuildDependencies()
        {
            if (PrimaryBuildResource is not { } primaryBuildResource)
            {
                return;
            }
 
            foreach (var registration in _registrations)
            {
                var resource = registration.Resource;
                if (_eagerDependencyRollbacks.ContainsKey(resource) ||
                    resource.Annotations.OfType<DotnetProjectMetadata>().SingleOrDefault() is not { } metadata ||
                    !IsSupportedPath(metadata.ProjectPath))
                {
                    continue;
                }
 
                if (AddBuildDependency(_builder, resource, primaryBuildResource) is { } rollback)
                {
                    _eagerDependencyRollbacks.Add(resource, rollback);
                }
            }
        }
 
        public void Dispose()
        {
            if (_disposed)
            {
                return;
            }
 
            _disposed = true;
            RemoveEagerBuildDependencies(_registrations.Select(registration => registration.Resource));
            foreach (var buildResource in _ownedBuildResources)
            {
                buildResource.Dispose();
            }
            foreach (var sharedBuildEnvironment in _sharedBuildEnvironments)
            {
                sharedBuildEnvironment.Dispose();
            }
        }
 
        internal Task MaterializeBuildPlan(
            DistributedApplicationModel model,
            IServiceProvider services)
        {
            if (_materialized)
            {
                return Task.CompletedTask;
            }
 
            var activeResources = model.Resources.ToHashSet(ReferenceEqualityComparer.Instance);
            var activeRegistrations = _registrations
                .Where(registration => activeResources.Contains(registration.Resource))
                .ToArray();
            var inactiveResources = _registrations
                .Where(registration => !activeResources.Contains(registration.Resource))
                .Select(registration => registration.Resource)
                .ToArray();
 
            var resourceEntries = activeRegistrations
                .Select(registration => new ProjectEntry(
                    registration,
                    registration.Resource.Annotations.OfType<DotnetProjectMetadata>().Single()))
                .ToArray();
            var unsupportedBuildEnvironmentEntry = resourceEntries
                .FirstOrDefault(entry =>
                    !IsProjectFile(entry.Metadata.ProjectPath) &&
                    GetBuildEnvironmentCallbacks(entry).Any());
            if (unsupportedBuildEnvironmentEntry is not null)
            {
                DotnetProjectHostingExtensions.ValidateBuildEnvironmentSupport(
                    unsupportedBuildEnvironmentEntry.Registration.Resource,
                    unsupportedBuildEnvironmentEntry.Metadata);
            }
 
            var buildEntries = resourceEntries
                .Where(entry => IsSupportedPath(entry.Metadata.ProjectPath))
                .ToArray();
            var missingBuildEntries = buildEntries
                .Where(entry => !File.Exists(entry.Metadata.ProjectPath))
                .ToArray();
            buildEntries = buildEntries
                .Where(entry => File.Exists(entry.Metadata.ProjectPath))
                .ToArray();
 
            if (buildEntries.Length == 0)
            {
                foreach (var missingEntry in missingBuildEntries)
                {
                    // Missing paths intentionally remain on the ordinary resource-start path so the
                    // resulting dotnet error names only that resource instead of failing the shared build.
                    ConfigureMissingPathFallback(missingEntry);
                }
 
                RemoveEagerBuildDependencies(_registrations.Select(registration => registration.Resource));
                if (PrimaryBuildResource is { } unusedBuildResource)
                {
                    _builder.Resources.Remove(unusedBuildResource);
                    _ownedBuildResources.Remove(unusedBuildResource);
                    unusedBuildResource.Dispose();
                    PrimaryBuildResource = null;
                }
 
                _materialized = true;
                return Task.CompletedTask;
            }
 
            var buildSteps = CreateBuildSteps(buildEntries);
            var applicationLifetime = services.GetRequiredService<IHostApplicationLifetime>();
            var primaryBuildResource = PrimaryBuildResource!;
            var originalPrimaryProjectPaths = primaryBuildResource.ProjectPaths;
            var originalPrimaryWorkingDirectory = primaryBuildResource.WorkingDirectory;
            var originalPrimaryBuildConfiguration = primaryBuildResource.BuildConfiguration;
            var rollbackActions = new Stack<Action>();
 
            try
            {
                rollbackActions.Push(() =>
                    primaryBuildResource.ConfigureTraversalBuild(
                        originalPrimaryProjectPaths,
                        originalPrimaryWorkingDirectory,
                        originalPrimaryBuildConfiguration));
 
                var buildResources = new List<DotnetProjectBuildResource>(buildSteps.Count);
                for (var index = 0; index < buildSteps.Count; index++)
                {
                    var step = buildSteps[index];
                    var buildResource = index == 0
                        ? primaryBuildResource
                        : CreateBuildResource(step.Configuration, index + 1);
 
                    if (index > 0)
                    {
                        rollbackActions.Push(() =>
                        {
                            _builder.Resources.Remove(buildResource);
                            _ownedBuildResources.Remove(buildResource);
                            buildResource.Dispose();
                        });
                    }
 
                    if (step.IsTraversal)
                    {
                        buildResource.ConfigureTraversalBuild(
                            step.Projects.Select(entry => entry.Metadata.ProjectPath),
                            step.WorkingDirectory,
                            step.Configuration);
                        rollbackActions.Push(ValidateMaterializedBuildCallbacks(buildResource, step.Projects));
                    }
                    else
                    {
                        var entry = step.Projects.Single();
                        buildResource.ConfigureDirectBuild(
                            entry.Metadata.ProjectPath,
                            step.WorkingDirectory,
                            step.Configuration);
 
                        // One coordinator-owned evaluation feeds the coordinated build, the rebuilder, and the IDE
                        // launch configuration, so build callbacks run once and every consumer observes identical values.
                        var sharedBuildEnvironment = new SharedBuildEnvironment(
                            entry,
                            services.GetRequiredService<ResourceNotificationService>(),
                            applicationLifetime.ApplicationStopping);
                        _sharedBuildEnvironments.Add(sharedBuildEnvironment);
                        rollbackActions.Push(() =>
                        {
                            _sharedBuildEnvironments.Remove(sharedBuildEnvironment);
                            sharedBuildEnvironment.Dispose();
                        });
                        rollbackActions.Push(BeginBuildEnvironmentAttemptOnResourceStart(
                            _builder,
                            buildResource,
                            sharedBuildEnvironment));
                        rollbackActions.Push(ValidateMaterializedBuildCallbacks(buildResource, step.Projects));
                        rollbackActions.Push(ApplyBuildEnvironment(buildResource, sharedBuildEnvironment));
                        rollbackActions.Push(ApplyBuildProperties(buildResource, sharedBuildEnvironment));
                        rollbackActions.Push(EnsureBuildEnvironmentBeforeResourceLaunch(
                            entry.Registration.Resource,
                            sharedBuildEnvironment));
                        if (FindRebuilder(model, entry.Registration.Resource) is { } rebuilder)
                        {
                            rollbackActions.Push(BeginBuildEnvironmentAttemptOnResourceStart(
                                _builder,
                                rebuilder,
                                sharedBuildEnvironment));
                            rollbackActions.Push(ValidateMaterializedBuildCallbacks(rebuilder, step.Projects));
                            rollbackActions.Push(ApplyBuildEnvironment(rebuilder, sharedBuildEnvironment));
                            rollbackActions.Push(ApplyBuildProperties(rebuilder, sharedBuildEnvironment));
                        }
                    }
 
                    foreach (var entry in step.Projects)
                    {
                        var originalBuildWorkingDirectory = entry.Metadata.BuildWorkingDirectory;
                        entry.Metadata.SetBuildWorkingDirectory(step.WorkingDirectory);
                        rollbackActions.Push(() =>
                            entry.Metadata.SetBuildWorkingDirectory(originalBuildWorkingDirectory));
                    }
 
                    buildResource.RegisterForShutdown(applicationLifetime);
                    buildResources.Add(buildResource);
                }
 
                for (var index = 1; index < buildResources.Count; index++)
                {
                    if (AddBuildDependency(_builder, buildResources[index], buildResources[index - 1]) is { } rollback)
                    {
                        rollbackActions.Push(rollback);
                    }
                }
 
                var finalBuildResource = buildResources[^1];
                foreach (var registration in activeRegistrations)
                {
                    var resource = registration.Resource;
                    if (resource.Annotations.OfType<DotnetProjectMetadata>().SingleOrDefault() is { } metadata &&
                        IsSupportedPath(metadata.ProjectPath) &&
                        File.Exists(metadata.ProjectPath))
                    {
                        var originalFinalBuildResource = registration.FinalBuildResource;
                        registration.FinalBuildResource = finalBuildResource;
                        rollbackActions.Push(() =>
                            registration.FinalBuildResource = originalFinalBuildResource);
 
                        if (AddBuildDependency(_builder, resource, finalBuildResource) is { } rollback)
                        {
                            rollbackActions.Push(rollback);
                        }
                    }
                }
 
                foreach (var missingEntry in missingBuildEntries)
                {
                    // Do this only after every throwing plan mutation has succeeded, so a failed materialization can
                    // be retried without leaving the missing resource on a partially changed launch path.
                    ConfigureMissingPathFallback(missingEntry);
                }
 
                RemoveEagerBuildDependencies(inactiveResources);
                RemoveEagerBuildDependencies(missingBuildEntries.Select(entry => entry.Registration.Resource));
                _materialized = true;
            }
            catch (Exception materializationException)
            {
                var rollbackExceptions = new List<Exception>();
                while (rollbackActions.TryPop(out var rollback))
                {
                    try
                    {
                        rollback();
                    }
                    catch (Exception rollbackException)
                    {
                        rollbackExceptions.Add(rollbackException);
                    }
                }
 
                if (rollbackExceptions.Count > 0)
                {
                    throw new AggregateException(
                        "Coordinated .NET project build-plan materialization failed and could not be fully rolled back.",
                        [materializationException, .. rollbackExceptions]);
                }
 
                throw;
            }
 
            return Task.CompletedTask;
        }
 
        private void RemoveEagerBuildDependencies(IEnumerable<DotnetProjectResource> resources)
        {
            foreach (var resource in resources)
            {
                if (_eagerDependencyRollbacks.Remove(resource, out var rollback))
                {
                    rollback();
                }
            }
        }
 
        private static List<BuildStep> CreateBuildSteps(IEnumerable<ProjectEntry> entries)
        {
            var entryList = entries.ToArray();
            var conflictingDuplicate = entryList
                .GroupBy(entry => entry.Metadata.ProjectPath, StringComparer.Ordinal)
                .FirstOrDefault(group => group.Count() > 1 && group.Any(RequiresContextSpecificBuild));
            if (conflictingDuplicate is not null)
            {
                var resourceNames = string.Join(
                    ", ",
                    conflictingDuplicate.Select(entry => $"'{entry.Registration.Resource.Name}'"));
                throw new DistributedApplicationException(
                    $"The .NET project '{conflictingDuplicate.Key}' is registered multiple times by resources {resourceNames}, " +
                    "and at least one registration has a project-specific build environment. The coordinated build cannot " +
                    "produce distinct outputs for the same project path.");
            }
 
            var steps = new List<BuildStep>();
            var traversalSteps = new Dictionary<BuildContext, BuildStep>();
 
            foreach (var entry in entryList)
            {
                // Resource.WorkingDirectory is the launched process's cwd and can be overridden independently.
                // SDK and global.json discovery must start at the project directory.
                var projectDirectory = Path.GetDirectoryName(entry.Metadata.ProjectPath)!;
                if (!IsProjectFile(entry.Metadata.ProjectPath))
                {
                    // File-based app artifacts are isolated, but their #:project references can share bin/obj
                    // directories. Direct build steps are serialized so those references never build concurrently.
                    steps.Add(BuildStep.CreateDirect(entry, projectDirectory));
                    continue;
                }
 
                if (RequiresContextSpecificBuild(entry))
                {
                    steps.Add(BuildStep.CreateDirect(entry, projectDirectory));
                    continue;
                }
 
                var globalJsonPath = FindNearestGlobalJson(projectDirectory);
                var context = new BuildContext(globalJsonPath, entry.Metadata.BuildConfiguration);
                if (!traversalSteps.TryGetValue(context, out var step))
                {
                    var workingDirectory = globalJsonPath is null
                        ? projectDirectory
                        : Path.GetDirectoryName(globalJsonPath)!;
                    step = BuildStep.CreateTraversal(entry, workingDirectory);
                    traversalSteps.Add(context, step);
                    steps.Add(step);
                }
                else
                {
                    step.Projects.Add(entry);
                }
            }
 
            return steps;
        }
 
        private static void ConfigureMissingPathFallback(ProjectEntry entry)
        {
            entry.Metadata.SuppressBuild = false;
            foreach (var annotation in entry.Registration.Resource.Annotations
                .OfType<SupportsDebuggingAnnotation>()
                .Where(annotation =>
                    annotation.LaunchConfigurationType == KnownLaunchConfigurationTypes.ProjectWithExternalBuild)
                .ToArray())
            {
                // The external-build debug annotation and launch-tool ownership were selected before file existence
                // was checked. Removing that capability makes the launch-tool callback emit the complete `dotnet run`
                // fallback instead of producing a legacy project configuration under the external-build capability.
                entry.Registration.Resource.Annotations.Remove(annotation);
            }
        }
 
        private DotnetProjectBuildResource CreateBuildResource(string? configuration, int? ordinal = null)
        {
            var name = ordinal is null
                ? BuildResourceName
                : $"{BuildResourceName}-{ordinal.Value.ToString(CultureInfo.InvariantCulture)}";
            var buildResource = AddBuildResource(
                _builder,
                name,
                configuration);
            _ownedBuildResources.Add(buildResource);
            return buildResource;
        }
 
        /// <summary>
        /// Copies the coordinated build environment onto a resource that performs the build (the coordinated build
        /// resource or the project rebuilder).
        /// </summary>
        private static Action ApplyBuildEnvironment(IResource target, SharedBuildEnvironment sharedBuildEnvironment)
        {
            var annotation = new EnvironmentCallbackAnnotation(sharedBuildEnvironment.ApplyBuildEnvironmentAsync);
            target.Annotations.Add(annotation);
            return () => target.Annotations.Remove(annotation);
        }
 
        private static Action EnsureBuildEnvironmentBeforeResourceLaunch(
            IResource target,
            SharedBuildEnvironment sharedBuildEnvironment)
        {
            // DCP resolves executable resources concurrently. This callback participates in the project resource's
            // own configuration path so launch metadata cannot be created before the coordinated build environment
            // is available, while deliberately leaving the runtime environment unchanged.
            var annotation = new EnvironmentCallbackAnnotation(sharedBuildEnvironment.EnsureEvaluatedAsync);
            target.Annotations.Add(annotation);
            return () => target.Annotations.Remove(annotation);
        }
 
        private static Action BeginBuildEnvironmentAttemptOnResourceStart(
            IDistributedApplicationBuilder builder,
            IResource target,
            SharedBuildEnvironment sharedBuildEnvironment)
        {
            var subscription = builder.Eventing.Subscribe<BeforeResourceStartedEvent>(
                target,
                (@event, cancellationToken) => sharedBuildEnvironment.BeginBuildAttemptAsync(
                    target,
                    @event.Services.GetRequiredService<ResourceLoggerService>().GetLogger(target),
                    cancellationToken));
            return () => builder.Eventing.Unsubscribe(subscription);
        }
 
        private static Action ApplyBuildProperties(IResource target, SharedBuildEnvironment sharedBuildEnvironment)
        {
            // Environment variables enter MSBuild as low-precedence properties. Also pass them as global properties
            // through a protected response file so the build, TargetPath query, and `dotnet run --no-build` agree when
            // the project assigns the same property without exposing its value in the process command line.
            var annotation = new CommandLineArgsCallbackAnnotation(sharedBuildEnvironment.ApplyBuildPropertiesAsync);
            target.Annotations.Add(annotation);
            return () =>
            {
                target.Annotations.Remove(annotation);
                sharedBuildEnvironment.ReleaseResponseFile(target);
            };
        }
 
        private static Action ValidateMaterializedBuildCallbacks(
            IResource target,
            IEnumerable<ProjectEntry> entries)
        {
            var snapshots = entries
                .Select(entry => new BuildCallbackSnapshot(
                    entry,
                    GetBuildEnvironmentCallbacks(entry).ToArray()))
                .ToArray();
            void Validate(EnvironmentCallbackContext _)
            {
                foreach (var snapshot in snapshots)
                {
                    var currentCallbacks = GetBuildEnvironmentCallbacks(snapshot.Entry);
                    if (!currentCallbacks.SequenceEqual(
                        snapshot.Callbacks,
                        ReferenceEqualityComparer.Instance))
                    {
                        throw new DistributedApplicationException(
                            $"The build environment of .NET project resource '{snapshot.Entry.Registration.Resource.Name}' " +
                            "changed after the coordinated build plan was materialized. Configure build-affecting " +
                            "environment variables with WithBuildEnvironment while constructing the AppHost or in a " +
                            "pipeline step required by BeforeStart; do not add or remove them after materialization.");
                    }
                }
            }
 
            var annotation = new EnvironmentCallbackAnnotation(Validate);
            target.Annotations.Add(annotation);
 
            return () => target.Annotations.Remove(annotation);
        }
 
        private static IResource? FindRebuilder(
            DistributedApplicationModel model,
            DotnetProjectResource resource) =>
            model.Resources
                .SingleOrDefault(candidate =>
                    candidate.Name == $"{resource.Name}-rebuilder" &&
                    candidate is IResourceWithParent<IResource> parent &&
                    ReferenceEquals(parent.Parent, resource));
 
        private static string? FindNearestGlobalJson(string workingDirectory)
        {
            var physicalWorkingDirectory = PathNormalizer.ResolveSymlinks(Path.GetFullPath(workingDirectory));
            for (var directory = new DirectoryInfo(physicalWorkingDirectory); directory is not null; directory = directory.Parent)
            {
                var globalJsonPath = Path.Combine(directory.FullName, "global.json");
                if (File.Exists(globalJsonPath))
                {
                    return PathNormalizer.ResolveToFilesystemPath(globalJsonPath);
                }
            }
 
            return null;
        }
 
        private static bool RequiresContextSpecificBuild(ProjectEntry entry)
        {
            return GetBuildEnvironmentCallbacks(entry).Any();
        }
 
        private static IEnumerable<DotnetProjectBuildEnvironmentCallbackAnnotation> GetBuildEnvironmentCallbacks(
            ProjectEntry entry) =>
            entry.Registration.Resource.Annotations.OfType<DotnetProjectBuildEnvironmentCallbackAnnotation>();
 
        private readonly record struct BuildContext(string? GlobalJsonPath, string? Configuration);
 
        /// <summary>
        /// Owns the single evaluation of a project's build-only environment callbacks.
        /// </summary>
        /// <remarks>
        /// The coordinated build resource, the project rebuilder, and the IDE launch configuration must all see the
        /// same build variables. Letting each consumer evaluate the callbacks would run them several times and allow
        /// whichever consumer evaluates first to decide the build inputs.
        /// </remarks>
        private sealed class SharedBuildEnvironment : IDisposable
        {
            private readonly ProjectEntry _entry;
            private readonly ResourceNotificationService _notificationService;
            private readonly CancellationToken _applicationStopping;
            private readonly object _lock = new();
            private readonly object _responseFilesLock = new();
            private readonly Dictionary<IResource, MsBuildResponseFile> _responseFiles =
                new(ReferenceEqualityComparer.Instance);
            private BuildEnvironmentGeneration _generation = new(precedingEvaluation: null);
            private Task _buildAttemptTail = Task.CompletedTask;
            private bool _disposed;
 
            public SharedBuildEnvironment(
                ProjectEntry entry,
                ResourceNotificationService notificationService,
                CancellationToken applicationStopping)
            {
                _entry = entry;
                _notificationService = notificationService;
                _applicationStopping = applicationStopping;
                Callbacks = GetBuildEnvironmentCallbacks(entry).ToArray();
            }
 
            public DotnetProjectResource Resource => _entry.Registration.Resource;
 
            /// <summary>
            /// Gets the project callbacks that contribute to the coordinated build, in registration order.
            /// </summary>
            /// <remarks>
            /// The build resource validates this materialized snapshot before evaluating it, so a later mutation cannot
            /// silently produce output that differs from the project launch environment.
            /// </remarks>
            public IReadOnlyList<DotnetProjectBuildEnvironmentCallbackAnnotation> Callbacks { get; }
 
            public async Task BeginBuildAttemptAsync(
                IResource resource,
                ILogger logger,
                CancellationToken cancellationToken)
            {
                var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
                Task precedingAttempt;
                lock (_lock)
                {
                    precedingAttempt = _buildAttemptTail;
                    _buildAttemptTail = completion.Task;
                }
 
                try
                {
                    await precedingAttempt.WaitAsync(cancellationToken).ConfigureAwait(false);
                    cancellationToken.ThrowIfCancellationRequested();
 
                    var initialSnapshot = _notificationService.TryGetCurrentState(
                        resource.Name,
                        out var resourceEvent)
                            ? resourceEvent.Snapshot
                            : null;
 
                    lock (_lock)
                    {
                        // Materialization creates a pending generation so eager project or IDE configuration can evaluate
                        // the same snapshot that the initial coordinated build will use. Later starts are rebuild attempts
                        // and must observe mutable callback inputs again.
                        if (!_generation.IsClaimedByBuildAttempt)
                        {
                            _generation.IsClaimedByBuildAttempt = true;
                        }
                        else
                        {
                            _generation = new BuildEnvironmentGeneration(
                                _generation.Evaluation?.Task ?? _generation.PrecedingEvaluation)
                            {
                                IsClaimedByBuildAttempt = true,
                            };
                        }
                    }
 
                    _ = CompleteBuildAttemptWhenSettledAsync(
                        resource,
                        initialSnapshot,
                        completion,
                        logger);
                }
                catch
                {
                    _ = CompleteSkippedBuildAttemptAsync(precedingAttempt, completion);
                    throw;
                }
            }
 
            /// <summary>
            /// Applies the complete coordinated build environment to the resource that runs the build.
            /// </summary>
            public async Task ApplyBuildEnvironmentAsync(EnvironmentCallbackContext context)
            {
                var evaluation = await EvaluateOnceAsync(
                    context.ExecutionContext,
                    context.Logger,
                    context.CancellationToken).ConfigureAwait(false);
                foreach (var (name, value) in evaluation)
                {
                    context.EnvironmentVariables[name] = value;
                }
            }
 
            public async Task EnsureEvaluatedAsync(EnvironmentCallbackContext context)
            {
                _ = await EvaluateOnceAsync(
                    context.ExecutionContext,
                    context.Logger,
                    context.CancellationToken).ConfigureAwait(false);
            }
 
            public async Task ApplyBuildPropertiesAsync(CommandLineArgsCallbackContext context)
            {
                var evaluation = await EvaluateOnceAsync(
                    context.ExecutionContext,
                    context.Logger,
                    context.CancellationToken).ConfigureAwait(false);
 
                var responseFile = await DotnetProjectBuildEnvironment.CreateResponseFileAsync(
                    evaluation,
                    context.Logger,
                    context.CancellationToken).ConfigureAwait(false);
                if (responseFile is null)
                {
                    return;
                }
 
                try
                {
                    context.CancellationToken.ThrowIfCancellationRequested();
                    var initialSnapshot = _notificationService.TryGetCurrentState(
                        context.Resource.Name,
                        out var resourceEvent)
                            ? resourceEvent.Snapshot
                            : null;
                    context.Args.Add(responseFile.Argument);
                    ReplaceResponseFile(context.Resource, responseFile);
                    _ = ReleaseResponseFileWhenSettledAsync(
                        context.Resource,
                        responseFile,
                        initialSnapshot,
                        context.Logger);
                    responseFile = null;
                }
                finally
                {
                    responseFile?.Dispose();
                }
            }
 
            public void ReleaseResponseFile(IResource resource)
            {
                MsBuildResponseFile? responseFile;
                lock (_responseFilesLock)
                {
                    _responseFiles.Remove(resource, out responseFile);
                }
 
                responseFile?.Dispose();
            }
 
            public void Dispose()
            {
                MsBuildResponseFile[] responseFiles;
                lock (_responseFilesLock)
                {
                    if (_disposed)
                    {
                        return;
                    }
 
                    _disposed = true;
                    responseFiles = [.. _responseFiles.Values];
                    _responseFiles.Clear();
                }
 
                foreach (var responseFile in responseFiles)
                {
                    responseFile.Dispose();
                }
            }
 
            private Task<IReadOnlyDictionary<string, string>> EvaluateOnceAsync(
                DistributedApplicationExecutionContext executionContext,
                ILogger logger,
                CancellationToken cancellationToken)
            {
                BuildEnvironmentGeneration generation;
                BuildEnvironmentEvaluation evaluation;
                lock (_lock)
                {
                    generation = _generation;
                    // A faulted or canceled callback evaluation is deliberately not retained so a later build or rebuild
                    // can retry a transient failure. Every attempt starts from a fresh dictionary, so a retry can never
                    // observe a half-applied environment.
                    if (generation.Evaluation is null ||
                        generation.Evaluation.IsAbandoned ||
                        generation.Evaluation.Task.IsFaulted ||
                        generation.Evaluation.Task.IsCanceled)
                    {
                        var precedingEvaluation =
                            generation.Evaluation?.Task ??
                            generation.PrecedingEvaluation;
                        var evaluationCancellationSource =
                            CancellationTokenSource.CreateLinkedTokenSource(_applicationStopping);
                        generation.Evaluation = new BuildEnvironmentEvaluation(
                            EvaluateAsync(
                                generation,
                                precedingEvaluation,
                                executionContext,
                                logger,
                                evaluationCancellationSource.Token),
                            evaluationCancellationSource);
                    }
 
                    evaluation = generation.Evaluation;
                    evaluation.ConsumerCount++;
                }
 
                return WaitForEvaluationAsync(evaluation, cancellationToken);
            }
 
            private async Task<IReadOnlyDictionary<string, string>> WaitForEvaluationAsync(
                BuildEnvironmentEvaluation evaluation,
                CancellationToken cancellationToken)
            {
                try
                {
                    return await evaluation.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
                }
                finally
                {
                    ReleaseEvaluationConsumer(evaluation);
                }
            }
 
            private void ReleaseEvaluationConsumer(BuildEnvironmentEvaluation evaluation)
            {
                var cancelAndDispose = false;
                var dispose = false;
                lock (_lock)
                {
                    evaluation.ConsumerCount--;
                    if (evaluation.ConsumerCount == 0 && !evaluation.CleanupScheduled)
                    {
                        evaluation.CleanupScheduled = true;
                        if (evaluation.Task.IsCompleted)
                        {
                            dispose = true;
                        }
                        else
                        {
                            // The evaluation remains shared while any build, rebuild, or launch consumer needs it.
                            // Once abandoned, cancel its callback token so a later generation is not permanently
                            // serialized behind work whose initiating operation no longer exists.
                            evaluation.IsAbandoned = true;
                            cancelAndDispose = true;
                        }
                    }
                }
 
                if (cancelAndDispose)
                {
                    _ = CancelAndDisposeWhenSettledAsync(evaluation);
                }
                else if (dispose)
                {
                    evaluation.CancellationSource.Dispose();
                }
            }
 
            private static async Task CancelAndDisposeWhenSettledAsync(BuildEnvironmentEvaluation evaluation)
            {
                try
                {
                    await evaluation.CancellationSource.CancelAsync()
                        .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                    await ((Task)evaluation.Task)
                        .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                }
                finally
                {
                    evaluation.CancellationSource.Dispose();
                }
            }
 
            private async Task<IReadOnlyDictionary<string, string>> EvaluateAsync(
                BuildEnvironmentGeneration generation,
                Task<IReadOnlyDictionary<string, string>>? precedingEvaluation,
                DistributedApplicationExecutionContext executionContext,
                ILogger logger,
                CancellationToken cancellationToken)
            {
                if (precedingEvaluation is not null)
                {
                    // Build attempts normally cannot overlap, but the rebuilder and coordinated-build resources are
                    // distinct DCP resources. Serialize their callbacks defensively because user callbacks need not be
                    // thread-safe, while allowing a failed prior generation to be retried with new inputs.
                    await ((Task)precedingEvaluation).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                }
 
                cancellationToken.ThrowIfCancellationRequested();
 
                var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
                var environment = new Dictionary<string, object>(comparer);
                foreach (var callback in Callbacks)
                {
                    var callbackContext = new EnvironmentCallbackContext(
                        executionContext,
                        Resource,
                        environment,
                        cancellationToken)
                    {
                        Logger = logger,
                    };
                    await callback.Callback(callbackContext).ConfigureAwait(false);
                }
 
                var resolvedEnvironment = new Dictionary<string, string>(environment.Count, comparer);
                foreach (var (name, value) in environment)
                {
                    if (value is not string stringValue)
                    {
                        throw new DistributedApplicationException(
                            $"The build environment variable '{name}' for .NET project resource '{Resource.Name}' " +
                            $"has unsupported value type '{value?.GetType().Name ?? "null"}'. Build environment values must be strings.");
                    }
 
                    resolvedEnvironment[name] = stringValue;
                }
 
                lock (_lock)
                {
                    // An older evaluation can finish after a new build attempt has started. Do not let its snapshot
                    // replace the metadata used by run-property resolution and IDE launch configuration.
                    if (ReferenceEquals(_generation, generation))
                    {
                        _entry.Metadata.SetBuildEnvironment(resolvedEnvironment);
                    }
                }
 
                return resolvedEnvironment;
            }
 
            private async Task ReleaseResponseFileWhenSettledAsync(
                IResource resource,
                MsBuildResponseFile responseFile,
                CustomResourceSnapshot? initialSnapshot,
                ILogger logger)
            {
                try
                {
                    await _notificationService.WaitForResourceAsync(
                        resource.Name,
                        resourceEvent =>
                            !ReferenceEquals(resourceEvent.Snapshot, initialSnapshot) &&
                            IsSettledBuildSnapshot(resourceEvent.Snapshot),
                        _applicationStopping).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (_applicationStopping.IsCancellationRequested)
                {
                    // Application shutdown disposes the response file in the finally block.
                }
                catch (Exception ex)
                {
                    logger.LogDebug(
                        ex,
                        "Failed to monitor .NET build resource '{ResourceName}' for MSBuild response-file cleanup.",
                        resource.Name);
                }
                finally
                {
                    ReleaseResponseFile(resource, responseFile);
                }
            }
 
            private async Task CompleteBuildAttemptWhenSettledAsync(
                IResource resource,
                CustomResourceSnapshot? initialSnapshot,
                TaskCompletionSource completion,
                ILogger logger)
            {
                try
                {
                    await _notificationService.WaitForResourceAsync(
                        resource.Name,
                        resourceEvent =>
                            !ReferenceEquals(resourceEvent.Snapshot, initialSnapshot) &&
                            (resourceEvent.Snapshot.State?.Text == KnownResourceStates.FailedToStart ||
                             KnownResourceStates.TerminalStates.Contains(resourceEvent.Snapshot.State?.Text)),
                        _applicationStopping).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (_applicationStopping.IsCancellationRequested)
                {
                    // Application shutdown ends every queued build attempt.
                }
                catch (Exception ex)
                {
                    logger.LogError(
                        ex,
                        "Failed to monitor .NET build resource '{ResourceName}' for build-attempt serialization. " +
                        "Further build attempts will remain blocked to avoid concurrent writes.",
                        resource.Name);
                    await Task.Delay(Timeout.InfiniteTimeSpan, _applicationStopping)
                        .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                }
                finally
                {
                    completion.TrySetResult();
                }
            }
 
            private static async Task CompleteSkippedBuildAttemptAsync(
                Task precedingAttempt,
                TaskCompletionSource completion)
            {
                await precedingAttempt.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
                completion.TrySetResult();
            }
 
            private void ReleaseResponseFile(IResource resource, MsBuildResponseFile expectedResponseFile)
            {
                MsBuildResponseFile? responseFile = null;
                lock (_responseFilesLock)
                {
                    if (_responseFiles.TryGetValue(resource, out var currentResponseFile) &&
                        ReferenceEquals(currentResponseFile, expectedResponseFile))
                    {
                        _responseFiles.Remove(resource);
                        responseFile = currentResponseFile;
                    }
                }
 
                responseFile?.Dispose();
            }
 
            private void ReplaceResponseFile(IResource resource, MsBuildResponseFile responseFile)
            {
                MsBuildResponseFile? previousResponseFile;
                lock (_responseFilesLock)
                {
                    ObjectDisposedException.ThrowIf(_disposed, this);
                    _responseFiles.Remove(resource, out previousResponseFile);
                    _responseFiles.Add(resource, responseFile);
                }
 
                previousResponseFile?.Dispose();
            }
 
        }
 
        private sealed record ProjectEntry(
            ResourceRegistration Registration,
            DotnetProjectMetadata Metadata);
 
        private sealed record BuildCallbackSnapshot(
            ProjectEntry Entry,
            DotnetProjectBuildEnvironmentCallbackAnnotation[] Callbacks);
 
        private sealed class ResourceRegistration(DotnetProjectResource resource)
        {
            public DotnetProjectResource Resource { get; } = resource;
 
            public DotnetProjectBuildResource? FinalBuildResource { get; set; }
        }
 
        private sealed class BuildStep
        {
            private BuildStep(
                bool isTraversal,
                string workingDirectory,
                string? configuration,
                List<ProjectEntry> projects)
            {
                IsTraversal = isTraversal;
                WorkingDirectory = workingDirectory;
                Configuration = configuration;
                Projects = projects;
            }
 
            public bool IsTraversal { get; }
 
            public string WorkingDirectory { get; }
 
            public string? Configuration { get; }
 
            public List<ProjectEntry> Projects { get; }
 
            public static BuildStep CreateTraversal(ProjectEntry entry, string workingDirectory) =>
                new(
                    isTraversal: true,
                    workingDirectory,
                    entry.Metadata.BuildConfiguration,
                    [entry]);
 
            public static BuildStep CreateDirect(ProjectEntry entry, string workingDirectory) =>
                new(
                    isTraversal: false,
                    workingDirectory,
                    entry.Metadata.BuildConfiguration,
                    [entry]);
        }
 
        private sealed class BuildEnvironmentGeneration(
            Task<IReadOnlyDictionary<string, string>>? precedingEvaluation)
        {
            public Task<IReadOnlyDictionary<string, string>>? PrecedingEvaluation { get; } = precedingEvaluation;
 
            public BuildEnvironmentEvaluation? Evaluation { get; set; }
 
            public bool IsClaimedByBuildAttempt { get; set; }
        }
 
        private sealed class BuildEnvironmentEvaluation(
            Task<IReadOnlyDictionary<string, string>> task,
            CancellationTokenSource cancellationSource)
        {
            public Task<IReadOnlyDictionary<string, string>> Task { get; } = task;
 
            public CancellationTokenSource CancellationSource { get; } = cancellationSource;
 
            public int ConsumerCount { get; set; }
 
            public bool IsAbandoned { get; set; }
 
            public bool CleanupScheduled { get; set; }
        }
    }
}