Skip to content

Commit 67f43d9

Browse files
tmatDustinCampbell
andauthored
Implement auto-restarting on rude edit or no-effect change (#48752)
Co-authored-by: Dustin Campbell <dustin@teamcampbell.org>
1 parent 86317d7 commit 67f43d9

File tree

9 files changed

+227
-110
lines changed

9 files changed

+227
-110
lines changed

src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs

Lines changed: 132 additions & 100 deletions
Large diffs are not rendered by default.

src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
100100
}
101101

102102
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter);
103-
compilationHandler = new CompilationHandler(Context.Reporter, Context.EnvironmentOptions, shutdownCancellationToken);
103+
compilationHandler = new CompilationHandler(Context.Reporter, Context.EnvironmentOptions, Context.Options, shutdownCancellationToken);
104104
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
105105
var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration);
106106
var outputDirectories = GetProjectOutputDirectories(evaluationResult.ProjectGraph);

src/BuiltInTools/dotnet-watch/Internal/IReporter.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ public bool TryGetMessage(string? prefix, object?[] args, [NotNullWhen(true)] ou
7474
public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = new("Ignoring change in hidden directory '{0}': {1} '{2}'", "⌚", MessageSeverity.Verbose, s_id++);
7575
public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = new("Ignoring change in output directory: {0} '{1}'", "⌚", MessageSeverity.Verbose, s_id++);
7676
public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = new("File addition triggered re-evaluation.", "⌚", MessageSeverity.Verbose, s_id++);
77-
public static readonly MessageDescriptor NoCSharpChangesToApply = new ("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++);
77+
public static readonly MessageDescriptor NoCSharpChangesToApply = new("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++);
78+
public static readonly MessageDescriptor Exited = new("Exited", "⌚", MessageSeverity.Output, s_id++);
79+
public static readonly MessageDescriptor ExitedWithUnknownErrorCode = new("Exited with unknown error code", "❌", MessageSeverity.Error, s_id++);
80+
public static readonly MessageDescriptor ExitedWithErrorCode = new("Exited with error code {0}", "❌", MessageSeverity.Error, s_id++);
7881
}
7982

8083
internal interface IReporter

src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,15 @@ public static async Task<int> RunAsync(ProcessSpec processSpec, IReporter report
154154
{
155155
if (exitCode == 0)
156156
{
157-
reporter.Output("Exited");
157+
reporter.Report(MessageDescriptor.Exited);
158158
}
159159
else if (exitCode == null)
160160
{
161-
reporter.Error("Exited with unknown error code");
161+
reporter.Report(MessageDescriptor.ExitedWithUnknownErrorCode);
162162
}
163163
else
164164
{
165-
reporter.Error($"Exited with error code {exitCode}");
165+
reporter.Report(MessageDescriptor.ExitedWithErrorCode, exitCode);
166166
}
167167
}
168168

src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public static string GetAssemblyName(this ProjectGraphNode projectNode)
4545
public static IEnumerable<string> GetCapabilities(this ProjectGraphNode projectNode)
4646
=> projectNode.ProjectInstance.GetItems("ProjectCapability").Select(item => item.EvaluatedInclude);
4747

48+
public static bool IsAutoRestartEnabled(this ProjectGraphNode projectNode)
49+
=> bool.TryParse(projectNode.ProjectInstance.GetPropertyValue("HotReloadAutoRestart"), out var result) && result;
50+
4851
public static IEnumerable<ProjectGraphNode> GetTransitivelyReferencingProjects(this IEnumerable<ProjectGraphNode> projects)
4952
{
5053
var visited = new HashSet<ProjectGraphNode>();

test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@
3030
await Task.Delay(1000);
3131
}
3232

33-
class C { }
33+
class C { /* member placeholder */ }

test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,83 @@ public static void Print()
6767
await App.AssertOutputLineStartsWith("Changed!");
6868
}
6969

70+
[Theory]
71+
[CombinatorialData]
72+
public async Task AutoRestartOnRudeEdit(bool nonInteractive)
73+
{
74+
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
75+
.WithSource();
76+
77+
if (!nonInteractive)
78+
{
79+
testAsset = testAsset
80+
.WithProjectChanges(project =>
81+
{
82+
project.Root.Descendants()
83+
.First(e => e.Name.LocalName == "PropertyGroup")
84+
.Add(XElement.Parse("""
85+
<HotReloadAutoRestart>true</HotReloadAutoRestart>
86+
"""));
87+
});
88+
}
89+
90+
var programPath = Path.Combine(testAsset.Path, "Program.cs");
91+
92+
App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []);
93+
94+
await App.AssertWaitingForChanges();
95+
App.Process.ClearOutput();
96+
97+
// rude edit: adding virtual method
98+
UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));
99+
100+
await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false);
101+
102+
App.AssertOutputContains("⌚ Restart is needed to apply the changes");
103+
App.AssertOutputContains($"⌚ [auto-restart] {programPath}(33,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
104+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
105+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
106+
}
107+
108+
[Theory]
109+
[CombinatorialData]
110+
public async Task AutoRestartOnNoEffectEdit(bool nonInteractive)
111+
{
112+
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
113+
.WithSource();
114+
115+
if (!nonInteractive)
116+
{
117+
testAsset = testAsset
118+
.WithProjectChanges(project =>
119+
{
120+
project.Root.Descendants()
121+
.First(e => e.Name.LocalName == "PropertyGroup")
122+
.Add(XElement.Parse("""
123+
<HotReloadAutoRestart>true</HotReloadAutoRestart>
124+
"""));
125+
});
126+
}
127+
128+
var programPath = Path.Combine(testAsset.Path, "Program.cs");
129+
130+
App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []);
131+
132+
await App.AssertWaitingForChanges();
133+
App.Process.ClearOutput();
134+
135+
// rude edit: adding virtual method
136+
UpdateSourceFile(programPath, src => src.Replace("Started", "<Updated>"));
137+
138+
await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false);
139+
140+
App.AssertOutputContains("⌚ Restart is needed to apply the changes");
141+
App.AssertOutputContains($"⌚ [auto-restart] {programPath}(16,19): warning ENC0118: Changing 'top-level code' might not have any effect until the application is restarted.");
142+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
143+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
144+
App.AssertOutputContains("<Updated>");
145+
}
146+
70147
/// <summary>
71148
/// Unchanged project doesn't build. Wait for source change and rebuild.
72149
/// </summary>
@@ -682,7 +759,7 @@ public async Task Aspire()
682759

