Skip to content

Commit ab0a1f2

Browse files
committed
Implement auto-restarting on rude edit or no-effect change
1 parent d0cb49d commit ab0a1f2

File tree

6 files changed

+124
-101
lines changed

6 files changed

+124
-101
lines changed

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

Lines changed: 118 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal sealed class CompilationHandler : IDisposable
1515
{
1616
public readonly IncrementalMSBuildWorkspace Workspace;
1717
public readonly EnvironmentOptions EnvironmentOptions;
18-
18+
private readonly GlobalOptions _options;
1919
private readonly IReporter _reporter;
2020
private readonly WatchHotReloadService _hotReloadService;
2121

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

4242
private bool _isDisposed;
4343

44-
public CompilationHandler(IReporter reporter, EnvironmentOptions environmentOptions, CancellationToken shutdownCancellationToken)
44+
public CompilationHandler(IReporter reporter, EnvironmentOptions environmentOptions, GlobalOptions options, CancellationToken shutdownCancellationToken)
4545
{
4646
_reporter = reporter;
4747
EnvironmentOptions = environmentOptions;
48+
_options = options;
4849
Workspace = new IncrementalMSBuildWorkspace(reporter);
4950
_hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities()));
5051
_shutdownCancellationToken = shutdownCancellationToken;
@@ -256,41 +257,25 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
256257
var currentSolution = Workspace.CurrentSolution;
257258
var runningProjects = _runningProjects;
258259

259-
var runningProjectIds = currentSolution.Projects
260-
.Where(project => project.FilePath != null && runningProjects.ContainsKey(project.FilePath))
261-
.Select(project => project.Id)
262-
.ToImmutableHashSet();
260+
var runningProjectInfos =
261+
(from project in currentSolution.Projects
262+
let runningProject = GetCorrespondingRunningProject(project, runningProjects)
263+
where runningProject != null
264+
let autoRestart = _options.NonInteractive || runningProject.ProjectNode.IsAutoRestartEnabled()
265+
select (project.Id, info: new WatchHotReloadService.RunningProjectInfo() { RestartWhenChangesHaveNoEffect = autoRestart }))
266+
.ToImmutableDictionary(e => e.Id, e => e.info);
263267

264-
var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectIds, cancellationToken);
265-
var anyProcessNeedsRestart = !updates.ProjectIdsToRestart.IsEmpty;
268+
var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, runningProjectInfos, cancellationToken);
266269

267-
await DisplayResultsAsync(updates, cancellationToken);
270+
await DisplayResultsAsync(updates, runningProjectInfos, cancellationToken);
268271

269-
if (updates.Status is ModuleUpdateStatus.None or ModuleUpdateStatus.Blocked)
272+
if (updates.Status is WatchHotReloadService.Status.NoChangesToApply or WatchHotReloadService.Status.Blocked)
270273
{
271274
// If Hot Reload is blocked (due to compilation error) we ignore the current
272275
// changes and await the next file change.
273276
return (ImmutableDictionary<ProjectId, string>.Empty, []);
274277
}
275278

276-
if (updates.Status == ModuleUpdateStatus.RestartRequired)
277-
{
278-
if (!anyProcessNeedsRestart)
279-
{
280-
return (ImmutableDictionary<ProjectId, string>.Empty, []);
281-
}
282-
283-
await restartPrompt.Invoke(updates.ProjectIdsToRestart.Select(id => currentSolution.GetProject(id)!.Name), cancellationToken);
284-
285-
// Terminate all tracked processes that need to be restarted,
286-
// except for the root process, which will terminate later on.
287-
var terminatedProjects = await TerminateNonRootProcessesAsync(updates.ProjectIdsToRestart.Select(id => currentSolution.GetProject(id)!.FilePath!), cancellationToken);
288-
289-
return (updates.ProjectIdsToRebuild.ToImmutableDictionary(keySelector: id => id, elementSelector: id => currentSolution.GetProject(id)!.FilePath!), terminatedProjects);
290-
}
291-
292-
Debug.Assert(updates.Status == ModuleUpdateStatus.Ready);
293-
294279
ImmutableDictionary<string, ImmutableArray<RunningProject>> projectsToUpdate;
295280
lock (_runningProjectsAndUpdatesGuard)
296281
{
@@ -326,115 +311,151 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT
326311
}
327312
}, cancellationToken);
328313

329-
return (ImmutableDictionary<ProjectId, string>.Empty, []);
314+
315+
if (updates.ProjectsToRestart.IsEmpty)
316+
{
317+
return (ImmutableDictionary<ProjectId, string>.Empty, []);
318+
}
319+
320+
// Terminate projects that need restarting.
321+
322+
var projectsToPromptForRestart =
323+
(from projectId in updates.ProjectsToRestart.Keys
324+
where !runningProjectInfos[projectId].RestartWhenChangesHaveNoEffect // equivallent to auto-restart
325+
select currentSolution.GetProject(projectId)!.Name).ToList();
326+
327+
if (projectsToPromptForRestart is not [])
328+
{
329+
await restartPrompt.Invoke(projectsToPromptForRestart, cancellationToken);
330+
}
331+
332+
// Terminate all tracked processes that need to be restarted,
333+
// except for the root process, which will terminate later on.
334+
var terminatedProjects = await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken);
335+
var projectsToRebuild = updates.ProjectsToRebuild.ToImmutableDictionary(keySelector: id => id, elementSelector: id => currentSolution.GetProject(id)!.FilePath!);
336+
337+
return (projectsToRebuild, terminatedProjects);
330338
}
331339

