Skip to content

Commit ca27b94

Browse files
authored
Fix reporting rude edits (#48966)
1 parent 02ab00a commit ca27b94

File tree

5 files changed

+124
-71
lines changed

5 files changed

+124
-71
lines changed

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

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ internal sealed class CompilationHandler : IDisposable
1515
{
1616
public readonly IncrementalMSBuildWorkspace Workspace;
1717
public readonly EnvironmentOptions EnvironmentOptions;
18-
private readonly GlobalOptions _options;
1918
private readonly IReporter _reporter;
2019
private readonly WatchHotReloadService _hotReloadService;
2120

@@ -41,11 +40,10 @@ internal sealed class CompilationHandler : IDisposable
4140

4241
private bool _isDisposed;
4342

44-
public CompilationHandler(IReporter reporter, EnvironmentOptions environmentOptions, GlobalOptions options, CancellationToken shutdownCancellationToken)
43+
public CompilationHandler(IReporter reporter, EnvironmentOptions environmentOptions, CancellationToken shutdownCancellationToken)
4544
{
4645
_reporter = reporter;
4746
EnvironmentOptions = environmentOptions;
48-
_options = options;
4947
Workspace = new IncrementalMSBuildWorkspace(reporter);
5048
_hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities()));
5149
_shutdownCancellationToken = shutdownCancellationToken;
@@ -265,6 +263,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
265263
}
266264

267265
public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
266+
bool autoRestart,
268267
Func<IEnumerable<string>, CancellationToken, Task> restartPrompt,
269268
CancellationToken cancellationToken)
270269
{
@@ -275,8 +274,8 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
275274
(from project in currentSolution.Projects
276275
let runningProject = GetCorrespondingRunningProject(project, runningProjects)
277276
where runningProject != null
278-
let autoRestart = _options.NonInteractive || runningProject.ProjectNode.IsAutoRestartEnabled()
279-
select (project.Id, info: new WatchHotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestart }))
277+
let autoRestartProject = autoRestart || runningProject.ProjectNode.IsAutoRestartEnabled()
278+
select (project.Id, info: new WatchHotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestartProject }))
280279
.ToImmutableDictionary(e => e.Id, e => e.info);
281280

282281
var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken);
@@ -290,41 +289,43 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
290289
return (ImmutableDictionary<ProjectId, string>.Empty, []);
291290
}
292291

293-
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
294-
lock (_runningProjectsAndUpdatesGuard)
292+
if (updates.ProjectUpdates.Any())
295293
{
296-
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
297-
_previousUpdates = _previousUpdates.AddRange(updates.ProjectUpdates);
294+
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
295+
lock (_runningProjectsAndUpdatesGuard)
296+
{
297+
// Adding the updates makes sure that all new processes receive them before they are added to running processes.
298+
_previousUpdates = _previousUpdates.AddRange(updates.ProjectUpdates);
298299

299-
// Capture the set of processes that do not have the currently calculated deltas yet.
300-
projectsToUpdate = _runningProjects;
301-
}
300+
// Capture the set of processes that do not have the currently calculated deltas yet.
301+
projectsToUpdate = _runningProjects;
302+
}
302303

303-
// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
304-
// The process may load any of the binaries using MEF or some other runtime dependency loader.
304+
// Apply changes to all running projects, even if they do not have a static project dependency on any project that changed.
305+
// The process may load any of the binaries using MEF or some other runtime dependency loader.
305306

306-
await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
307-
{
308-
try
307+
await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationToken) =>
309308
{
310-
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
311-
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
312-
if (applySucceded)
309+
try
313310
{
314-
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
315-
if (runningProject.BrowserRefreshServer is { } server)
311+
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
312+
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
313+
if (applySucceded)
316314
{
317-
runningProject.Reporter.Verbose("Refreshing browser.");
318-
await server.RefreshBrowserAsync(cancellationToken);
315+
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
316+
if (runningProject.BrowserRefreshServer is { } server)
317+
{
318+
runningProject.Reporter.Verbose("Refreshing browser.");
319+
await server.RefreshBrowserAsync(cancellationToken);
320+
}
319321
}
320322
}
321-
}
322-
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
323-
{
324-
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
325-
}
326-
}, cancellationToken);
327-
323+
catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
324+
{
325+
runningProject.Reporter.Verbose("Hot reload canceled because the process exited.", emoji: "🔥");
326+
}
327+
}, cancellationToken);
328+
}
328329