683760
await App.AssertOutputLineStartsWith(" ❔ Do you want to restart these projects? Yes (y) / No (n) / Always (a) / Never (v)");
684761

685-
App.AssertOutputContains("dotnet watch ⌚ Unable to apply hot reload, restart is needed to apply the changes.");
762+
App.AssertOutputContains("dotnet watch ⌚ Restart is needed to apply the changes.");
686763
App.AssertOutputContains("error ENC0020: Renaming record 'WeatherForecast' requires restarting the application.");
687764
App.AssertOutputContains("dotnet watch ⌚ Affected projects:");
688765
App.AssertOutputContains("dotnet watch ⌚ WatchAspire.ApiService");

test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public async Task ReferenceOutputAssembly_False()
2727
reporter);
2828

2929
var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false);
30-
var handler = new CompilationHandler(reporter, environmentOptions, CancellationToken.None);
30+
var handler = new CompilationHandler(reporter, environmentOptions, new GlobalOptions(), CancellationToken.None);
3131

3232
await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None);
3333

test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,7 @@ public async Task RudeEditInProjectWithoutRunningProcess()
503503

504504
var changeHandled = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled);
505505
var sessionStarted = w.Reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted);
506+
var applyUpdateVerbose = w.Reporter.RegisterSemaphore(MessageDescriptor.ApplyUpdate_Verbose);
506507

507508
// let the host process start:
508509
Log("Waiting for changes...");
@@ -516,6 +517,7 @@ public async Task RudeEditInProjectWithoutRunningProcess()
516517
await sessionStarted.WaitAsync(w.ShutdownSource.Token);
517518

518519
// Terminate the process:
520+
Log($"Terminating process {runningProject.ProjectNode.GetDisplayName()} ...");
519521
await w.Service.ProjectLauncher.TerminateProcessAsync(runningProject, CancellationToken.None);
520522

521523
// rude edit in A (changing assembly level attribute):
@@ -526,8 +528,8 @@ public async Task RudeEditInProjectWithoutRunningProcess()
526528
Log("Waiting for change handled ...");
527529
await changeHandled.WaitAsync(w.ShutdownSource.Token);
528530

529-
w.Reporter.ProcessOutput.Contains("verbose ⌚ Rude edits detected but do not affect any running process");
530-
w.Reporter.ProcessOutput.Contains($"verbose ❌ {serviceSourceA2}(1,12): error ENC0003: Updating 'attribute' requires restarting the application.");
531+
Log("Waiting for verbose rude edit reported ...");
532+
await applyUpdateVerbose.WaitAsync(w.ShutdownSource.Token);
531533
}
532534

533535
public enum DirectoryKind

0 commit comments

Comments
 (0)