File: HotReload\CompilationHandler.cs
Web Access
Project: src\src\sdk\src\Dotnet.Watch\Watch\Microsoft.DotNet.HotReload.Watch.csproj (Microsoft.DotNet.HotReload.Watch)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Build.Execution;
using Microsoft.Build.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class CompilationHandler : IDisposable
{
    public readonly HotReloadMSBuildWorkspace Workspace;
    private readonly DotNetWatchContext _context;
    private readonly HotReloadService _hotReloadService;

    /// <summary>
    /// Lock to synchronize:
    /// <see cref="_runningProjects"/>
    /// <see cref="_activeProjectRelaunchOperations"/>
    /// <see cref="_previousUpdates"/>
    /// </summary>
    private readonly object _runningProjectsAndUpdatesGuard = new();

    /// <summary>
    /// Projects that have been launched and to which we apply changes.
    /// Maps <see cref="ProjectInstance.FullPath"/> to the list of running instances of that project.
    /// </summary>
    private ImmutableDictionary<string, ImmutableArray<RunningProject>> _runningProjects
        = ImmutableDictionary<string, ImmutableArray<RunningProject>>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer);

    /// <summary>
    /// Maps <see cref="ProjectInstance.FullPath"/> to the list of active restart operations for the project.
    /// The <see cref="RestartOperation"/> of the project instance is added whenever a process crashes (terminated with non-zero exit code)
    /// and the corresponding <see cref="RunningProject"/> is removed from <see cref="_runningProjects"/>.
    ///
    /// When a file change is observed whose containing project is listed here, the associated relaunch operations are executed.
    /// </summary>
    private ImmutableDictionary<string, ImmutableArray<RestartOperation>> _activeProjectRelaunchOperations
        = ImmutableDictionary<string, ImmutableArray<RestartOperation>>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer);

    /// <summary>
    /// All updates that were attempted. Includes updates whose application failed.
    /// </summary>
    private ImmutableList<HotReloadService.Update> _previousUpdates = [];

    private bool _isDisposed;
    private int _solutionUpdateId;

    /// <summary>
    /// Current set of project instances indexed by <see cref="ProjectInstance.FullPath"/>.
    /// Updated whenever the project graph changes.
    /// </summary>
    private ImmutableDictionary<string, ImmutableArray<ProjectInstance>> _projectInstances
        = ImmutableDictionary<string, ImmutableArray<ProjectInstance>>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer);

    public CompilationHandler(DotNetWatchContext context)
    {
        _context = context;
        Workspace = new HotReloadMSBuildWorkspace(context.Logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null));
        _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities()));
    }

    public void Dispose()
    {
        _isDisposed = true;
        Workspace?.Dispose();
    }

    public ILogger Logger
        => _context.Logger;

    public async ValueTask TerminatePeripheralProcessesAndDispose(CancellationToken cancellationToken)
    {
        Logger.LogDebug("Terminating remaining child processes.");
        await TerminatePeripheralProcessesAsync(projectPaths: null, cancellationToken);
        Dispose();
    }

    private void DiscardPreviousUpdates(ImmutableArray<ProjectId> projectsToBeRebuilt)
    {
        // Remove previous updates to all modules that were affected by rude edits.
        // All running projects that statically reference these modules have been terminated.
        // If we missed any project that dynamically references one of these modules its rebuild will fail.
        // At this point there is thus no process that these modules loaded and any process created in future
        // that will load their rebuilt versions.

        lock (_runningProjectsAndUpdatesGuard)
        {
            _previousUpdates = _previousUpdates.RemoveAll(update => projectsToBeRebuilt.Contains(update.ProjectId));
        }
    }

    public async ValueTask StartSessionAsync(ProjectGraph graph, CancellationToken cancellationToken)
    {
        var solution = await UpdateProjectGraphAsync(graph, cancellationToken);

        await _hotReloadService.StartSessionAsync(solution, cancellationToken);

        // TODO: StartSessionAsync should do this: https://github.com/dotnet/roslyn/issues/80687
        foreach (var project in solution.Projects)
        {
            foreach (var document in project.AdditionalDocuments)
            {
                await document.GetTextAsync(cancellationToken);
            }

            foreach (var document in project.AnalyzerConfigDocuments)
            {
                await document.GetTextAsync(cancellationToken);
            }
        }

        Logger.Log(MessageDescriptor.HotReloadSessionStarted);
    }

    public async Task<RunningProject?> TrackRunningProjectAsync(
        ProjectGraphNode projectNode,
        ProjectOptions projectOptions,
        HotReloadClients clients,
        ILogger clientLogger,
        ProcessSpec processSpec,
        RestartOperation restartOperation,
        CancellationToken cancellationToken)
    {
        var processExitedSource = new CancellationTokenSource();
        var processTerminationSource = new CancellationTokenSource();

        // Cancel process communication as soon as process termination is requested, shutdown is requested, or the process exits (whichever comes first).
        // If we only cancel after we process exit event handler is triggered the pipe might have already been closed and may fail unexpectedly.
        using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processTerminationSource.Token, processExitedSource.Token, cancellationToken);
        var processCommunicationCancellationToken = processCommunicationCancellationSource.Token;

        // Dispose these objects on failure:
        await using var disposables = new Disposables([clients, processExitedSource, processTerminationSource]);

        // It is important to first create the named pipe connection (Hot Reload client is the named pipe server)
        // and then start the process (named pipe client). Otherwise, the connection would fail.
        clients.InitiateConnection(processCommunicationCancellationToken);

        RunningProject? publishedRunningProject = null;

        var previousOnExit = processSpec.OnExit;
        processSpec.OnExit = async (processId, exitCode) =>
        {
            // Await the previous action so that we only clean up after all requested "on exit" actions have been completed.
            if (previousOnExit != null)
            {
                await previousOnExit(processId, exitCode);
            }

            if (publishedRunningProject != null)
            {
                var relaunch =
                    !cancellationToken.IsCancellationRequested &&
                    !publishedRunningProject.Options.IsMainProject &&
                    exitCode.HasValue &&
                    exitCode.Value != 0;

                // Remove the running project if it has been published to _runningProjects (if it hasn't exited during initialization):
                if (RemoveRunningProject(publishedRunningProject, relaunch))
                {
                    await publishedRunningProject.DisposeAsync(isExiting: true);
                }
            }
        };

        var launchResult = new ProcessLaunchResult();
        var processTask = _context.ProcessRunner.RunAsync(processSpec, clientLogger, launchResult, processTerminationSource.Token);
        if (launchResult.ProcessId == null)
        {
            // process failed to start:
            Debug.Assert(processTask.IsCompleted && processTask.Result == int.MinValue);

            // error already reported
            return null;
        }

        var runningProcess = new RunningProcess(launchResult.ProcessId.Value, processTask, processExitedSource, processTerminationSource);

        // transfer ownership to the running process:
        disposables.Items.Remove(processExitedSource);
        disposables.Items.Remove(processTerminationSource);
        disposables.Items.Add(runningProcess);

        var projectPath = projectNode.ProjectInstance.FullPath;

        try
        {
            // Wait for agent to create the name pipe and send capabilities over.
            // the agent blocks the app execution until initial updates are applied (if any).
            var managedCodeUpdateCapabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken);

            var runningProject = new RunningProject(
                projectNode,
                projectOptions,
                clients,
                clientLogger,
                runningProcess,
                restartOperation,
                managedCodeUpdateCapabilities);

            // transfer ownership to the running project:
            disposables.Items.Remove(clients);
            disposables.Items.Remove(runningProcess);
            disposables.Items.Add(runningProject);

            var appliedUpdateCount = 0;
            while (true)
            {
                // Observe updates that need to be applied to the new process
                // and apply them before adding it to running processes.
                // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date.
                var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray();
                if (updatesToApply.Any() && clients.IsManagedAgentSupported)
                {
                    await await clients.ApplyManagedCodeUpdatesAsync(
                        ToManagedCodeUpdates(updatesToApply),
                        applyOperationCancellationToken: processExitedSource.Token,
                        cancellationToken: processCommunicationCancellationToken);
                }

                appliedUpdateCount += updatesToApply.Length;

                lock (_runningProjectsAndUpdatesGuard)
                {
                    ObjectDisposedException.ThrowIf(_isDisposed, this);

                    // More updates might have come in while we have been applying updates.
                    // If so, continue updating.
                    if (_previousUpdates.Count > appliedUpdateCount)
                    {
                        continue;
                    }

                    // Only add the running process after it has been up-to-date.
                    // This will prevent new updates being applied before we have applied all the previous updates.
                    _runningProjects = _runningProjects.Add(projectPath, runningProject);

                    // transfer ownership to _runningProjects
                    publishedRunningProject = runningProject;
                    disposables.Items.Remove(runningProject);
                    Debug.Assert(disposables.Items is []);
                    break;
                }
            }

            if (clients.IsManagedAgentSupported)
            {
                clients.OnRuntimeRudeEdit += (code, message) =>
                {
                    // fire and forget:
                    _ = HandleRuntimeRudeEditAsync(publishedRunningProject, message, cancellationToken);
                };

                // Notifies the agent that it can unblock the execution of the process:
                await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken);

                // If non-empty solution is loaded into the workspace (a Hot Reload session is active):
                if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution)
                {
                    // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. 
                    PrepareCompilations(currentSolution, projectPath, cancellationToken);
                }
            }

            return publishedRunningProject;
        }
        catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested)
        {
            // Process exited during initialization. This should not happen since we control the process during this time.
            Logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId);
            return null;
        }
    }

    private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken)
    {
        var logger = runningProject.ClientLogger;

        try
        {
            // Always auto-restart on runtime rude edits regardless of the settings.
            // Since there is no debugger attached the process would crash on an unhandled HotReloadException if
            // we let it continue executing.
            logger.LogWarning(rudeEditMessage);
            logger.Log(MessageDescriptor.RestartingApplication);

            if (!runningProject.InitiateRestart())
            {
                // Already in the process of restarting, possibly because of another runtime rude edit.
                return;
            }

            await runningProject.Clients.ReportCompilationErrorsInApplicationAsync([rudeEditMessage, MessageDescriptor.RestartingApplication.GetMessage()], cancellationToken);

            // Terminate the process.
            await runningProject.Process.TerminateAsync();

            // Creates a new running project and launches it:
            await runningProject.RestartAsync(cancellationToken);
        }
        catch (Exception e)
        {
            if (e is not OperationCanceledException)
            {
                logger.LogError("Failed to handle runtime rude edit: {Exception}", e.ToString());
            }
        }
    }

    private ImmutableArray<string> GetAggregateCapabilities()
    {
        var capabilities = _runningProjects
            .SelectMany(p => p.Value)
            .SelectMany(p => p.ManagedCodeUpdateCapabilities)
            .Distinct(StringComparer.Ordinal)
            .Order()
            .ToImmutableArray();

        Logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities));
        return capabilities;
    }

    private static void PrepareCompilations(Solution solution, string projectPath, CancellationToken cancellationToken)
    {
        // Warm up the compilation. This would help make the deltas for first edit appear much more quickly
        foreach (var project in solution.Projects)
        {
            if (project.FilePath == projectPath)
            {
                // fire and forget:
                _ = project.GetCompilationAsync(cancellationToken);
            }
        }
    }

    public async ValueTask GetManagedCodeUpdatesAsync(
        HotReloadProjectUpdatesBuilder builder,
        Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
        bool autoRestart,
        CancellationToken cancellationToken)
    {
        var currentSolution = Workspace.CurrentSolution;
        var runningProjects = _runningProjects;

        var runningProjectInfos =
           (from project in currentSolution.Projects
            let runningProject = GetCorrespondingRunningProjects(runningProjects, project).FirstOrDefault()
            where runningProject != null
            let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled()
            select (project.Id, info: new HotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject }))
            .ToImmutableDictionary(e => e.Id, e => e.info);

        var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken);

        await DisplayResultsAsync(updates, currentSolution, runningProjectInfos, cancellationToken);

        if (updates.Status is HotReloadService.Status.NoChangesToApply or HotReloadService.Status.Blocked)
        {
            // If Hot Reload is blocked (due to compilation error) we ignore the current
            // changes and await the next file change.

            // Note: CommitUpdate/DiscardUpdate is not expected to be called.
            return;
        }

        var projectsToPromptForRestart =
            (from projectId in updates.ProjectsToRestart.Keys
             where !runningProjectInfos[projectId].RestartWhenChangesHaveNoEffect // equivallent to auto-restart
             select currentSolution.GetProject(projectId)!.Name).ToList();

        if (projectsToPromptForRestart.Any() &&
            !await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken))
        {
            _hotReloadService.DiscardUpdate();

            Logger.Log(MessageDescriptor.HotReloadSuspended);
            await Task.Delay(-1, cancellationToken);

            return;
        }

        // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding.
        _hotReloadService.CommitUpdate();

        DiscardPreviousUpdates(updates.ProjectsToRebuild);

        builder.ManagedCodeUpdates.AddRange(updates.ProjectUpdates);
        builder.ProjectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!));
        builder.ProjectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!));

        // Terminate all tracked processes that need to be restarted,
        // except for the root process, which will terminate later on.
        if (!updates.ProjectsToRestart.IsEmpty)
        {
            builder.ProjectsToRestart.AddRange(await TerminatePeripheralProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken));
        }
    }

    public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync(
        IReadOnlyList<HotReloadService.Update> managedCodeUpdates,
        IReadOnlyDictionary<RunningProject, List<StaticWebAsset>> staticAssetUpdates,
        ImmutableArray<ChangedFile> changedFiles,
        LoadedProjectGraph projectGraph,
        Stopwatch stopwatch,
        CancellationToken cancellationToken)
    {
        var applyTasks = new List<Task>();
        ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate = [];

        IReadOnlyList<RestartOperation> relaunchOperations;
        lock (_runningProjectsAndUpdatesGuard)
        {
            // Adding the updates makes sure that all new processes receive them before they are added to running processes.
            _previousUpdates = _previousUpdates.AddRange(managedCodeUpdates);

            // Capture the set of processes that do not have the currently calculated deltas yet.
            projectsToUpdate = _runningProjects;

            // Determine relaunch operations at the same time as we capture running processes,
            // so that these sets are consistent even if another process crashes while doing so.
            relaunchOperations = GetRelaunchOperations_NoLock(changedFiles, projectGraph);
        }

        // Relaunch projects after _previousUpdates were updated above.
        // Ensures that the current and previous updates will be applied as initial updates to the newly launched processes.
        // We also capture _runningProjects above, before launching new ones, so that the current updates are not applied twice to the relaunched processes.
        // Static asset changes do not need to be updated in the newly launched processes since the application will read their updated content once it launches.
        // Fire and forget.
        foreach (var relaunchOperation in relaunchOperations)
        {
            // fire and forget:
            _ = Task.Run(async () =>
            {
                try
                {
                    await relaunchOperation.Invoke(cancellationToken);
                }
                catch (OperationCanceledException)
                {
                    // nop
                }
                catch (Exception e)
                {
                    // Handle all exceptions since this is a fire-and-forget task.
                    _context.Logger.LogError("Failed to relaunch: {Exception}", e.ToString());
                }
            }, cancellationToken);
        }

        if (managedCodeUpdates is not [])
        {
            // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
            // The process may load any of the binaries using MEF or some other runtime dependency loader.

            foreach (var (_, projects) in projectsToUpdate)
            {
                foreach (var runningProject in projects)
                {
                    Debug.Assert(runningProject.Clients.IsManagedAgentSupported);

                    // Only cancel applying updates when the process exits. Canceling disables further updates since the state of the runtime becomes unknown.
                    var applyTask = await runningProject.Clients.ApplyManagedCodeUpdatesAsync(
                        ToManagedCodeUpdates(managedCodeUpdates),
                        applyOperationCancellationToken: runningProject.Process.ExitedCancellationToken,
                        cancellationToken);

                    applyTasks.Add(runningProject.CompleteApplyOperationAsync(applyTask));
                }
            }
        }

        // Creating apply tasks involves reading static assets from disk. Parallelize this IO.
        var staticAssetApplyTaskProducers = new List<Task<Task>>();

        foreach (var (runningProject, assets) in staticAssetUpdates)
        {
            // Only cancel applying updates when the process exits. Canceling in-progress static asset update might be ok,
            // but for consistency with managed code updates we only cancel when the process exits.
            staticAssetApplyTaskProducers.Add(runningProject.Clients.ApplyStaticAssetUpdatesAsync(
                assets,
                applyOperationCancellationToken: runningProject.Process.ExitedCancellationToken,
                cancellationToken));
        }

        applyTasks.AddRange(await Task.WhenAll(staticAssetApplyTaskProducers));

        // fire and forget:
        _ = CompleteApplyOperationAsync();

        async Task CompleteApplyOperationAsync()
        {
            try
            {
                await Task.WhenAll(applyTasks);

                var elapsedMilliseconds = stopwatch.ElapsedMilliseconds;

                if (managedCodeUpdates.Count > 0)
                {
                    _context.Logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds);
                }

                if (staticAssetUpdates.Count > 0)
                {
                    _context.Logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds);
                }

                _context.Logger.Log(MessageDescriptor.ChangesAppliedToProjectsNotification,
                    projectsToUpdate.Select(e => e.Value.First().Options.Representation).Concat(
                        staticAssetUpdates.Select(e => e.Key.Options.Representation)));
            }
            catch (OperationCanceledException)
            {
                // nop
            }
            catch (Exception e)
            {
                // Handle all exceptions since this is a fire-and-forget task.
                _context.Logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString());
            }
        }
    }

    private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Solution solution, ImmutableDictionary<ProjectId, HotReloadService.RunningProjectInfo> runningProjectInfos, CancellationToken cancellationToken)
    {
        switch (updates.Status)
        {
            case HotReloadService.Status.ReadyToApply:
                break;

            case HotReloadService.Status.NoChangesToApply:
                Logger.Log(MessageDescriptor.NoManagedCodeChangesToApply);
                break;

            case HotReloadService.Status.Blocked:
                Logger.Log(MessageDescriptor.UnableToApplyChanges);
                break;

            default:
                throw new InvalidOperationException();
        }

        if (!updates.ProjectsToRestart.IsEmpty)
        {
            Logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
        }

        var errorsToDisplayInApp = new List<string>();

        // Display errors first, then warnings:
        ReportCompilationDiagnostics(DiagnosticSeverity.Error);
        ReportCompilationDiagnostics(DiagnosticSeverity.Warning);
        ReportRudeEdits();

        // report or clear diagnostics in the browser UI
        await _runningProjects.ForEachValueAsync(
            (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. errorsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
            cancellationToken);

        void ReportCompilationDiagnostics(DiagnosticSeverity severity)
        {
            foreach (var diagnostic in updates.PersistentDiagnostics)
            {
                if (diagnostic.Id == "CS8002")
                {
                    // TODO: This is not a useful warning. Compiler shouldn't be reporting this on .NET/
                    // Referenced assembly '...' does not have a strong name"
                    continue;
                }

                // TODO: https://github.com/dotnet/roslyn/pull/79018
                // shouldn't be included in compilation diagnostics
                if (diagnostic.Id == "ENC0118")
                {
                    // warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted
                    continue;
                }

                if (diagnostic.DefaultSeverity != severity)
                {
                    continue;
                }

                // TODO: we don't currently have a project associated with the diagnostic
                ReportDiagnostic(diagnostic, projectDisplayPrefix: "", autoPrefix: "");
            }
        }

        void ReportRudeEdits()
        {
            // Rude edits in projects that caused restart of a project that can be restarted automatically
            // will be reported only as verbose output.
            var projectsRestartedDueToRudeEdits = updates.ProjectsToRestart
                .Where(e => IsAutoRestartEnabled(e.Key))
                .SelectMany(e => e.Value)
                .ToHashSet();

            // Project with rude edit that doesn't impact running project is only listed in ProjectsToRebuild.
            // Such projects are always auto-rebuilt whether or not there is any project to be restarted that needs a confirmation.
            var projectsRebuiltDueToRudeEdits = updates.ProjectsToRebuild
                .Where(p => !updates.ProjectsToRestart.ContainsKey(p))
                .ToHashSet();

            foreach (var (projectId, diagnostics) in updates.TransientDiagnostics)
            {
                // The diagnostic may be reported for a project that has been deleted.
                var project = solution.GetProject(projectId);
                var projectDisplay = project != null ? $"[{GetProjectInstance(project).GetDisplayName()}] " : "";

                foreach (var diagnostic in diagnostics)
                {
                    var prefix =
                        projectsRestartedDueToRudeEdits.Contains(projectId) ? "[auto-restart] " :
                        projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " :
                        "";

                    ReportDiagnostic(diagnostic, projectDisplay, prefix);
                }
            }
        }

        bool IsAutoRestartEnabled(ProjectId id)
            => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect;

        void ReportDiagnostic(Diagnostic diagnostic, string projectDisplayPrefix, string autoPrefix)
        {
            var message = projectDisplayPrefix + autoPrefix + CSharpDiagnosticFormatter.Instance.Format(diagnostic);

            if (autoPrefix != "")
            {
                Logger.Log(MessageDescriptor.ApplyUpdate_AutoVerbose, message);
                errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage());
            }
            else
            {
                var descriptor = GetMessageDescriptor(diagnostic);
                Logger.Log(descriptor, message);

                if (descriptor.Level != LogLevel.None)
                {
                    errorsToDisplayInApp.Add(descriptor.GetMessage(message));
                }
            }
        }

        // Use the default severity of the diagnostic as it conveys impact on Hot Reload
        // (ignore warnings as errors and other severity configuration).
        static MessageDescriptor<string> GetMessageDescriptor(Diagnostic diagnostic)
        {
            if (diagnostic.Id == "ENC0118")
            {
                // Changing '<entry-point>' might not have any effect until the application is restarted.
                return MessageDescriptor.ApplyUpdate_ChangingEntryPoint;
            }

            return diagnostic.DefaultSeverity switch
            {
                DiagnosticSeverity.Error => MessageDescriptor.ApplyUpdate_Error,
                DiagnosticSeverity.Warning => MessageDescriptor.ApplyUpdate_Warning,
                _ => MessageDescriptor.ApplyUpdate_Verbose,
            };
        }
    }

    private static readonly ImmutableArray<string> s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets];

    private static bool HasScopedCssTargets(ProjectInstance projectInstance)
        => s_targets.All(projectInstance.Targets.ContainsKey);

    public async ValueTask GetStaticAssetUpdatesAsync(
        HotReloadProjectUpdatesBuilder builder,
        IReadOnlyList<ChangedFile> files,
        EvaluationResult evaluationResult,
        Stopwatch stopwatch,
        CancellationToken cancellationToken)
    {
        // capture snapshot:
        var runningProjects = _runningProjects;

        var assets = new Dictionary<ProjectInstance, Dictionary<string, StaticWebAsset>>();
        var projectInstancesToRegenerate = new HashSet<ProjectInstanceId>();

        foreach (var changedFile in files)
        {
            var file = changedFile.Item;
            var isScopedCss = StaticWebAsset.IsScopedCssFile(file.FilePath);

            if (!isScopedCss && file.StaticWebAssetRelativeUrl is null)
            {
                continue;
            }

            foreach (var containingProjectPath in file.ContainingProjectPaths)
            {
                foreach (var containingProjectNode in evaluationResult.ProjectGraph.GetProjectNodes(containingProjectPath))
                {
                    if (isScopedCss)
                    {
                        if (!HasScopedCssTargets(containingProjectNode.ProjectInstance))
                        {
                            continue;
                        }

                        projectInstancesToRegenerate.Add(containingProjectNode.ProjectInstance.GetId());
                    }

                    foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf())
                    {
                        var applicationProjectInstance = referencingProjectNode.ProjectInstance;
                        var runningApplicationProject = GetCorrespondingRunningProjects(runningProjects, applicationProjectInstance).FirstOrDefault();
                        if (runningApplicationProject == null)
                        {
                            continue;
                        }

                        string filePath;
                        string relativeUrl;

                        if (isScopedCss)
                        {
                            // Razor class library may be referenced by application that does not have static assets:
                            if (!HasScopedCssTargets(applicationProjectInstance))
                            {
                                continue;
                            }

                            projectInstancesToRegenerate.Add(applicationProjectInstance.GetId());

                            var bundleFileName = StaticWebAsset.GetScopedCssBundleFileName(
                                applicationProjectFilePath: applicationProjectInstance.FullPath,
                                containingProjectFilePath: containingProjectNode.ProjectInstance.FullPath);

                            if (!evaluationResult.StaticWebAssetsManifests.TryGetValue(applicationProjectInstance.GetId(), out var manifest))
                            {
                                // Shouldn't happen.
                                runningApplicationProject.ClientLogger.Log(MessageDescriptor.StaticWebAssetManifestNotFound);
                                continue;
                            }

                            if (!manifest.TryGetBundleFilePath(bundleFileName, out var bundleFilePath))
                            {
                                // Shouldn't happen.
                                runningApplicationProject.ClientLogger.Log(MessageDescriptor.ScopedCssBundleFileNotFound, bundleFileName);
                                continue;
                            }

                            filePath = bundleFilePath;
                            relativeUrl = bundleFileName;
                        }
                        else
                        {
                            Debug.Assert(file.StaticWebAssetRelativeUrl != null);
                            filePath = file.FilePath;
                            relativeUrl = file.StaticWebAssetRelativeUrl;
                        }

                        if (!assets.TryGetValue(applicationProjectInstance, out var applicationAssets))
                        {
                            applicationAssets = [];
                            assets.Add(applicationProjectInstance, applicationAssets);
                        }
                        else if (applicationAssets.ContainsKey(filePath))
                        {
                            // asset already being updated in this application project:
                            continue;
                        }

                        applicationAssets.Add(filePath, new StaticWebAsset(
                            filePath,
                            StaticWebAsset.WebRoot + "/" + relativeUrl,
                            containingProjectNode.GetAssemblyName(),
                            isApplicationProject: containingProjectNode.ProjectInstance == applicationProjectInstance));
                    }
                }
            }
        }

        if (assets.Count == 0)
        {
            return;
        }

        HashSet<ProjectInstance>? failedApplicationProjectInstances = null; 
        if (projectInstancesToRegenerate.Count > 0)
        {
            Logger.LogDebug("Regenerating scoped CSS bundles.");

            // Deep copy instances so that we don't pollute the project graph:
            var buildRequests = projectInstancesToRegenerate
                .Select(instanceId => BuildRequest.Create(evaluationResult.RestoredProjectInstances[instanceId].DeepCopy(), s_targets))
                .ToArray();

            _ = await evaluationResult.BuildManager.BuildAsync(
                buildRequests,
                onFailure: failedInstance =>
                {
                    Logger.LogWarning("[{ProjectName}] Failed to regenerate scoped CSS bundle.", failedInstance.GetDisplayName());

                    failedApplicationProjectInstances ??= [];
                    failedApplicationProjectInstances.Add(failedInstance);

                    // continue build
                    return true;
                },
                operationName: "ScopedCss",
                cancellationToken);
        }

        foreach (var (applicationProjectInstance, instanceAssets) in assets)
        {
            if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true)
            {
                continue;
            }

            foreach (var runningProject in GetCorrespondingRunningProjects(runningProjects, applicationProjectInstance))
            {
                if (!builder.StaticAssetsToUpdate.TryGetValue(runningProject, out var updatesPerRunningProject))
                {
                    builder.StaticAssetsToUpdate.Add(runningProject, updatesPerRunningProject = []);
                }

                if (!runningProject.Clients.UseRefreshServerToApplyStaticAssets && !runningProject.Clients.IsManagedAgentSupported)
                {
                    // Static assets are applied via managed Hot Reload agent (e.g. in MAUI Blazor app), but managed Hot Reload is not supported (e.g. startup hooks are disabled).
                    builder.ProjectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath);
                    builder.ProjectsToRestart.Add(runningProject);
                }
                else
                {
                    updatesPerRunningProject.AddRange(instanceAssets.Values);
                }
            }
        }
    }

    /// <summary>
    /// Terminates all processes launched for peripheral projects with <paramref name="projectPaths"/>,
    /// or all running peripheral project processes if <paramref name="projectPaths"/> is null.
    /// 
    /// Removes corresponding entries from <see cref="_runningProjects"/>.
    /// 
    /// Does not terminate the main project.
    /// </summary>
    /// <returns>All processes (including main) to be restarted.</returns>
    internal async ValueTask<ImmutableArray<RunningProject>> TerminatePeripheralProcessesAsync(
        IEnumerable<string>? projectPaths, CancellationToken cancellationToken)
    {
        ImmutableArray<RunningProject> projectsToRestart = [];

        lock (_runningProjectsAndUpdatesGuard)
        {
            projectsToRestart = projectPaths == null
                ? [.. _runningProjects.SelectMany(entry => entry.Value)]
                : [.. projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : [])];
        }

        // Do not terminate root process at this time - it would signal the cancellation token we are currently using.
        // The process will be restarted later on.
        // Wait for all processes to exit to release their resources, so we can rebuild.
        await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsMainProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken);

        return projectsToRestart;
    }

    /// <summary>
    /// Restarts given projects after their process have been terminated via <see cref="TerminatePeripheralProcessesAsync"/>.
    /// </summary>
    internal async Task RestartPeripheralProjectsAsync(IReadOnlyList<RunningProject> projectsToRestart, CancellationToken cancellationToken)
    {
        if (projectsToRestart.Any(p => p.Options.IsMainProject))
        {
            throw new InvalidOperationException("Main project can't be restarted.");
        }

        _context.Logger.Log(MessageDescriptor.RestartingProjectsNotification, projectsToRestart.Select(p => p.Options.Representation));

        await Task.WhenAll(
            projectsToRestart.Select(async runningProject => runningProject.RestartAsync(cancellationToken)))
            .WaitAsync(cancellationToken);

        _context.Logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Count);
    }

    private bool RemoveRunningProject(RunningProject project, bool relaunch)
    {
        var projectPath = project.ProjectNode.ProjectInstance.FullPath;

        lock (_runningProjectsAndUpdatesGuard)
        {
            var newRunningProjects = _runningProjects.Remove(projectPath, project);
            if (newRunningProjects == _runningProjects)
            {
                return false;
            }

            if (relaunch)
            {
                // Create re-launch operation for each instance that crashed
                // even if other instances of the project are still running.
                _activeProjectRelaunchOperations = _activeProjectRelaunchOperations.Add(projectPath, project.GetRelaunchOperation());
            }

            _runningProjects = newRunningProjects;
        }

        if (relaunch)
        {
            project.ClientLogger.Log(MessageDescriptor.ProcessCrashedAndWillBeRelaunched);
        }

        return true;
    }

    private IReadOnlyList<RestartOperation> GetRelaunchOperations_NoLock(IReadOnlyList<ChangedFile> changedFiles, LoadedProjectGraph projectGraph)
    {
        if (_activeProjectRelaunchOperations.IsEmpty)
        {
            return [];
        }

        var relaunchOperations = new List<RestartOperation>();
        foreach (var changedFile in changedFiles)
        {
            foreach (var containingProjectPath in changedFile.Item.ContainingProjectPaths)
            {
                var containingProjectNodes = projectGraph.GetProjectNodes(containingProjectPath);

                // Relaunch all projects whose dependency is affected by this file change.
                foreach (var ancestor in containingProjectNodes[0].GetAncestorsAndSelf())
                {
                    var ancestorPath = ancestor.ProjectInstance.FullPath;
                    if (_activeProjectRelaunchOperations.TryGetValue(ancestorPath, out var operations))
                    {
                        relaunchOperations.AddRange(operations);
                        _activeProjectRelaunchOperations = _activeProjectRelaunchOperations.Remove(ancestorPath);

                        if (_activeProjectRelaunchOperations.IsEmpty)
                        {
                            break;
                        }
                    }
                }
            }
        }

        return relaunchOperations;
    }

    private static IEnumerable<RunningProject> GetCorrespondingRunningProjects(ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects, Project project)
    {
        if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath))
        {
            return [];
        }

        // msbuild workspace doesn't set TFM if the project is not multi-targeted
        var tfm = HotReloadService.GetTargetFramework(project);
        if (tfm == null)
        {
            Debug.Assert(projectsWithPath.All(p => string.Equals(p.GetTargetFramework(), projectsWithPath[0].GetTargetFramework(), StringComparison.OrdinalIgnoreCase)));
            return projectsWithPath;
        }

        return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
    }

    private static IEnumerable<RunningProject> GetCorrespondingRunningProjects(ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects, ProjectInstance project)
    {
        if (!runningProjects.TryGetValue(project.FullPath, out var projectsWithPath))
        {
            return [];
        }

        var tfm = project.GetTargetFramework();
        return projectsWithPath.Where(p => string.Equals(p.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
    }

    private ProjectInstance GetProjectInstance(Project project)
    {
        Debug.Assert(project.FilePath != null);

        if (!_projectInstances.TryGetValue(project.FilePath, out var instances))
        {
            throw new InvalidOperationException($"Project '{project.FilePath}' (id = '{project.Id}') not found in project graph");
        }

        // msbuild workspace doesn't set TFM if the project is not multi-targeted
        var tfm = HotReloadService.GetTargetFramework(project);
        if (tfm == null)
        {
            return instances.Single();
        }

        return instances.Single(instance => string.Equals(instance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
    }

    private static ImmutableArray<HotReloadManagedCodeUpdate> ToManagedCodeUpdates(IEnumerable<HotReloadService.Update> updates)
        => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))];

    private static ImmutableDictionary<string, ImmutableArray<ProjectInstance>> CreateProjectInstanceMap(ProjectGraph graph)
        => graph.ProjectNodes
            .GroupBy(static node => node.ProjectInstance.FullPath)
            .ToImmutableDictionary(
                keySelector: static group => group.Key,
                elementSelector: static group => group.Select(static node => node.ProjectInstance).ToImmutableArray());

    public async Task<Solution> UpdateProjectGraphAsync(ProjectGraph projectGraph, CancellationToken cancellationToken)
    {
        _projectInstances = CreateProjectInstanceMap(projectGraph);

        var solution = await Workspace.UpdateProjectGraphAsync([.. projectGraph.EntryPointNodes.Select(n => n.ProjectInstance.FullPath)], cancellationToken);
        await SolutionUpdatedAsync(solution, "project update", cancellationToken);
        return solution;
    }

    public async Task UpdateFileContentAsync(IReadOnlyList<ChangedFile> changedFiles, CancellationToken cancellationToken)
    {
        var solution = await Workspace.UpdateFileContentAsync(changedFiles.Select(static f => (f.Item.FilePath, f.Kind.Convert())), cancellationToken);
        await SolutionUpdatedAsync(solution, "document update", cancellationToken);
    }

    private Task SolutionUpdatedAsync(Solution newSolution, string operationDisplayName, CancellationToken cancellationToken)
        => ReportSolutionFilesAsync(newSolution, Interlocked.Increment(ref _solutionUpdateId), operationDisplayName, cancellationToken);

    private async Task ReportSolutionFilesAsync(Solution solution, int updateId, string operationDisplayName, CancellationToken cancellationToken)
    {
        Logger.LogDebug("Solution after {Operation}: v{Version}", operationDisplayName, updateId);

        if (!Logger.IsEnabled(LogLevel.Trace))
        {
            return;
        }

        foreach (var project in solution.Projects)
        {
            Logger.LogDebug("  Project: {Path}", project.FilePath);

            foreach (var document in project.Documents)
            {
                await InspectDocumentAsync(document, "Document").ConfigureAwait(false);
            }

            foreach (var document in project.AdditionalDocuments)
            {
                await InspectDocumentAsync(document, "Additional").ConfigureAwait(false);
            }

            foreach (var document in project.AnalyzerConfigDocuments)
            {
                await InspectDocumentAsync(document, "Config").ConfigureAwait(false);
            }
        }

        async ValueTask InspectDocumentAsync(TextDocument document, string kind)
        {
            var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
            Logger.LogDebug("    {Kind}: {FilePath} [{Checksum}]", kind, document.FilePath, Convert.ToBase64String(text.GetChecksum().ToArray()));
        }
    }
}