329330
if (updates.ProjectsToRestart.IsEmpty)
330331
{
@@ -430,11 +431,15 @@ void ReportRudeEdits()
430431
// Rude edits in projects that caused restart of a project that can be restarted automatically
431432
// will be reported only as verbose output.
432433
var projectsRestartedDueToRudeEdits = updates.ProjectsToRestart
433-
.Where(e => runningProjectInfos.TryGetValue(e.Key, out var info) && info.RestartWhenChangesHaveNoEffect)
434+
.Where(e => IsAutoRestartEnabled(e.Key))
434435
.SelectMany(e => e.Value)
435436
.ToHashSet();
436437

437-
var projectsRebuiltDueToRudeEdits = updates.ProjectsToRebuild.ToHashSet();
438+
// Project with rude edit that doesn't impact running project is only listed in ProjectsToRebuild.
439+
// Such projects are always auto-rebuilt whether or not there is any project to be restarted that needs a confirmation.
440+
var projectsRebuiltDueToRudeEdits = updates.ProjectsToRebuild
441+
.Where(p => !updates.ProjectsToRestart.ContainsKey(p))
442+
.ToHashSet();
438443

439444
foreach (var (projectId, diagnostics) in updates.RudeEdits)
440445
{
@@ -451,6 +456,9 @@ void ReportRudeEdits()
451456
}
452457
}
453458

459+
bool IsAutoRestartEnabled(ProjectId id)
460+
=> runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect;
461+
454462
void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string prefix = "")
455463
{
456464
var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ namespace Microsoft.DotNet.Watch
55
{
66
internal sealed class RestartPrompt(IReporter reporter, ConsoleInputReader requester, bool? noPrompt)
77
{
8-
private bool? _restartImmediatelySessionPreference = noPrompt;
8+
public bool? AutoRestartPreference { get; private set; } = noPrompt;
99

1010
public async ValueTask<bool> WaitForRestartConfirmationAsync(string question, CancellationToken cancellationToken)
1111
{
12-
if (_restartImmediatelySessionPreference.HasValue)
12+
if (AutoRestartPreference.HasValue)
1313
{
1414
reporter.Output("Restarting");
15-
return _restartImmediatelySessionPreference.Value;
15+
return AutoRestartPreference.Value;
1616
}
1717

1818
var key = await requester.GetKeyAsync(
@@ -30,11 +30,11 @@ public async ValueTask<bool> WaitForRestartConfirmationAsync(string question, Ca
3030
return false;
3131

3232
case ConsoleKey.A:
33-
_restartImmediatelySessionPreference = true;
33+
AutoRestartPreference = true;
3434
return true;
3535

3636
case ConsoleKey.V:
37-
_restartImmediatelySessionPreference = false;
37+
AutoRestartPreference = false;
3838
return false;
3939
}
4040

src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Lines changed: 35 additions & 31 deletions
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, Context.Options, shutdownCancellationToken);
103+
compilationHandler = new CompilationHandler(Context.Reporter, Context.EnvironmentOptions, 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);
@@ -265,46 +265,50 @@ void FileChangedCallback(ChangedPath change)
265265

266266
HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler);
267267

268-
var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(restartPrompt: async (projectNames, cancellationToken) =>
269-
{
270-
if (_rudeEditRestartPrompt != null)
268+
var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(
269+
autoRestart: Context.Options.NonInteractive || _rudeEditRestartPrompt?.AutoRestartPreference is true,
270+
restartPrompt: async (projectNames, cancellationToken) =>
271271
{
272-
// stop before waiting for user input:
273-
stopwatch.Stop();
274-
275-
string question;
276-
if (runtimeProcessLauncher == null)
277-
{
278-
question = "Do you want to restart your app?";
279-
}
280-
else
272+
if (_rudeEditRestartPrompt != null)
281273
{
282-
Context.Reporter.Output("Affected projects:");
274+
// stop before waiting for user input:
275+
stopwatch.Stop();
283276

284-
foreach (var projectName in projectNames.OrderBy(n => n))
277+
string question;
278+
if (runtimeProcessLauncher == null)
285279
{
286-
Context.Reporter.Output(" " + projectName);
280+
question = "Do you want to restart your app?";
287281
}
282+
else
283+
{
284+
Context.Reporter.Output("Affected projects:");
288285

289-
question = "Do you want to restart these projects?";
290-
}
286+
foreach (var projectName in projectNames.OrderBy(n => n))
287+
{
288+
Context.Reporter.Output(" " + projectName);
289+
}
291290

292-
if (!await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken))
293-
{
294-
Context.Reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥");
295-
await Task.Delay(-1, cancellationToken);
296-
}
297-
}
298-
else
299-
{
300-
Context.Reporter.Verbose("Restarting without prompt since dotnet-watch is running in non-interactive mode.");
291+
question = "Do you want to restart these projects?";
292+
}
301293

302-
foreach (var projectName in projectNames)
294+
if (!await _rudeEditRestartPrompt.WaitForRestartConfirmationAsync(question, cancellationToken))
295+
{
296+
Context.Reporter.Output("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", emoji: "🔥");
297+
await Task.Delay(-1, cancellationToken);
298+
}
299+
}
300+
else
303301
{
304-
Context.Reporter.Verbose($" Project to restart: '{projectName}'");
302+
Context.Reporter.Verbose("Restarting without prompt since dotnet-watch is running in non-interactive mode.");
303+
304+
foreach (var projectName in projectNames)
305+
{
306+
Context.Reporter.Verbose($" Project to restart: '{projectName}'");
307+
}
305308
}
306-
}
307-
}, iterationCancellationToken);
309+
},
310+
iterationCancellationToken);
311+
308312
HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler);
309313

