File: HotReload\CompilationHandler.cs
Web Access
Project: ..\..\..\src\BuiltInTools\dotnet-watch\dotnet-watch.csproj (dotnet-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.Graph;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;
 
namespace Microsoft.DotNet.Watch
{
    internal sealed class CompilationHandler : IDisposable
    {
        public readonly IncrementalMSBuildWorkspace Workspace;
        private readonly ILoggerFactory _loggerFactory;
        private readonly ILogger _logger;
        private readonly WatchHotReloadService _hotReloadService;
        private readonly ProcessRunner _processRunner;
 
        /// <summary>
        /// Lock to synchronize:
        /// <see cref="_runningProjects"/>
        /// <see cref="_previousUpdates"/>
        /// <see cref="_currentAggregateCapabilities"/>
        /// </summary>
        private readonly object _runningProjectsAndUpdatesGuard = new();
 
        /// <summary>
        /// Projects that have been launched and to which we apply changes. 
        /// </summary>
        private ImmutableDictionary<string, ImmutableArray<RunningProject>> _runningProjects = ImmutableDictionary<string, ImmutableArray<RunningProject>>.Empty;
 
        /// <summary>
        /// All updates that were attempted. Includes updates whose application failed.
        /// </summary>
        private ImmutableList<WatchHotReloadService.Update> _previousUpdates = [];
 
        private bool _isDisposed;
 
        public CompilationHandler(ILoggerFactory loggerFactory, ILogger logger, ProcessRunner processRunner)
        {
            _loggerFactory = loggerFactory;
            _logger = logger;
            _processRunner = processRunner;
            Workspace = new IncrementalMSBuildWorkspace(logger);
            _hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities()));
        }
 
        public void Dispose()
        {
            _isDisposed = true;
            Workspace?.Dispose();
        }
 
        public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken)
        {
            _logger.LogDebug("Disposing remaining child processes.");
 
            var projectsToDispose = await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken);
 
            foreach (var project in projectsToDispose)
            {
                project.Dispose();
            }
 
            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(CancellationToken cancellationToken)
        {
            _logger.Log(MessageDescriptor.HotReloadSessionStarting);
 
            await _hotReloadService.StartSessionAsync(Workspace.CurrentSolution, cancellationToken);
 
            _logger.Log(MessageDescriptor.HotReloadSessionStarted);
        }
 
        public async Task<RunningProject?> TrackRunningProjectAsync(
            ProjectGraphNode projectNode,
            ProjectOptions projectOptions,
            HotReloadClients clients,
            ProcessSpec processSpec,
            RestartOperation restartOperation,
            CancellationTokenSource processTerminationSource,
            CancellationToken cancellationToken)
        {
            var processExitedSource = new CancellationTokenSource();
            var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processExitedSource.Token, cancellationToken);
 
            // Dispose these objects on failure:
            using var disposables = new Disposables([clients, processExitedSource, processCommunicationCancellationSource]);
 
            // 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(processCommunicationCancellationSource.Token);
 
            processSpec.OnExit += (_, _) =>
            {
                processExitedSource.Cancel();
                return ValueTask.CompletedTask;
            };
 
            var launchResult = new ProcessLaunchResult();
            var runningProcess = _processRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token);
            if (launchResult.ProcessId == null)
            {
                // error already reported
                return null;
            }
 
            // 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 capabilities = await clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token);
 
            var runningProject = new RunningProject(
                projectNode,
                projectOptions,
                clients,
                runningProcess,
                launchResult.ProcessId.Value,
                processExitedSource: processExitedSource,
                processTerminationSource: processTerminationSource,
                restartOperation: restartOperation,
                disposables: [processCommunicationCancellationSource],
                capabilities);
 
            var projectPath = projectNode.ProjectInstance.FullPath;
 
            // ownership transferred to running project:
            disposables.Items.Clear();
            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())
                {
                    await clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updatesToApply), isProcessSuspended: false, processCommunicationCancellationSource.Token);
                }
 
                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.
                    if (!_runningProjects.TryGetValue(projectPath, out var projectInstances))
                    {
                        projectInstances = [];
                    }
 
                    _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject));
 
                    // ownership transferred to _runningProjects
                    disposables.Items.Clear();
                    break;
                }
            }
 
            // Notifies the agent that it can unblock the execution of the process:
            await clients.InitialUpdatesAppliedAsync(cancellationToken);
 
            // 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 runningProject;
        }
 
        private ImmutableArray<string> GetAggregateCapabilities()
        {
            var capabilities = _runningProjects
                .SelectMany(p => p.Value)
                .SelectMany(p => p.Capabilities)
                .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<(
                ImmutableArray<WatchHotReloadService.Update> projectUpdates,
                ImmutableArray<string> projectsToRebuild,
                ImmutableArray<string> projectsToRedeploy,
                ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
            bool autoRestart,
            Func<IEnumerable<string>, CancellationToken, Task<bool>> restartPrompt,
            CancellationToken cancellationToken)
        {
            var currentSolution = Workspace.CurrentSolution;
            var runningProjects = _runningProjects;
 
            var runningProjectInfos =
               (from project in currentSolution.Projects
                let runningProject = GetCorrespondingRunningProject(project, runningProjects)
                where runningProject != null
                let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled()
                select (project.Id, info: new WatchHotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject }))
                .ToImmutableDictionary(e => e.Id, e => e.info);
 
            var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken);
 
            await DisplayResultsAsync(updates, runningProjectInfos, cancellationToken);
 
            if (updates.Status is WatchHotReloadService.Status.NoChangesToApply or WatchHotReloadService.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);
 
            var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
            var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray();
 
            // Terminate all tracked processes that need to be restarted,
            // except for the root process, which will terminate later on.
            var terminatedProjects = updates.ProjectsToRestart.IsEmpty
                ? []
                : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken);
 
            return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, terminatedProjects);
        }
 
        public async ValueTask ApplyUpdatesAsync(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
        {
            Debug.Assert(!updates.IsEmpty);
 
            ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
            lock (_runningProjectsAndUpdatesGuard)
            {
                // Adding the updates makes sure that all new processes receive them before they are added to running processes.
                _previousUpdates = _previousUpdates.AddRange(updates);
 
                // Capture the set of processes that do not have the currently calculated deltas yet.
                projectsToUpdate = _runningProjects;
            }
 
            // 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.
 
            await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
            {
                try
                {
                    using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
                    await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, processCommunicationCancellationSource.Token);
                }
                catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
                {
                    runningProject.Clients.ClientLogger.Log(MessageDescriptor.HotReloadCanceledProcessExited);
                }
            }, cancellationToken);
        }
 
        private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects)
        {
            if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath))
            {
                return null;
            }
 
            // msbuild workspace doesn't set TFM if the project is not multi-targeted
            var tfm = WatchHotReloadService.GetTargetFramework(project);
            if (tfm == null)
            {
                return projectsWithPath[0];
            }
 
            return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
        }
 
        private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updates, ImmutableDictionary<ProjectId, WatchHotReloadService.RunningProjectInfo> runningProjectInfos, CancellationToken cancellationToken)
        {
            switch (updates.Status)
            {
                case WatchHotReloadService.Status.ReadyToApply:
                    break;
 
                case WatchHotReloadService.Status.NoChangesToApply:
                    _logger.Log(MessageDescriptor.NoCSharpChangesToApply);
                    break;
 
                case WatchHotReloadService.Status.Blocked:
                    _logger.Log(MessageDescriptor.UnableToApplyChanges);
                    break;
 
                default:
                    throw new InvalidOperationException();
            }
 
            if (!updates.ProjectsToRestart.IsEmpty)
            {
                _logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
            }
 
            var diagnosticsToDisplayInApp = new List<string>();
 
            // Display errors first, then warnings:
            ReportCompilationDiagnostics(DiagnosticSeverity.Error);
            ReportCompilationDiagnostics(DiagnosticSeverity.Warning);
            ReportRudeEdits();
 
            // report or clear diagnostics in the browser UI
            await ForEachProjectAsync(
                _runningProjects,
                (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
                cancellationToken);
 
            void ReportCompilationDiagnostics(DiagnosticSeverity severity)
            {
                foreach (var diagnostic in updates.CompilationDiagnostics)
                {
                    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;
                    }
 
                    ReportDiagnostic(diagnostic, GetMessageDescriptor(diagnostic, verbose: false));
                }
            }
 
            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.RudeEdits)
                {
                    foreach (var diagnostic in diagnostics)
                    {
                        var prefix =
                            projectsRestartedDueToRudeEdits.Contains(projectId) ? "[auto-restart] " :
                            projectsRebuiltDueToRudeEdits.Contains(projectId) ? "[auto-rebuild] " :
                            "";
 
                        var descriptor = GetMessageDescriptor(diagnostic, verbose: prefix != "");
                        ReportDiagnostic(diagnostic, descriptor, prefix);
                    }
                }
            }
 
            bool IsAutoRestartEnabled(ProjectId id)
                => runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect;
 
            void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string prefix = "")
            {
                var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);
                var args = new[] { prefix, display };
 
                _logger.Log(descriptor, args);
 
                if (descriptor.Severity != MessageSeverity.None)
                {
                    diagnosticsToDisplayInApp.Add(descriptor.GetMessage(args));
                }
            }
 
            // Use the default severity of the diagnostic as it conveys impact on Hot Reload
            // (ignore warnings as errors and other severity configuration).
            static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbose)
            {
                if (verbose)
                {
                    return MessageDescriptor.ApplyUpdate_Verbose;
                }
 
                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,
                };
            }
        }
 
        public async ValueTask<bool> HandleStaticAssetChangesAsync(IReadOnlyList<ChangedFile> files, ProjectNodeMap projectMap, CancellationToken cancellationToken)
        {
            var allFilesHandled = true;
 
            var updates = new Dictionary<RunningProject, List<(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject)>>();
 
            foreach (var changedFile in files)
            {
                var file = changedFile.Item;
 
                if (file.StaticWebAssetPath is null)
                {
                    allFilesHandled = false;
                    continue;
                }
 
                foreach (var containingProjectPath in file.ContainingProjectPaths)
                {
                    if (!projectMap.Map.TryGetValue(containingProjectPath, out var containingProjectNodes))
                    {
                        // Shouldn't happen.
                        _logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath);
                        continue;
                    }
 
                    foreach (var containingProjectNode in containingProjectNodes)
                    {
                        foreach (var referencingProjectNode in containingProjectNode.GetAncestorsAndSelf())
                        {
                            if (TryGetRunningProject(referencingProjectNode.ProjectInstance.FullPath, out var runningProjects))
                            {
                                foreach (var runningProject in runningProjects)
                                {
                                    if (!updates.TryGetValue(runningProject, out var updatesPerRunningProject))
                                    {
                                        updates.Add(runningProject, updatesPerRunningProject = []);
                                    }
 
                                    updatesPerRunningProject.Add((file.FilePath, file.StaticWebAssetPath, containingProjectNode.GetAssemblyName(), containingProjectNode == runningProject.ProjectNode));
                                }
                            }
                        }
                    }
                }
            }
 
            if (updates.Count == 0)
            {
                return allFilesHandled;
            }
 
            var tasks = updates.Select(async entry =>
            {
                var (runningProject, assets) = entry;
                await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, cancellationToken);
            });
 
            await Task.WhenAll(tasks).WaitAsync(cancellationToken);
 
            _logger.Log(MessageDescriptor.HotReloadOfStaticAssetsSucceeded);
 
            return allFilesHandled;
        }
 
        /// <summary>
        /// Terminates all processes launched for projects with <paramref name="projectPaths"/>,
        /// or all running non-root project processes if <paramref name="projectPaths"/> is null.
        /// 
        /// Removes corresponding entries from <see cref="_runningProjects"/>.
        /// 
        /// Does not terminate the root project.
        /// </summary>
        internal async ValueTask<ImmutableArray<RunningProject>> TerminateNonRootProcessesAsync(
            IEnumerable<string>? projectPaths, CancellationToken cancellationToken)
        {
            ImmutableArray<RunningProject> projectsToRestart = [];
 
            UpdateRunningProjects(runningProjectsByPath =>
            {
                if (projectPaths == null)
                {
                    projectsToRestart = _runningProjects.SelectMany(entry => entry.Value).Where(p => !p.Options.IsRootProject).ToImmutableArray();
                    return _runningProjects.Clear();
                }
 
                projectsToRestart = projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : []).ToImmutableArray();
                return runningProjectsByPath.RemoveRange(projectPaths);
            });
 
            // 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.
            var projectsToTerminate = projectsToRestart.Where(p => !p.Options.IsRootProject);
 
            // wait for all processes to exit to release their resources, so we can rebuild:
            _ = await TerminateRunningProjects(projectsToTerminate, cancellationToken);
 
            return projectsToRestart;
        }
 
        /// <summary>
        /// Terminates process of the given <paramref name="project"/>.
        /// Removes corresponding entries from <see cref="_runningProjects"/>.
        ///
        /// Should not be called with the root project.
        /// </summary>
        /// <returns>Exit code of the terminated process.</returns>
        internal async ValueTask<int> TerminateNonRootProcessAsync(RunningProject project, CancellationToken cancellationToken)
        {
            Debug.Assert(!project.Options.IsRootProject);
 
            var projectPath = project.ProjectNode.ProjectInstance.FullPath;
 
            UpdateRunningProjects(runningProjectsByPath =>
            {
                if (!runningProjectsByPath.TryGetValue(projectPath, out var runningProjects) ||
                    runningProjects.Remove(project) is var updatedRunningProjects && runningProjects == updatedRunningProjects)
                {
                    _logger.LogDebug("Ignoring an attempt to terminate process {ProcessId} of project '{Path}' that has no associated running processes.", project.ProcessId, projectPath);
                    return runningProjectsByPath;
                }
 
                return updatedRunningProjects is []
                    ? runningProjectsByPath.Remove(projectPath)
                    : runningProjectsByPath.SetItem(projectPath, updatedRunningProjects);
            });
 
            // wait for all processes to exit to release their resources:
            return (await TerminateRunningProjects([project], cancellationToken)).Single();
        }
 
        private void UpdateRunningProjects(Func<ImmutableDictionary<string, ImmutableArray<RunningProject>>, ImmutableDictionary<string, ImmutableArray<RunningProject>>> updater)
        {
            lock (_runningProjectsAndUpdatesGuard)
            {
                _runningProjects = updater(_runningProjects);
            }
        }
 
        public bool TryGetRunningProject(string projectPath, out ImmutableArray<RunningProject> projects)
        {
            lock (_runningProjectsAndUpdatesGuard)
            {
                return _runningProjects.TryGetValue(projectPath, out projects);
            }
        }
 
        private async ValueTask<IReadOnlyList<int>> TerminateRunningProjects(IEnumerable<RunningProject> projects, CancellationToken cancellationToken)
        {
            // wait for all tasks to complete:
            return await Task.WhenAll(projects.Select(p => p.TerminateAsync().AsTask())).WaitAsync(cancellationToken);
        }
 
        private static Task ForEachProjectAsync(ImmutableDictionary<string, ImmutableArray<RunningProject>> projects, Func<RunningProject, CancellationToken, Task> action, CancellationToken cancellationToken)
            => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken);
 
        private static ImmutableArray<HotReloadManagedCodeUpdate> ToManagedCodeUpdates(ImmutableArray<WatchHotReloadService.Update> updates)
            => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))];
    }
}