332-
private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates updates, CancellationToken cancellationToken)
340+
private static RunningProject? GetCorrespondingRunningProject(Project project, ImmutableDictionary<string, ImmutableArray<RunningProject>> runningProjects)
333341
{
334-
var anyProcessNeedsRestart = !updates.ProjectIdsToRestart.IsEmpty;
342+
if (project.FilePath == null || !runningProjects.TryGetValue(project.FilePath, out var projectsWithPath))
343+
{
344+
return null;
345+
}
335346

336-
switch (updates.Status)
347+
// msbuild workspace doesn't set TFM if the project is not multi-targeted
348+
var tfm = WatchHotReloadService.GetTargetFramework(project);
349+
if (tfm == null)
337350
{
338-
case ModuleUpdateStatus.None:
339-
_reporter.Report(MessageDescriptor.NoCSharpChangesToApply);
340-
break;
351+
return projectsWithPath[0];
352+
}
341353

342-
case ModuleUpdateStatus.Ready:
343-
break;
354+
return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase));
355+
}
344356

345-
case ModuleUpdateStatus.RestartRequired:
346-
if (anyProcessNeedsRestart)
347-
{
348-
_reporter.Output("Unable to apply hot reload, restart is needed to apply the changes.");
349-
}
350-
else
351-
{
352-
_reporter.Verbose("Rude edits detected but do not affect any running process");
353-
}
357+
private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updates, ImmutableDictionary<ProjectId, WatchHotReloadService.RunningProjectInfo> runningProjectInfos, CancellationToken cancellationToken)
358+
{
359+
switch (updates.Status)
360+
{
361+
case WatchHotReloadService.Status.ReadyToApply:
362+
break;
354363

364+
case WatchHotReloadService.Status.NoChangesToApply:
365+
_reporter.Report(MessageDescriptor.NoCSharpChangesToApply);
355366
break;
356367

357-
case ModuleUpdateStatus.Blocked:
368+
case WatchHotReloadService.Status.Blocked:
358369
_reporter.Output("Unable to apply hot reload due to compilation errors.");
359370
break;
360371

361372
default:
362373
throw new InvalidOperationException();
363374
}
364375

365-
// Diagnostics include syntactic errors, semantic warnings/errors and rude edit warnigns/errors for members being updated.
376+
if (!updates.ProjectsToRestart.IsEmpty)
377+
{
378+
_reporter.Output("Restart is needed to apply the changes.");
379+
}
366380

367-
var diagnosticsToDisplay = new List<string>();
381+
var diagnosticsToDisplayInApp = new List<string>();
368382

369383
// Display errors first, then warnings:
370-
Display(MessageSeverity.Error);
371-
Display(MessageSeverity.Warning);
384+
ReportCompilationDiagnostics(DiagnosticSeverity.Error);
385+
ReportCompilationDiagnostics(DiagnosticSeverity.Warning);
386+
ReportRudeEdits();
387+
388+
// report or clear diagnostics in the browser UI
389+
await ForEachProjectAsync(
390+
_runningProjects,
391+
(project, cancellationToken) => project.BrowserRefreshServer?.ReportCompilationErrorsInBrowserAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
392+
cancellationToken);
372393

373-
void Display(MessageSeverity severity)
394+
void ReportCompilationDiagnostics(DiagnosticSeverity severity)
374395
{
375-
foreach (var diagnostic in updates.Diagnostics)
396+
foreach (var diagnostic in updates.CompilationDiagnostics)
376397
{
377-
MessageDescriptor descriptor;
378-
379-
if (diagnostic.Id == "ENC0118")
380-
{
381-
// Changing '<entry-point>' might not have any effect until the application is restarted.
382-
descriptor = MessageDescriptor.ApplyUpdate_ChangingEntryPoint;
383-
}
384-
else if (diagnostic.Id == "ENC1005")
385-
{
386-
// TODO: This warning is overreported in cases when the solution contains projects that are not rebuilt (up-to-date)
387-
// and a document is updated that is linked to such a project and another "active" project.
388-
// E.g. multi-tfm projects where only one TFM is currently built/running.
389-
390-
// Warning: The current content of source file 'D:\Temp\App\Program.cs' does not match the built source.
391-
// Any changes made to this file while debugging won't be applied until its content matches the built source.
392-
descriptor = MessageDescriptor.ApplyUpdate_FileContentDoesNotMatchBuiltSource;
393-
}
394-
else if (diagnostic.Id == "CS8002")
398+
if (diagnostic.Id == "CS8002")
395399
{
396400
// TODO: This is not a useful warning. Compiler shouldn't be reporting this on .NET/
397401
// Referenced assembly '...' does not have a strong name"
398402
continue;
399403
}
400-
else
401-
{
402-
// Use the default severity of the diagnostic as it conveys impact on Hot Reload
403-
// (ignore warnings as errors and other severity configuration).
404-
descriptor = diagnostic.DefaultSeverity switch
405-
{
406-
DiagnosticSeverity.Error => MessageDescriptor.ApplyUpdate_Error,
407-
DiagnosticSeverity.Warning => MessageDescriptor.ApplyUpdate_Warning,
408-
_ => MessageDescriptor.ApplyUpdate_Verbose,
409-
};
410-
}
411404

412-
if (descriptor.Severity != severity)
405+
if (diagnostic.DefaultSeverity != severity)
413406
{
414407
continue;
415408
}
416409

417-
// Do not report rude edits as errors/warnings if no running process is affected.
418-
if (!anyProcessNeedsRestart && diagnostic.Id is ['E', 'N', 'C', >= '0' and <= '9', ..])
410+
ReportDiagnostic(diagnostic, GetMessageDescritor(diagnostic));
411+
}
412+
}
413+
414+
void ReportRudeEdits()
415+
{
416+
// Rude edits in projects that caused restart of a project that can be restarted automatically
417+
// will be reported only as verbose output.
418+
var projectsWithVerboseRudeEdits = updates.ProjectsToRestart
419+
.Where(e => runningProjectInfos.TryGetValue(e.Key, out var info) && info.RestartWhenChangesHaveNoEffect)
420+
.SelectMany(e => e.Value)
421+
.ToImmutableHashSet();
422+
423+
foreach (var (projectId, diagnostics) in updates.RudeEdits)
424+
{
425+
foreach (var diagnostic in diagnostics)
419426
{
420-
descriptor = descriptor with { Severity = MessageSeverity.Verbose };
421-
}
427+
var descriptor = GetMessageDescritor(diagnostic);
422428

423-
var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);
424-
_reporter.Report(descriptor, display);
429+
if (projectsWithVerboseRudeEdits.Contains(projectId))
430+
{
431+
descriptor = descriptor with { Severity = MessageSeverity.Verbose };
432+
}
425433

426-
if (descriptor.TryGetMessage(prefix: null, [display], out var message))
427-
{
428-
diagnosticsToDisplay.Add(message);
434+
ReportDiagnostic(diagnostic, descriptor);
429435
}
430436
}
431437
}
432438