310314
stopwatch.Stop();

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,47 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive)
105105
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
106106
}
107107

108+
[Fact]
109+
public async Task AutoRestartOnRudeEditAfterRestartPrompt()
110+
{
111+
var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp")
112+
.WithSource();
113+
114+
var programPath = Path.Combine(testAsset.Path, "Program.cs");
115+
116+
App.Start(testAsset, [], testFlags: TestFlags.ReadKeyFromStdin);
117+
118+
await App.AssertWaitingForChanges();
119+
App.Process.ClearOutput();
120+
121+
// rude edit: adding virtual method
122+
UpdateSourceFile(programPath, src => src.Replace("/* member placeholder */", "public virtual void F() {}"));
123+
124+
await App.AssertOutputLineStartsWith(" ❔ Do you want to restart your app? Yes (y) / No (n) / Always (a) / Never (v)", failure: _ => false);
125+
126+
App.AssertOutputContains("⌚ Restart is needed to apply the changes.");
127+
App.AssertOutputContains($"❌ {programPath}(33,11): error ENC0023: Adding an abstract method or overriding an inherited method requires restarting the application.");
128+
App.Process.ClearOutput();
129+
130+
App.SendKey('a');
131+
132+
await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false);
133+
134+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
135+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
136+
App.Process.ClearOutput();
137+
138+
// rude edit: deleting virtual method
139+
UpdateSourceFile(programPath, src => src.Replace("public virtual void F() {}", ""));
140+
141+
await App.AssertOutputLineStartsWith(MessageDescriptor.WaitingForChanges, failure: _ => false);
142+
143+
App.AssertOutputContains("⌚ Restart is needed to apply the changes");
144+
App.AssertOutputContains($"⌚ [auto-restart] {programPath}(33,1): error ENC0033: Deleting method 'F()' requires restarting the application.");
145+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exited");
146+
App.AssertOutputContains($"[WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Launched");
147+
}
148+
108149
[Theory]
109150
[CombinatorialData]
110151
public async Task AutoRestartOnNoEffectEdit(bool nonInteractive)
@@ -760,7 +801,7 @@ public async Task Aspire()
760801
await App.AssertOutputLineStartsWith(" ❔ Do you want to restart these projects? Yes (y) / No (n) / Always (a) / Never (v)");
761802

762803
App.AssertOutputContains("dotnet watch ⌚ Restart is needed to apply the changes.");
763-
App.AssertOutputContains("error ENC0020: Renaming record 'WeatherForecast' requires restarting the application.");
804+
App.AssertOutputContains($"dotnet watch ❌ {serviceSourcePath}(36,1): error ENC0020: Renaming record 'WeatherForecast' requires restarting the application.");
764805
App.AssertOutputContains("dotnet watch ⌚ Affected projects:");
765806
App.AssertOutputContains("dotnet watch ⌚ WatchAspire.ApiService");
766807
App.Process.ClearOutput();

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, new GlobalOptions(), CancellationToken.None);
30+
var handler = new CompilationHandler(reporter, environmentOptions, CancellationToken.None);
3131

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

0 commit comments

Comments
 (0)