From bd7bb161acb7fb0190705d51eff4f8bb3c073f4d Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 15 Jul 2025 16:22:03 -0700 Subject: [PATCH] Implement support for changing project and package references --- .../DotNetDeltaApplier/StartupHook.cs | 21 +++++ .../dotnet-watch/Build/BuildNames.cs | 2 + .../HotReload/CompilationHandler.cs | 16 ++-- .../HotReload/HotReloadDotNetWatcher.cs | 89 +++++++++++++++++-- .../HotReload/IncrementalMSBuildWorkspace.cs | 5 +- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 1 + .../AppWithDeps/Program.cs | 7 +- .../WatchAppWithProjectDeps/Dependency/Foo.cs | 1 + .../HotReload/ApplyDeltaTests.cs | 71 +++++++++++++++ 9 files changed, 198 insertions(+), 15 deletions(-) diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index bff71aeaee23..c222766c9db6 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using System.IO.Pipes; +using System.Reflection; +using System.Runtime.Loader; using Microsoft.DotNet.HotReload; /// @@ -19,12 +21,15 @@ internal sealed class StartupHook private static PosixSignalRegistration? s_signalRegistration; #endif + private static Func? s_assemblyResolvingEventHandler; + /// /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. /// public static void Initialize() { var processPath = Environment.GetCommandLineArgs().FirstOrDefault(); + var processDir = Path.GetDirectoryName(processPath)!; Log($"Loaded into process: {processPath} ({typeof(StartupHook).Assembly.Location})"); @@ -60,6 +65,14 @@ public static void Initialize() RegisterPosixSignalHandlers(); + // prepare handler, it will be installed on first managed update: + s_assemblyResolvingEventHandler = (_, args) => + { + Log($"Resolving {args.Name}"); + var path = Path.Combine(processDir, args.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }; + var agent = new HotReloadAgent(); try { @@ -126,6 +139,14 @@ private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipe // Shouldn't get initial managed code updates when the debugger is attached. // The debugger itself applies these updates when launching process with the debugger attached. Debug.Assert(!Debugger.IsAttached); + + var handler = s_assemblyResolvingEventHandler; + if (handler != null) + { + AssemblyLoadContext.Default.Resolving += handler; + s_assemblyResolvingEventHandler = null; + } + await ReadAndApplyManagedCodeUpdateAsync(pipeClient, agent, cancellationToken); break; diff --git a/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs index a49547f3948d..05f41d26e645 100644 --- a/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs +++ b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs @@ -37,6 +37,7 @@ internal static class ItemNames internal static class MetadataNames { public const string Watch = nameof(Watch); + public const string TargetPath = nameof(TargetPath); } internal static class TargetNames @@ -44,4 +45,5 @@ internal static class TargetNames public const string Compile = nameof(Compile); public const string Restore = nameof(Restore); public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); + public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 07602bb15e78..a450ee9e4aa2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -237,7 +237,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C } } - public async ValueTask<(ImmutableDictionary projectsToRebuild, ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( + public async ValueTask<( + ImmutableArray projectsToRebuild, + ImmutableArray projectsToRedeploy, + ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( bool autoRestart, Func, CancellationToken, Task> restartPrompt, CancellationToken cancellationToken) @@ -263,7 +266,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C // changes and await the next file change. // Note: CommitUpdate/DiscardUpdate is not expected to be called. - return ([], []); + return ([], [], []); } var projectsToPromptForRestart = @@ -279,7 +282,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C _reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥"); await Task.Delay(-1, cancellationToken); - return ([], []); + return ([], [], []); } if (!updates.ProjectUpdates.IsEmpty) @@ -325,7 +328,10 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT DiscardPreviousUpdates(updates.ProjectsToRebuild); - var projectsToRebuild = updates.ProjectsToRebuild.ToImmutableDictionary(keySelector: id => id, elementSelector: id => currentSolution.GetProject(id)!.FilePath!); + var projectsToRebuild = updates.ProjectsToRebuild.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + // TODO: needs Roslyn update + // var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); + var projectsToRedeploy = ImmutableArray.Empty; // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. @@ -333,7 +339,7 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT ? [] : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - return (projectsToRebuild, terminatedProjects); + return (projectsToRebuild, projectsToRedeploy, terminatedProjects); } private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary> runningProjects) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 705a7abcb867..0655164efb82 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -3,6 +3,8 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.Build.Execution; +using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; namespace Microsoft.DotNet.Watch @@ -240,7 +242,7 @@ void FileChangedCallback(ChangedPath change) extendTimeout = false; - var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: null); + var changedFiles = await CaptureChangedFilesSnapshot(rebuiltProjects: []); if (changedFiles is []) { continue; @@ -269,7 +271,7 @@ void FileChangedCallback(ChangedPath change) HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); - var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( + var (projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( autoRestart: _context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true, restartPrompt: async (projectNames, cancellationToken) => { @@ -334,7 +336,7 @@ void FileChangedCallback(ChangedPath change) try { var buildResults = await Task.WhenAll( - projectsToRebuild.Values.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken))); + projectsToRebuild.Select(projectPath => BuildProjectAsync(projectPath, rootProjectOptions.BuildArguments, iterationCancellationToken))); foreach (var (success, output, projectPath) in buildResults) { @@ -363,7 +365,14 @@ void FileChangedCallback(ChangedPath change) // Apply them to the workspace. _ = await CaptureChangedFilesSnapshot(projectsToRebuild); - _context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Count); + _context.Reporter.Report(MessageDescriptor.ProjectsRebuilt, projectsToRebuild.Length); + } + + // Deploy dependencies after rebuilding and before restarting. + if (!projectsToRedeploy.IsEmpty) + { + DeployProjectDependencies(evaluationResult.ProjectGraph, projectsToRedeploy, iterationCancellationToken); + _context.Reporter.Report(MessageDescriptor.ProjectDependenciesDeployed, projectsToRedeploy.Length); } if (!projectsToRestart.IsEmpty) @@ -393,7 +402,7 @@ await Task.WhenAll( _context.Reporter.Report(MessageDescriptor.HotReloadChangeHandled, stopwatch.ElapsedMilliseconds); - async Task> CaptureChangedFilesSnapshot(ImmutableDictionary? rebuiltProjects) + async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) { var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); if (changedPaths is []) @@ -464,12 +473,12 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict _context.Reporter.Report(MessageDescriptor.ReEvaluationCompleted); } - if (rebuiltProjects != null) + if (!rebuiltProjects.IsEmpty) { // Filter changed files down to those contained in projects being rebuilt. // File changes that affect projects that are not being rebuilt will stay in the accumulator // and be included in the next Hot Reload change set. - var rebuiltProjectPaths = rebuiltProjects.Values.ToHashSet(); + var rebuiltProjectPaths = rebuiltProjects.ToHashSet(); var newAccumulator = ImmutableList.Empty; var newChangedFiles = ImmutableList.Empty; @@ -555,6 +564,72 @@ async Task> CaptureChangedFilesSnapshot(ImmutableDict } } + private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray projectPaths, CancellationToken cancellationToken) + { + var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); + var buildReporter = new BuildReporter(_context.Reporter, _context.EnvironmentOptions); + var targetName = TargetNames.ReferenceCopyLocalPathsOutputGroup; + + foreach (var node in graph.ProjectNodes) + { + cancellationToken.ThrowIfCancellationRequested(); + + var projectPath = node.ProjectInstance.FullPath; + + if (!projectPathSet.Contains(projectPath)) + { + continue; + } + + if (!node.ProjectInstance.Targets.ContainsKey(targetName)) + { + continue; + } + + if (node.GetOutputDirectory() is not { } relativeOutputDir) + { + continue; + } + + using var loggers = buildReporter.GetLoggers(projectPath, targetName); + if (!node.ProjectInstance.Build([targetName], loggers, out var targetOutputs)) + { + _context.Reporter.Verbose($"{targetName} target failed"); + loggers.ReportOutput(); + continue; + } + + var outputDir = Path.Combine(Path.GetDirectoryName(projectPath)!, relativeOutputDir); + + foreach (var item in targetOutputs[targetName].Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sourcePath = item.ItemSpec; + var targetPath = Path.Combine(outputDir, item.GetMetadata(MetadataNames.TargetPath)); + if (!File.Exists(targetPath)) + { + _context.Reporter.Verbose($"Deploying project dependency '{targetPath}' from '{sourcePath}'"); + + try + { + var directory = Path.GetDirectoryName(targetPath); + if (directory != null) + { + Directory.CreateDirectory(directory); + } + + File.Copy(sourcePath, targetPath, overwrite: false); + } + catch (Exception e) + { + _context.Reporter.Verbose($"Copy failed: {e.Message}"); + } + } + } + } + } + private async ValueTask WaitForFileChangeBeforeRestarting(FileWatcher fileWatcher, EvaluationResult? evaluationResult, CancellationToken cancellationToken) { if (evaluationResult != null) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs b/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs index 936b2485455e..39aba156e6e3 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs @@ -95,8 +95,9 @@ public async Task UpdateProjectConeAsync(string rootProjectPath, CancellationTok UpdateReferencesAfterAdd(); ProjectReference MapProjectReference(ProjectReference pr) - // Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing: - => new(projectIdMap.TryGetValue(pr.ProjectId, out var mappedId) ? mappedId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes); + // Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing. + // When a new project is added along with a new project reference the old project id is also null. + => new(projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId, pr.Aliases, pr.EmbedInteropTypes); ImmutableArray MapDocuments(ProjectId mappedProjectId, IReadOnlyList documents) => documents.Select(docInfo => diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index 2422074157aa..eca73a1bfb6e 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -65,6 +65,7 @@ public MessageDescriptor ToErrorWhen(bool condition) public static readonly MessageDescriptor HotReloadSessionStarted = new("Hot reload session started.", HotReloadEmoji, MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor ProjectsRebuilt = new("Projects rebuilt ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor ProjectsRestarted = new("Projects restarted ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++); + public static readonly MessageDescriptor ProjectDependenciesDeployed = new("Project dependencies deployed ({0})", HotReloadEmoji, MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor FixBuildError = new("Fix the error to continue or press Ctrl+C to exit.", WatchEmoji, MessageSeverity.Warning, s_id++); public static readonly MessageDescriptor WaitingForChanges = new("Waiting for changes", WatchEmoji, MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor LaunchedProcess = new("Launched '{0}' with arguments '{1}': process id {2}", LaunchEmoji, MessageSeverity.Verbose, s_id++); diff --git a/test/TestAssets/TestProjects/WatchAppWithProjectDeps/AppWithDeps/Program.cs b/test/TestAssets/TestProjects/WatchAppWithProjectDeps/AppWithDeps/Program.cs index d99e06b88044..30ec714824cd 100644 --- a/test/TestAssets/TestProjects/WatchAppWithProjectDeps/AppWithDeps/Program.cs +++ b/test/TestAssets/TestProjects/WatchAppWithProjectDeps/AppWithDeps/Program.cs @@ -19,9 +19,14 @@ public static void Main(string[] args) while (true) { - Lib.Print(); + CallLib(); Thread.Sleep(1000); } } + + public static void CallLib() + { + Lib.Print(); + } } } diff --git a/test/TestAssets/TestProjects/WatchAppWithProjectDeps/Dependency/Foo.cs b/test/TestAssets/TestProjects/WatchAppWithProjectDeps/Dependency/Foo.cs index 8aa7e072271c..01fcf847c6e7 100644 --- a/test/TestAssets/TestProjects/WatchAppWithProjectDeps/Dependency/Foo.cs +++ b/test/TestAssets/TestProjects/WatchAppWithProjectDeps/Dependency/Foo.cs @@ -4,5 +4,6 @@ public class Lib { public static void Print() { + System.Console.WriteLine(""); } } diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 95c258b65945..2199d67ab0ee 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -236,6 +236,77 @@ public static void Print() await App.AssertOutputLineStartsWith("BUILD_CONST_IN_PROPS not set"); } + [Fact] + public async Task ProjectChange_AddProjectReference() + { + var testAsset = TestAssets.CopyTestAsset("WatchAppWithProjectDeps") + .WithSource() + .WithProjectChanges(project => + { + foreach (var r in project.Root!.Descendants().Where(e => e.Name.LocalName == "ProjectReference").ToArray()) + { + r.Remove(); + } + }); + + var appProjDir = Path.Combine(testAsset.Path, "AppWithDeps"); + var appProjFile = Path.Combine(appProjDir, "App.WithDeps.csproj"); + var appFile = Path.Combine(appProjDir, "Program.cs"); + + UpdateSourceFile(appFile, code => code.Replace("Lib.Print();", "// Lib.Print();")); + + App.Start(testAsset, [], "AppWithDeps"); + + await App.AssertWaitingForChanges(); + + UpdateSourceFile(appProjFile, src => src.Replace(""" + + """, """ + + + + """)); + + UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();")); + + await App.WaitUntilOutputContains(""); + + App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"AppWithDeps ({ToolsetInfo.CurrentTargetFramework})"); + App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + } + + [Fact] + public async Task ProjectChange_AddPackageReference() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); + var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); + + App.Start(testAsset, []); + + await App.AssertWaitingForChanges(); + App.Process.ClearOutput(); + + UpdateSourceFile(projFilePath, source => source.Replace(""" + + """, """ + + """)); + + UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));")); + + await App.WaitUntilOutputContains("Newtonsoft.Json.Linq.JToken"); + + var binDirDll = Path.Combine(testAsset.Path, "bin", "Debug", ToolsetInfo.CurrentTargetFramework, "Newtonsoft.Json.dll"); + + App.AssertOutputContains($"Deploying project dependency '{binDirDll}'"); + App.AssertOutputContains(MessageDescriptor.HotReloadSucceeded, $"WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})"); + App.AssertOutputContains(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + App.AssertOutputContains("Resolving Newtonsoft.Json"); + } + [Fact] public async Task DefaultItemExcludes_DefaultItemsEnabled() {