433-
// report or clear diagnostics in the browser UI
434-
await ForEachProjectAsync(
435-
_runningProjects,
436-
(project, cancellationToken) => project.BrowserRefreshServer?.ReportCompilationErrorsInBrowserAsync(diagnosticsToDisplay.ToImmutableArray(), cancellationToken).AsTask() ?? Task.CompletedTask,
437-
cancellationToken);
439+
void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor)
440+
{
441+
var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);
442+
_reporter.Report(descriptor, display);
443+
444+
if (descriptor.TryGetMessage(prefix: null, [display], out var message))
445+
{
446+
diagnosticsToDisplayInApp.Add(message);
447+
}
448+
}
449+
450+
// Use the default severity of the diagnostic as it conveys impact on Hot Reload
451+
// (ignore warnings as errors and other severity configuration).
452+
static MessageDescriptor GetMessageDescritor(Diagnostic diagnostic)
453+
=> diagnostic.DefaultSeverity switch
454+
{
455+
DiagnosticSeverity.Error => MessageDescriptor.ApplyUpdate_Error,
456+
DiagnosticSeverity.Warning => MessageDescriptor.ApplyUpdate_Warning,
457+
_ => MessageDescriptor.ApplyUpdate_Verbose,
458+
};
438459
}
439460

440461
public async ValueTask<bool> HandleStaticAssetChangesAsync(IReadOnlyList<ChangedFile> files, ProjectNodeMap projectMap, CancellationToken cancellationToken)

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/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"dotnet-watch": {
44
"commandName": "Project",
55
"commandLineArgs": "--verbose /bl:DotnetRun.binlog",
6-
"workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm",
6+
"workingDirectory": "C:\\temp\\app",
77
"environmentVariables": {
88
"DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)",
99
"DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000",

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/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ public async Task Aspire()
682682

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

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

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,6 @@ public async Task RudeEditInProjectWithoutRunningProcess()
526526
Log("Waiting for change handled ...");
527527
await changeHandled.WaitAsync(w.ShutdownSource.Token);
528528

529-
w.Reporter.ProcessOutput.Contains("verbose ⌚ Rude edits detected but do not affect any running process");
530529
w.Reporter.ProcessOutput.Contains($"verbose ❌ {serviceSourceA2}(1,12): error ENC0003: Updating 'attribute' requires restarting the application.");
531530
}
532531

0 commit comments

Comments
 (0)