diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs index 145f90e43d35..034d52501ad3 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs @@ -24,7 +24,7 @@ public IRuntimeProcessLauncher Create(ProjectLauncher projectLauncher) { // Connect to control pipe if provided var controlReader = controlPipeName != null - ? new WatchControlReader(controlPipeName, projectLauncher.CompilationHandler, projectLauncher.Logger) + ? new WatchControlReader(controlPipeName, projectLauncher) : null; return new Launcher(serverPipeName, controlReader, projectLauncher, statusWriter, launchProfile, shutdownCancellationToken); diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs index 3391077c1a36..6b119a93f920 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs @@ -9,14 +9,13 @@ namespace Microsoft.DotNet.Watch; internal sealed class WatchControlReader : IAsyncDisposable { - private readonly CompilationHandler _compilationHandler; + private readonly ProjectLauncher _launcher; private readonly string _pipeName; private readonly NamedPipeClientStream _pipe; - private readonly ILogger _logger; private readonly CancellationTokenSource _disposalCancellationSource = new(); private readonly Task _listener; - public WatchControlReader(string pipeName, CompilationHandler compilationHandler, ILogger logger) + public WatchControlReader(string pipeName, ProjectLauncher launcher) { _pipe = new NamedPipeClientStream( serverName: ".", @@ -25,14 +24,16 @@ public WatchControlReader(string pipeName, CompilationHandler compilationHandler PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); _pipeName = pipeName; - _compilationHandler = compilationHandler; - _logger = logger; + _launcher = launcher; _listener = ListenAsync(_disposalCancellationSource.Token); } + private ILogger Logger + => _launcher.Logger; + public async ValueTask DisposeAsync() { - _logger.LogDebug("Disposing control pipe."); + Logger.LogDebug("Disposing control pipe."); _disposalCancellationSource.Cancel(); await _listener; @@ -53,7 +54,7 @@ private async Task ListenAsync(CancellationToken cancellationToken) { try { - _logger.LogDebug("Connecting to control pipe '{PipeName}'.", _pipeName); + Logger.LogDebug("Connecting to control pipe '{PipeName}'.", _pipeName); await _pipe.ConnectAsync(cancellationToken); using var reader = new StreamReader(_pipe); @@ -74,12 +75,12 @@ private async Task ListenAsync(CancellationToken cancellationToken) if (command.Type == WatchControlCommand.Types.Rebuild) { - _logger.LogDebug("Received request to restart projects"); + Logger.LogDebug("Received request to restart projects"); await RestartProjectsAsync(command.Projects.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath), cancellationToken); } else { - _logger.LogError("Unknown control command: '{Type}'", command.Type); + Logger.LogError("Unknown control command: '{Type}'", command.Type); } } } @@ -89,22 +90,23 @@ private async Task ListenAsync(CancellationToken cancellationToken) } catch (Exception e) { - _logger.LogDebug("Control pipe listener failed: {Message}", e.Message); + Logger.LogDebug("Control pipe listener failed: {Message}", e.Message); } } private async ValueTask RestartProjectsAsync(IEnumerable projects, CancellationToken cancellationToken) { - var projectsToRestart = await _compilationHandler.TerminatePeripheralProcessesAsync(projects.Select(p => p.ProjectGraphPath), cancellationToken); + var projectsToRestart = _launcher.RunningProjectsManager.GetRunningProjects(projects).ToArray(); + await _launcher.RunningProjectsManager.TerminatePeripheralProcessesAsync(projectsToRestart, cancellationToken); foreach (var project in projects) { if (!projectsToRestart.Any(p => p.Options.Representation == project)) { - _compilationHandler.Logger.LogDebug("Restart of '{Project}' requested but the project is not running.", project); + Logger.LogDebug("Restart of '{Project}' requested but the project is not running.", project); } } - await _compilationHandler.RestartPeripheralProjectsAsync(projectsToRestart, cancellationToken); + await _launcher.RunningProjectsManager.RestartPeripheralProjectsAsync(projectsToRestart, cancellationToken); } } diff --git a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs b/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs deleted file mode 100644 index 749d0450b176..000000000000 --- a/src/Dotnet.Watch/Watch/HotReload/CompilationHandler.cs +++ /dev/null @@ -1,1068 +0,0 @@ -// 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; - - /// - /// Lock to synchronize: - /// - /// - /// - /// - private readonly object _runningProjectsAndUpdatesGuard = new(); - - /// - /// Projects that have been launched and to which we apply changes. - /// Maps to the list of running instances of that project. - /// - private ImmutableDictionary> _runningProjects - = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); - - /// - /// Maps to the list of active restart operations for the project. - /// The of the project instance is added whenever a process crashes (terminated with non-zero exit code) - /// and the corresponding is removed from . - /// - /// When a file change is observed whose containing project is listed here, the associated relaunch operations are executed. - /// - private ImmutableDictionary> _activeProjectRelaunchOperations - = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); - - /// - /// All updates that were attempted. Includes updates whose application failed. - /// - private ImmutableList _previousUpdates = []; - - private bool _isDisposed; - private int _solutionUpdateId; - - /// - /// Current set of project instances indexed by . - /// Updated whenever the project graph changes. - /// - private ImmutableDictionary> _projectInstances - = ImmutableDictionary>.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 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 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 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, CancellationToken, Task> 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 managedCodeUpdates, - IReadOnlyDictionary> staticAssetUpdates, - ImmutableArray changedFiles, - LoadedProjectGraph projectGraph, - Stopwatch stopwatch, - CancellationToken cancellationToken) - { - var applyTasks = new List(); - ImmutableDictionary> projectsToUpdate = []; - - IReadOnlyList 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>(); - - 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 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(); - - // 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 GetMessageDescriptor(Diagnostic diagnostic) - { - if (diagnostic.Id == "ENC0118") - { - // Changing '' 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 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 files, - EvaluationResult evaluationResult, - Stopwatch stopwatch, - CancellationToken cancellationToken) - { - // capture snapshot: - var runningProjects = _runningProjects; - - var assets = new Dictionary>(); - var projectInstancesToRegenerate = new HashSet(); - - 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? 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); - } - } - } - } - - /// - /// Terminates all processes launched for peripheral projects with , - /// or all running peripheral project processes if is null. - /// - /// Removes corresponding entries from . - /// - /// Does not terminate the main project. - /// - /// All processes (including main) to be restarted. - internal async ValueTask> TerminatePeripheralProcessesAsync( - IEnumerable? projectPaths, CancellationToken cancellationToken) - { - ImmutableArray 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; - } - - /// - /// Restarts given projects after their process have been terminated via . - /// - internal async Task RestartPeripheralProjectsAsync(IReadOnlyList 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 GetRelaunchOperations_NoLock(IReadOnlyList changedFiles, LoadedProjectGraph projectGraph) - { - if (_activeProjectRelaunchOperations.IsEmpty) - { - return []; - } - - var relaunchOperations = new List(); - 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 GetCorrespondingRunningProjects(ImmutableDictionary> 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 GetCorrespondingRunningProjects(ImmutableDictionary> 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 ToManagedCodeUpdates(IEnumerable updates) - => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; - - private static ImmutableDictionary> 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 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 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())); - } - } -} diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs index 9ac8aacf40c7..6457eef0fd85 100644 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/Dotnet.Watch/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Text.Encodings.Web; +using Microsoft.Build.Execution; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -80,11 +81,13 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) using var iterationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); var iterationCancellationToken = iterationCancellationSource.Token; + var runningProjectsManager = new RunningProjectsManager(_context.ProcessRunner, _context.Logger); + var suppressWaitForFileChange = false; EvaluationResult? evaluationResult = null; RunningProject? mainRunningProject = null; IRuntimeProcessLauncher? runtimeProcessLauncher = null; - CompilationHandler? compilationHandler = null; + ManagedCodeWorkspace? workspace = null; Action? fileChangedCallback = null; LoadedProjectGraph? projectGraph = null; BuildProjectsResult? rootProjectsBuildResult = null; @@ -124,7 +127,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) continue; } - compilationHandler = new CompilationHandler(_context); + workspace = new ManagedCodeWorkspace(_context.Logger, runningProjectsManager); // The session must start after the project is built and design time build completes, // so that the EnC service can read document checksums from the PDB and the solution @@ -133,9 +136,9 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) // Starting the session hydrates the contents of solution documents from disk. // Session must be started before we start accepting file changes to avoid race condition // when the EnC session hydrates solution documents with their file content after the changes have already been observed. - await compilationHandler.StartSessionAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); + await workspace.StartSessionAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); - var projectLauncher = new ProjectLauncher(_context, projectGraph, compilationHandler, iteration); + var projectLauncher = new ProjectLauncher(_context, projectGraph, runningProjectsManager, iteration); var runtimeProcessLauncherFactory = _runtimeProcessLauncherFactory; @@ -235,13 +238,19 @@ void FileChangedCallback(ChangedPath change) } while (changedFiles is []); - var updates = new HotReloadProjectUpdatesBuilder(); + var updatesBuilder = new ProjectUpdatesBuilder() + { + Logger = _context.Logger, + HotReloadService = workspace.HotReloadService, + ManagedCodeSnapshot = workspace.CurrentSnapshot, + RunningProjects = runningProjectsManager.CurrentRunningProjects + }; + var stopwatch = Stopwatch.StartNew(); - await compilationHandler.GetStaticAssetUpdatesAsync(updates, changedFiles, evaluationResult, stopwatch, iterationCancellationToken); + await updatesBuilder.AddStaticAssetUpdatesAsync(changedFiles, evaluationResult, stopwatch, iterationCancellationToken); - await compilationHandler.GetManagedCodeUpdatesAsync( - updates, + await updatesBuilder.AddManagedCodeUpdatesAsync( restartPrompt: async (projectNames, cancellationToken) => { // stop before waiting for user input: @@ -253,22 +262,27 @@ await compilationHandler.GetManagedCodeUpdatesAsync( autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, iterationCancellationToken); + if (updatesBuilder.ProjectsToRestart is not []) + { + await runningProjectsManager.TerminatePeripheralProcessesAsync(updatesBuilder.ProjectsToRestart, iterationCancellationToken); + } + // Terminate root process if it had rude edits or is non-reloadable. - if (updates.ProjectsToRestart.Any(static project => project.Options.IsMainProject)) + if (updatesBuilder.ProjectsToRestart.Any(static project => project.Options.IsMainProject)) { Debug.Assert(mainRunningProject != null); mainRunningProject.InitiateRestart(); break; } - if (updates.ProjectsToRebuild is not []) + if (updatesBuilder.ProjectsToRebuild is not []) { while (true) { iterationCancellationToken.ThrowIfCancellationRequested(); var result = await BuildProjectsAsync( - [.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], + [.. updatesBuilder.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPointFilePath)], fileWatcher, mainProjectOptions, frameworkSelector: null, @@ -287,24 +301,24 @@ [.. updates.ProjectsToRebuild.Select(ProjectRepresentation.FromProjectOrEntryPoi // Changes made since last snapshot of the accumulator shouldn't be included in next Hot Reload update. // Apply them to the workspace. - _ = await CaptureChangedFilesSnapshot(updates.ProjectsToRebuild); + _ = await CaptureChangedFilesSnapshot(updatesBuilder.ProjectsToRebuild); - _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updates.ProjectsToRebuild.Count); + _context.Logger.Log(MessageDescriptor.ProjectsRebuilt, updatesBuilder.ProjectsToRebuild.Count); } // Deploy dependencies after rebuilding and before restarting. - if (updates.ProjectsToRedeploy is not []) + if (updatesBuilder.ProjectsToRedeploy is not []) { - await DeployProjectDependenciesAsync(evaluationResult, updates.ProjectsToRedeploy, iterationCancellationToken); - _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updates.ProjectsToRedeploy.Count); + await DeployProjectDependenciesAsync(evaluationResult, updatesBuilder.ProjectsToRedeploy, iterationCancellationToken); + _context.Logger.Log(MessageDescriptor.ProjectDependenciesDeployed, updatesBuilder.ProjectsToRedeploy.Count); } // Apply updates only after dependencies have been deployed, // so that updated code doesn't attempt to access the dependency before it has been deployed. - await compilationHandler.ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync(updates.ManagedCodeUpdates, updates.StaticAssetsToUpdate, changedFiles, evaluationResult.ProjectGraph, stopwatch, iterationCancellationToken); - if (updates.ProjectsToRestart is not []) + await runningProjectsManager.ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync(updatesBuilder, changedFiles, evaluationResult.ProjectGraph, stopwatch, iterationCancellationToken); + if (updatesBuilder.ProjectsToRestart is not []) { - await compilationHandler.RestartPeripheralProjectsAsync(updates.ProjectsToRestart, shutdownCancellationToken); + await runningProjectsManager.RestartPeripheralProjectsAsync(updatesBuilder.ProjectsToRestart, shutdownCancellationToken); } async Task> CaptureChangedFilesSnapshot(IReadOnlyList rebuiltProjects) @@ -375,7 +389,7 @@ async Task> CaptureChangedFilesSnapshot(IReadOnlyLis // additional files/directories may have been added: evaluationResult.WatchFileItems(fileWatcher); - await compilationHandler.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); + await workspace.UpdateProjectGraphAsync(evaluationResult.ProjectGraph.Graph, iterationCancellationToken); if (shutdownCancellationToken.IsCancellationRequested) { @@ -427,7 +441,7 @@ async Task> CaptureChangedFilesSnapshot(IReadOnlyLis { // Update the workspace to reflect changes in the file content:. // If the project was re-evaluated the Roslyn solution is already up to date. - await compilationHandler.UpdateFileContentAsync(changedFiles, iterationCancellationToken); + await workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } return [.. changedFiles]; @@ -457,11 +471,10 @@ async Task> CaptureChangedFilesSnapshot(IReadOnlyLis await runtimeProcessLauncher.DisposeAsync(); } - if (compilationHandler != null) - { - // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. - await compilationHandler.TerminatePeripheralProcessesAndDispose(CancellationToken.None); - } + // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. + await runningProjectsManager.TerminatePeripheralProcesses(CancellationToken.None); + + workspace?.Dispose(); if (mainRunningProject != null) { diff --git a/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs b/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs deleted file mode 100644 index ae5ef0168787..000000000000 --- a/src/Dotnet.Watch/Watch/HotReload/HotReloadProjectUpdatesBuilder.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; -using Microsoft.DotNet.HotReload; - -namespace Microsoft.DotNet.Watch; - -internal sealed class HotReloadProjectUpdatesBuilder -{ - public List ManagedCodeUpdates { get; } = []; - public Dictionary> StaticAssetsToUpdate { get; } = []; - public List ProjectsToRebuild { get; } = []; - public List ProjectsToRedeploy { get; } = []; - public List ProjectsToRestart { get; } = []; -} diff --git a/src/Dotnet.Watch/Watch/HotReload/ManagedCodeSnapshot.cs b/src/Dotnet.Watch/Watch/HotReload/ManagedCodeSnapshot.cs new file mode 100644 index 000000000000..6e98db6bc983 --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/ManagedCodeSnapshot.cs @@ -0,0 +1,14 @@ +// 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 Microsoft.Build.Execution; +using Microsoft.CodeAnalysis; + +namespace Microsoft.DotNet.Watch; + +internal readonly struct ManagedCodeSnapshot +{ + public Solution Solution { get; init; } + public ImmutableDictionary> ProjectInstances { get; init; } +} diff --git a/src/Dotnet.Watch/Watch/HotReload/ManagedCodeWorkspace.cs b/src/Dotnet.Watch/Watch/HotReload/ManagedCodeWorkspace.cs new file mode 100644 index 000000000000..e54df9f24c4a --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/ManagedCodeWorkspace.cs @@ -0,0 +1,145 @@ +// 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 Microsoft.Build.Execution; +using Microsoft.Build.Graph; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class ManagedCodeWorkspace : IDisposable +{ + private readonly ILogger _logger; + private readonly RunningProjectsManager _runningProjectsManager; + private readonly HotReloadMSBuildWorkspace _workspace; + public readonly HotReloadService HotReloadService; + + private int _solutionUpdateId; + + /// + /// Current set of project instances indexed by . + /// Updated whenever the project graph changes. + /// + private ImmutableDictionary> _projectInstances + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); + + public ManagedCodeWorkspace(ILogger logger, RunningProjectsManager runningProjectsManager) + { + _logger = logger; + _runningProjectsManager = runningProjectsManager; + _workspace = new HotReloadMSBuildWorkspace(logger, projectFile => (instances: _projectInstances.GetValueOrDefault(projectFile, []), project: null)); + HotReloadService = new HotReloadService(_workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); + } + + public void Dispose() + { + _workspace.Dispose(); + } + + public ManagedCodeSnapshot CurrentSnapshot + => new() + { + Solution = _workspace.CurrentSolution, + ProjectInstances = _projectInstances + }; + + private ImmutableArray GetAggregateCapabilities() + { + var capabilities = _runningProjectsManager.CurrentRunningProjects + .SelectMany(p => p.Value) + .SelectMany(p => p.ManagedCodeUpdateCapabilities) + .Distinct(StringComparer.Ordinal) + .Order() + .ToImmutableArray(); + + _logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities)); + return capabilities; + } + + public void PrepareCompilation(string projectPath, CancellationToken cancellationToken) + { + // Warm up the compilation. This would help make the deltas for first edit appear much more quickly. + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + foreach (var project in _workspace.CurrentSolution.Projects) + { + if (project.FilePath == projectPath) + { + // fire and forget: + _ = project.GetCompilationAsync(cancellationToken); + } + } + } + + public async ValueTask StartSessionAsync(ProjectGraph graph, CancellationToken cancellationToken) + { + var solution = await UpdateProjectGraphAsync(graph, cancellationToken); + + await HotReloadService.StartSessionAsync(solution, cancellationToken); + + _logger.Log(MessageDescriptor.HotReloadSessionStarted); + } + + public async Task 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; + } + + private static ImmutableDictionary> 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 UpdateFileContentAsync(IReadOnlyList 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())); + } + } +} diff --git a/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs new file mode 100644 index 000000000000..813b66de058e --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/ProjectUpdatesBuilder.cs @@ -0,0 +1,460 @@ +// 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.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 ProjectUpdatesBuilder +{ + public required ILogger Logger { get; init; } + public required HotReloadService HotReloadService { get; init; } + + // inputs: + public required ManagedCodeSnapshot ManagedCodeSnapshot { get; init; } + public required ImmutableDictionary> RunningProjects { get; init; } + + // outputs: + private readonly List _previousProjectUpdatesToDiscard = []; + private readonly List _managedCodeUpdates = []; + private readonly Dictionary> _staticAssetUpdates = []; + private readonly List _projectsToRebuild = []; + private readonly List _projectsToRedeploy = []; + private readonly List _projectsToRestart = []; + + public IReadOnlyList PreviousProjectUpdatesToDiscard => _previousProjectUpdatesToDiscard; + public IReadOnlyList ManagedCodeUpdates => _managedCodeUpdates; + public IReadOnlyDictionary> StaticAssetUpdates => _staticAssetUpdates; + public IReadOnlyList ProjectsToRebuild => _projectsToRebuild; + public IReadOnlyList ProjectsToRedeploy => _projectsToRedeploy; + public IReadOnlyList ProjectsToRestart => _projectsToRestart; + + private Solution Solution + => ManagedCodeSnapshot.Solution; + + public async ValueTask AddManagedCodeUpdatesAsync( + Func, CancellationToken, Task> restartPrompt, + bool autoRestart, + CancellationToken cancellationToken) + { + var runningProjectInfos = + (from project in Solution.Projects + let runningProject = GetCorrespondingRunningProjects(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(Solution, runningProjectInfos, cancellationToken); + + await DisplayResultsAsync(updates, 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 Solution.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(); + + _previousProjectUpdatesToDiscard.AddRange(updates.ProjectsToRebuild); + _managedCodeUpdates.AddRange(updates.ProjectUpdates); + _projectsToRebuild.AddRange(updates.ProjectsToRebuild.Select(GetRequiredProjectFilePath)); + _projectsToRedeploy.AddRange(updates.ProjectsToRedeploy.Select(GetRequiredProjectFilePath)); + + // Terminate all tracked processes that need to be restarted, + // except for the root process, which will terminate later on. + _projectsToRestart.AddRange( + updates.ProjectsToRestart.SelectMany(e => RunningProjects.TryGetValue(GetRequiredProjectFilePath(e.Key), out var array) ? array : [])); + } + + private string GetRequiredProjectFilePath(ProjectId projectId) + => (Solution.GetProject(projectId) ?? throw new InvalidOperationException()).FilePath ?? throw new InvalidOperationException(); + + private async ValueTask DisplayResultsAsync( + HotReloadService.Updates updates, + ImmutableDictionary 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(); + + // 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 GetMessageDescriptor(Diagnostic diagnostic) + { + if (diagnostic.Id == "ENC0118") + { + // Changing '' 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 s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; + + private static bool HasScopedCssTargets(ProjectInstance projectInstance) + => s_targets.All(projectInstance.Targets.ContainsKey); + + public async ValueTask AddStaticAssetUpdatesAsync( + IReadOnlyList files, + EvaluationResult evaluationResult, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var assets = new Dictionary>(); + var projectInstancesToRegenerate = new HashSet(); + + 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(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? 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(applicationProjectInstance)) + { + if (!_staticAssetUpdates.TryGetValue(runningProject, out var updatesPerRunningProject)) + { + _staticAssetUpdates.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). + _projectsToRebuild.Add(runningProject.ProjectNode.ProjectInstance.FullPath); + _projectsToRestart.Add(runningProject); + } + else + { + updatesPerRunningProject.AddRange(instanceAssets.Values); + } + } + } + } + + private IEnumerable GetCorrespondingRunningProjects(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 IEnumerable GetCorrespondingRunningProjects(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 (!ManagedCodeSnapshot.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)); + } +} diff --git a/src/Dotnet.Watch/Watch/HotReload/RunningProjectsManager.cs b/src/Dotnet.Watch/Watch/HotReload/RunningProjectsManager.cs new file mode 100644 index 000000000000..74660a9266e1 --- /dev/null +++ b/src/Dotnet.Watch/Watch/HotReload/RunningProjectsManager.cs @@ -0,0 +1,486 @@ +// 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.Evaluation; +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 RunningProjectsManager(ProcessRunner processRunner, ILogger logger) +{ + /// + /// Lock to synchronize: + /// + /// + /// + /// + private readonly object _runningProjectsAndUpdatesGuard = new(); + + /// + /// Projects that have been launched and to which we apply changes. + /// Maps to the list of running instances of that project. + /// + private ImmutableDictionary> _runningProjects + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); + + /// + /// Maps to the list of active restart operations for the project. + /// The of the project instance is added whenever a process crashes (terminated with non-zero exit code) + /// and the corresponding is removed from . + /// + /// When a file change is observed whose containing project is listed here, the associated relaunch operations are executed. + /// + private ImmutableDictionary> _activeProjectRelaunchOperations + = ImmutableDictionary>.Empty.WithComparers(PathUtilities.OSSpecificPathComparer); + + /// + /// All updates that were attempted. Includes updates whose application failed. + /// + private ImmutableList _previousUpdates = []; + + public ImmutableDictionary> CurrentRunningProjects + => _runningProjects; + + public async ValueTask TerminatePeripheralProcesses(CancellationToken cancellationToken) + { + logger.LogDebug("Terminating remaining child processes."); + + await TerminatePeripheralProcessesAsync([.. _runningProjects.SelectMany(entry => entry.Value)], cancellationToken); + } + + public async Task 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 = 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) + { + // 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); + } + + 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()); + } + } + } + + public async ValueTask ApplyManagedCodeAndStaticAssetUpdatesAndRelaunchAsync( + ProjectUpdatesBuilder builder, + ImmutableArray changedFiles, + LoadedProjectGraph projectGraph, + Stopwatch stopwatch, + CancellationToken cancellationToken) + { + var applyTasks = new List(); + ImmutableDictionary> projectsToUpdate = []; + + IReadOnlyList relaunchOperations; + lock (_runningProjectsAndUpdatesGuard) + { + // 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. + _previousUpdates = _previousUpdates.RemoveAll(update => builder.PreviousProjectUpdatesToDiscard.Contains(update.ProjectId)); + + // Adding the updates makes sure that all new processes receive them before they are added to running processes. + _previousUpdates = _previousUpdates.AddRange(builder.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. + logger.LogError("Failed to relaunch: {Exception}", e.ToString()); + } + }, cancellationToken); + } + + if (builder.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(builder.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>(); + + foreach (var (runningProject, assets) in builder.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 (builder.ManagedCodeUpdates.Count > 0) + { + logger.Log(MessageDescriptor.ManagedCodeChangesApplied, elapsedMilliseconds); + } + + if (builder.StaticAssetUpdates.Count > 0) + { + logger.Log(MessageDescriptor.StaticAssetsChangesApplied, elapsedMilliseconds); + } + + logger.Log(MessageDescriptor.ChangesAppliedToProjectsNotification, + projectsToUpdate.Select(e => e.Value.First().Options.Representation).Concat( + builder.StaticAssetUpdates.Select(e => e.Key.Options.Representation))); + } + catch (OperationCanceledException) + { + // nop + } + catch (Exception e) + { + // Handle all exceptions since this is a fire-and-forget task. + logger.LogError("Failed to apply managedCodeUpdates: {Exception}", e.ToString()); + } + } + } + + /// + /// Terminates all processes launched for peripheral projects with , + /// or all running peripheral project processes if is null. + /// + /// Removes corresponding entries from . + /// + /// Does not terminate the main project. + /// + /// All processes (including main) to be restarted. + internal async ValueTask TerminatePeripheralProcessesAsync( + IEnumerable projectsToRestart, CancellationToken cancellationToken) + { + // 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); + } + + /// + /// Restarts given projects after their process have been terminated via . + /// + internal async Task RestartPeripheralProjectsAsync(IReadOnlyList projectsToRestart, CancellationToken cancellationToken) + { + if (projectsToRestart.Any(p => p.Options.IsMainProject)) + { + throw new InvalidOperationException("Main project can't be restarted."); + } + + logger.Log(MessageDescriptor.RestartingProjectsNotification, projectsToRestart.Select(p => p.Options.Representation)); + + await Task.WhenAll( + projectsToRestart.Select(async runningProject => runningProject.RestartAsync(cancellationToken))) + .WaitAsync(cancellationToken); + + logger.Log(MessageDescriptor.ProjectsRestarted, projectsToRestart.Count); + } + + internal IEnumerable GetRunningProjects(IEnumerable projects) + { + var runningProjects = _runningProjects; + return projects.SelectMany(project => runningProjects.TryGetValue(project.ProjectGraphPath, out var array) ? array : []); + } + + 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 GetRelaunchOperations_NoLock(IReadOnlyList changedFiles, LoadedProjectGraph projectGraph) + { + if (_activeProjectRelaunchOperations.IsEmpty) + { + return []; + } + + var relaunchOperations = new List(); + 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 ImmutableArray ToManagedCodeUpdates(IEnumerable updates) + => [.. updates.Select(update => new HotReloadManagedCodeUpdate(update.ModuleId, update.MetadataDelta, update.ILDelta, update.PdbDelta, update.UpdatedTypes, update.RequiredCapabilities))]; +} diff --git a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs index 8b4b1707fefc..130e11824814 100644 --- a/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs +++ b/src/Dotnet.Watch/Watch/Process/ProjectLauncher.cs @@ -12,7 +12,7 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectLauncher( DotNetWatchContext context, LoadedProjectGraph projectGraph, - CompilationHandler compilationHandler, + RunningProjectsManager runningProjectsManager, int iteration) { public int Iteration = iteration; @@ -26,8 +26,8 @@ public ILoggerFactory LoggerFactory public EnvironmentOptions EnvironmentOptions => context.EnvironmentOptions; - public CompilationHandler CompilationHandler - => compilationHandler; + public RunningProjectsManager RunningProjectsManager + => runningProjectsManager; public async ValueTask TryLaunchProcessAsync( ProjectOptions projectOptions, @@ -87,7 +87,7 @@ public CompilationHandler CompilationHandler processSpec.RedirectOutput(outputObserver, context.ProcessOutputReporter, context.EnvironmentOptions, projectDisplayName); - return await compilationHandler.TrackRunningProjectAsync( + return await runningProjectsManager.TrackRunningProjectAsync( projectNode, projectOptions, clients, diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index 3f50d195e0b4..a8c78b2bd658 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -28,32 +28,17 @@ public async Task ReferenceOutputAssembly_False() var processOutputReporter = new TestProcessOutputReporter(); - var context = new DotNetWatchContext() - { - ProcessOutputReporter = processOutputReporter, - Logger = NullLogger.Instance, - BuildLogger = NullLogger.Instance, - LoggerFactory = NullLoggerFactory.Instance, - ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), - Options = new(), - MainProjectOptions = TestOptions.ProjectOptions, - RootProjects = [hostProjectRepr], - BuildArguments = [], - EnvironmentOptions = environmentOptions, - BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), - BrowserRefreshServerFactory = new BrowserRefreshServerFactory() - }; - - var handler = new CompilationHandler(context); - - await handler.UpdateProjectGraphAsync(projectGraph.Graph, CancellationToken.None); + var handler = new RunningProjectsManager(new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), NullLogger.Instance); + var workspace = new ManagedCodeWorkspace(NullLogger.Instance, handler); + + var solution = await workspace.UpdateProjectGraphAsync(projectGraph.Graph, CancellationToken.None); // all projects are present - AssertEx.SequenceEqual(["Host", "Lib2", "Lib", "A", "B"], handler.Workspace.CurrentSolution.Projects.Select(p => p.Name)); + AssertEx.SequenceEqual(["Host", "Lib2", "Lib", "A", "B"], solution.Projects.Select(p => p.Name)); // Host does not have project reference to A, B: AssertEx.SequenceEqual(["Lib2"], - handler.Workspace.CurrentSolution.Projects.Single(p => p.Name == "Host").ProjectReferences - .Select(r => handler.Workspace.CurrentSolution.GetProject(r.ProjectId)!.Name)); + solution.Projects.Single(p => p.Name == "Host").ProjectReferences + .Select(r => solution.GetProject(r.ProjectId)!.Name)); } } diff --git a/test/dotnet-watch.Tests/HotReload/MobileHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/MobileHotReloadTests.cs index 6a99baab6982..40efbf92e426 100644 --- a/test/dotnet-watch.Tests/HotReload/MobileHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/MobileHotReloadTests.cs @@ -22,8 +22,8 @@ public async Task HotReload_WithWebSocketCapability() App.Start(testAsset, []); - await App.WaitForOutputLineContaining("Started"); - await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + await App.WaitUntilOutputContains("Started"); + await App.WaitUntilOutputContains(MessageDescriptor.WaitingForChanges); // Verify the app is detected as requiring WebSocket transport with a dynamically assigned port await App.WaitUntilOutputContains(MessageDescriptor.ApplicationKind_WebSockets);