From 8258e1d182f04f364e2a2f55c4810980db1fa26c 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 | 93 ++++++----- .../HotReload/HotReloadDotNetWatcher.cs | 96 +++++++++++- .../HotReload/IncrementalMSBuildWorkspace.cs | 5 +- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 1 + .../AppWithDeps/Program.cs | 7 +- .../WatchAppWithProjectDeps/Dependency/Foo.cs | 1 + .../HotReload/RuntimeProcessLauncherTests.cs | 147 ++++++++++++++++-- 9 files changed, 310 insertions(+), 63 deletions(-) diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 744ffdf2027e..c23470bb5721 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; using Microsoft.DotNet.Watch; @@ -20,12 +22,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})"); @@ -61,6 +66,14 @@ public static void Initialize() RegisterSignalHandlers(); + // prepare handler, it will be installed on first managed update: + s_assemblyResolvingEventHandler = (_, args) => + { + Log($"Resolving '{args.Name}, Version={args.Version}'"); + var path = Path.Combine(processDir, args.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }; + var agent = new HotReloadAgent(); try { @@ -134,6 +147,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 375d8ab34503..990155f69851 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -232,7 +232,11 @@ private static void PrepareCompilations(Solution solution, string projectPath, C } } - public async ValueTask<(ImmutableDictionary projectsToRebuild, ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( + public async ValueTask<( + ImmutableArray projectUpdates, + ImmutableArray projectsToRebuild, + ImmutableArray projectsToRedeploy, + ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( bool autoRestart, Func, CancellationToken, Task> restartPrompt, CancellationToken cancellationToken) @@ -258,7 +262,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 = @@ -274,45 +278,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 ([], []); - } - - if (!updates.ProjectUpdates.IsEmpty) - { - ImmutableDictionary> projectsToUpdate; - lock (_runningProjectsAndUpdatesGuard) - { - // Adding the updates makes sure that all new processes receive them before they are added to running processes. - _previousUpdates = _previousUpdates.AddRange(updates.ProjectUpdates); - - // Capture the set of processes that do not have the currently calculated deltas yet. - projectsToUpdate = _runningProjects; - } - - // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. - // The process may load any of the binaries using MEF or some other runtime dependency loader. - - await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) => - { - try - { - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken); - var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed; - if (applySucceded) - { - runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded); - if (runningProject.BrowserRefreshServer is { } server) - { - runningProject.Reporter.Verbose("Refreshing browser."); - await server.RefreshBrowserAsync(cancellationToken); - } - } - } - catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥"); - } - }, cancellationToken); + return ([], [], [], []); } // Note: Releases locked project baseline readers, so we can rebuild any projects that need rebuilding. @@ -320,7 +286,8 @@ 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(); + var projectsToRedeploy = updates.ProjectsToRedeploy.Select(id => currentSolution.GetProject(id)!.FilePath!).ToImmutableArray(); // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. @@ -328,7 +295,47 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT ? [] : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - return (projectsToRebuild, terminatedProjects); + return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, terminatedProjects); + } + + public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, CancellationToken cancellationToken) + { + Debug.Assert(!updates.IsEmpty); + + ImmutableDictionary> projectsToUpdate; + lock (_runningProjectsAndUpdatesGuard) + { + // Adding the updates makes sure that all new processes receive them before they are added to running processes. + _previousUpdates = _previousUpdates.AddRange(updates); + + // Capture the set of processes that do not have the currently calculated deltas yet. + projectsToUpdate = _runningProjects; + } + + // Apply changes to all running projects, even if they do not have a static project dependency on any project that changed. + // The process may load any of the binaries using MEF or some other runtime dependency loader. + + await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) => + { + try + { + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken); + var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed; + if (applySucceded) + { + runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded); + if (runningProject.BrowserRefreshServer is { } server) + { + runningProject.Reporter.Verbose("Refreshing browser."); + await server.RefreshBrowserAsync(cancellationToken); + } + } + } + catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥"); + } + }, cancellationToken); } 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 a8408fed7051..a35f1e4242ef 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 (managedCodeUpdates, 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,21 @@ 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); + } + + // Apply updates only after dependencies have been deployed, + // so that updated code doesn't attempt to access the dependency before it has been deployed. + if (!managedCodeUpdates.IsEmpty) + { + await compilationHandler.ApplyUpdatesAsync(managedCodeUpdates, iterationCancellationToken); } if (!projectsToRestart.IsEmpty) @@ -393,7 +409,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 +480,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 +571,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.Options, _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 86c44aab759c..3aada29c9be6 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/IncrementalMSBuildWorkspace.cs @@ -97,8 +97,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 c346c901b49b..b5bc342a88a3 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/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 5b2329f4382c..9d8216e971a3 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; namespace Microsoft.DotNet.Watch.UnitTests; @@ -90,15 +91,15 @@ private static async Task Launch(string projectPath, TestRuntime return await startOp(cancellationToken); } - private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string workingDirectory, string projectPath, SemaphoreSlim? fileChangesCompleted = null) + private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string? workingDirectory = null) { var console = new TestConsole(Logger); var reporter = new TestReporter(Logger); - var environmentOptions = TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset); + var environmentOptions = TestOptions.GetEnvironmentOptions(workingDirectory ?? testAsset.Path, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset); var processRunner = new ProcessRunner(environmentOptions.ProcessCleanupTimeout); var program = Program.TryCreate( - TestOptions.GetCommandLineOptions(["--verbose", ..args, "--project", projectPath]), + TestOptions.GetCommandLineOptions(["--verbose", ..args]), console, environmentOptions, reporter, @@ -154,7 +155,7 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) var libProject = Path.Combine(libDir, "Lib.csproj"); var libSource = Path.Combine(libDir, "Lib.cs"); - await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject); + await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); var launchCompletionA = w.CreateCompletionSource(); var launchCompletionB = w.CreateCompletionSource(); @@ -317,7 +318,7 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) var libProject = Path.Combine(libDir, "Lib.csproj"); var libSource = Path.Combine(libDir, "Lib.cs"); - await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject); + await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled); @@ -407,7 +408,7 @@ public async Task HostRestart(UpdateLocation updateLocation) var libProject = Path.Combine(testAsset.Path, "Lib2", "Lib2.csproj"); var lib = Path.Combine(testAsset.Path, "Lib2", "Lib2.cs"); - await using var w = StartWatcher(testAsset, args: [], workingDirectory, hostProject); + await using var w = StartWatcher(testAsset, args: ["--project", hostProject], workingDirectory); var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled); @@ -496,7 +497,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs"); var serviceProjectA = Path.Combine(serviceDirA, "A.csproj"); - await using var w = StartWatcher(testAsset, ["--non-interactive"], workingDirectory, hostProject); + await using var w = StartWatcher(testAsset, ["--non-interactive", "--project", hostProject], workingDirectory); var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); @@ -588,7 +589,7 @@ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind } } - await using var w = StartWatcher(testAsset, ["--no-exit"], workingDirectory, workingDirectory); + await using var w = StartWatcher(testAsset, ["--no-exit"], workingDirectory); var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled); @@ -646,7 +647,7 @@ public async Task ProjectAndSourceFileChange() var projectPath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); var programPath = Path.Combine(testAsset.Path, "Program.cs"); - await using var w = StartWatcher(testAsset, [], workingDirectory, projectPath); + await using var w = StartWatcher(testAsset, [], workingDirectory); var fileChangesCompleted = w.CreateCompletionSource(); w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; @@ -664,7 +665,7 @@ public async Task ProjectAndSourceFileChange() } }; - // let the host process start: + // start process: Log("Waiting for changes..."); await waitingForChanges.WaitAsync(w.ShutdownSource.Token); @@ -682,4 +683,130 @@ public async Task ProjectAndSourceFileChange() Log("Waiting for output 'System.Xml.Linq.XDocument'..."); await hasUpdatedOutput.Task; } + + [Fact] + public async Task ProjectAndSourceFileChange_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();")); + + await using var w = StartWatcher(testAsset, [], appProjDir); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var projectChangeTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + var projectsRebuilt = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var projectDependenciesDeployed = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); + var hotReloadSucceeded = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSucceeded); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + // start process: + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(appProjFile, src => src.Replace(""" + + """, """ + + + + """)); + + UpdateSourceFile(appFile, code => code.Replace("// Lib.Print();", "Lib.Print();")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for output ''..."); + await hasUpdatedOutput.Task; + + AssertEx.ContainsSubstring("Resolving 'Dependency, Version=1.0.0.0'", w.Reporter.ProcessOutput); + + Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); + Assert.Equal(1, projectsRebuilt.CurrentCount); + Assert.Equal(1, projectDependenciesDeployed.CurrentCount); + Assert.Equal(1, hotReloadSucceeded.CurrentCount); + } + + [Fact] + public async Task ProjectAndSourceFileChange_AddPackageReference() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var projFilePath = Path.Combine(testAsset.Path, "WatchHotReloadApp.csproj"); + var programFilePath = Path.Combine(testAsset.Path, "Program.cs"); + + await using var w = StartWatcher(testAsset, []); + + var fileChangesCompleted = w.CreateCompletionSource(); + w.Watcher.Test_FileChangesCompletedTask = fileChangesCompleted.Task; + + var waitingForChanges = w.Reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + var projectChangeTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectChangeTriggeredReEvaluation); + var projectsRebuilt = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectsRebuilt); + var projectDependenciesDeployed = w.Reporter.RegisterSemaphore(MessageDescriptor.ProjectDependenciesDeployed); + var hotReloadSucceeded = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSucceeded); + + var hasUpdatedOutput = w.CreateCompletionSource(); + w.Reporter.OnProcessOutput += line => + { + if (line.Content.Contains("Newtonsoft.Json.Linq.JToken")) + { + hasUpdatedOutput.TrySetResult(); + } + }; + + // start process: + Log("Waiting for changes..."); + await waitingForChanges.WaitAsync(w.ShutdownSource.Token); + + // change the project and source files at the same time: + + UpdateSourceFile(projFilePath, source => source.Replace(""" + + """, """ + + """)); + + UpdateSourceFile(programFilePath, source => source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(typeof(Newtonsoft.Json.Linq.JToken));")); + + // done updating files: + fileChangesCompleted.TrySetResult(); + + Log("Waiting for output 'Newtonsoft.Json.Linq.JToken'..."); + await hasUpdatedOutput.Task; + + AssertEx.ContainsSubstring("Resolving 'Newtonsoft.Json, Version=13.0.0.0'", w.Reporter.ProcessOutput); + + Assert.Equal(1, projectChangeTriggeredReEvaluation.CurrentCount); + Assert.Equal(0, projectsRebuilt.CurrentCount); + Assert.Equal(1, projectDependenciesDeployed.CurrentCount); + Assert.Equal(1, hotReloadSucceeded.CurrentCount); + } }