Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement termination command in the agent and use it to gracefully terminate processes #46003

Draft
wants to merge 4 commits into
base: release/9.0.3xx
Choose a base branch
from
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Implement static asset updating
tmat committed Jan 15, 2025
commit d49206f435fb132bb46a7ac62dbbfd5abac945d6
8 changes: 6 additions & 2 deletions src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@ public static void Initialize()
return;
}

using var agent = new HotReloadAgent();
var agent = new HotReloadAgent();
try
{
// block until initialization completes:
@@ -64,7 +64,6 @@ public static void Initialize()
}
}


private static async ValueTask InitializeAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)
{
agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose);
@@ -116,6 +115,11 @@ private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipe
{
await pipeClient.DisposeAsync();
}

if (!initialUpdates)
{
agent.Dispose();
}
}
}

14 changes: 13 additions & 1 deletion src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@

namespace Microsoft.DotNet.Watch
{
internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable
internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable, IStaticAssetChangeApplierProvider
{
// This needs to be in sync with the version BrowserRefreshMiddleware is compiled against.
private static readonly Version s_minimumSupportedVersion = Versions.Version6_0;
@@ -92,6 +92,18 @@ await Task.WhenAll(serversToDispose.Select(async server =>
return server;
}

bool IStaticAssetChangeApplierProvider.TryGetApplier(ProjectGraphNode projectNode, [NotNullWhen(true)] out IStaticAssetChangeApplier? applier)
{
if (TryGetRefreshServer(projectNode, out var server))
{
applier = server;
return true;
}

applier = null;
return false;
}

public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server)
{
lock (_serversGuard)
25 changes: 21 additions & 4 deletions src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ namespace Microsoft.DotNet.Watch
/// <summary>
/// Communicates with aspnetcore-browser-refresh.js loaded in the browser.
/// </summary>
internal sealed class BrowserRefreshServer : IAsyncDisposable
internal sealed class BrowserRefreshServer : IAsyncDisposable, IStaticAssetChangeApplier
{
private static readonly ReadOnlyMemory<byte> s_reloadMessage = Encoding.UTF8.GetBytes("Reload");
private static readonly ReadOnlyMemory<byte> s_waitMessage = Encoding.UTF8.GetBytes("Wait");
@@ -81,8 +81,8 @@ public void SetEnvironmentVariables(EnvironmentVariablesBuilder environmentBuild
environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _serverUrls);
environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey, GetServerKey());

environmentBuilder.DotNetStartupHookDirective.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll"));
environmentBuilder.AspNetCoreHostingStartupAssembliesVariable.Add("Microsoft.AspNetCore.Watch.BrowserRefresh");
environmentBuilder.DotNetStartupHooks.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll"));
environmentBuilder.AspNetCoreHostingStartupAssemblies.Add("Microsoft.AspNetCore.Watch.BrowserRefresh");

if (_reporter.IsVerbose)
{
@@ -288,7 +288,7 @@ public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken)
public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken)
=> SendAsync(s_waitMessage, cancellationToken);

public ValueTask SendAsync(ReadOnlyMemory<byte> messageBytes, CancellationToken cancellationToken)
private ValueTask SendAsync(ReadOnlyMemory<byte> messageBytes, CancellationToken cancellationToken)
=> SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken);

public async ValueTask SendAndReceiveAsync<TRequest>(
@@ -354,6 +354,17 @@ public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray<string> co
}
}

public async ValueTask UpdateStaticAssetsAsync(IEnumerable<string> relativeUrls, CancellationToken cancellationToken)
{
// Serialize all requests sent to a single server:
foreach (var relativeUrl in relativeUrls)
{
_reporter.Verbose($"Sending static asset update request to browser: '{relativeUrl}'.");
var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions);
await SendAsync(message, cancellationToken);
}
}

private readonly struct AspNetCoreHotReloadApplied
{
public string Type => "AspNetCoreHotReloadApplied";
@@ -365,5 +376,11 @@ private readonly struct HotReloadDiagnostics

public IEnumerable<string> Diagnostics { get; init; }
}

private readonly struct UpdateStaticFileMessage
{
public string Type => "UpdateStaticFile";
public string Path { get; init; }
}
}
}
Original file line number Diff line number Diff line change
@@ -67,7 +67,7 @@ public override Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsync(Can
return Task.FromResult(capabilities);
}

public override async Task<ApplyStatus> Apply(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
public override async Task<ApplyStatus> ApplyManagedCodeUpdates(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
{
var applicableUpdates = await FilterApplicableUpdatesAsync(updates, cancellationToken);
if (applicableUpdates.Count == 0)
@@ -135,6 +135,10 @@ await browserRefreshServer.SendAndReceiveAsync(
return (!anySuccess && anyFailure) ? ApplyStatus.Failed : (applicableUpdates.Count < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied;
}

public override Task<ApplyStatus> ApplyStaticAssetUpdates(ImmutableArray<StaticAssetUpdate> updates, CancellationToken cancellationToken)
// static asset updates are handled by browser refresh server:
=> Task.FromResult(ApplyStatus.NoChangesApplied);

public override Task InitialUpdatesApplied(CancellationToken cancellationToken)
=> Task.CompletedTask;

Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@ public override async Task<ImmutableArray<string>> GetApplyUpdateCapabilitiesAsy
return result[0].Union(result[1], StringComparer.OrdinalIgnoreCase).ToImmutableArray();
}

public override async Task<ApplyStatus> Apply(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
public override async Task<ApplyStatus> ApplyManagedCodeUpdates(ImmutableArray<WatchHotReloadService.Update> updates, CancellationToken cancellationToken)
{
// Apply to both processes.
// The module the change is for does not need to be loaded in either of the processes, yet we still consider it successful if the application does not fail.
@@ -50,8 +50,8 @@ public override async Task<ApplyStatus> Apply(ImmutableArray<WatchHotReloadServi
// the compiler (producing wrong delta), or rude edit detection (the change shouldn't have been allowed).

var result = await Task.WhenAll(
_wasmApplier.Apply(updates, cancellationToken),
_hostApplier.Apply(updates, cancellationToken));
_wasmApplier.ApplyManagedCodeUpdates(updates, cancellationToken),
_hostApplier.ApplyManagedCodeUpdates(updates, cancellationToken));

var wasmResult = result[0];
var hostResult = result[1];
@@ -80,6 +80,10 @@ void ReportStatus(ApplyStatus status, string target)
}
}

public override Task<ApplyStatus> ApplyStaticAssetUpdates(ImmutableArray<StaticAssetUpdate> updates, CancellationToken cancellationToken)
// static asset updates are handled by browser refresh server:
=> Task.FromResult(ApplyStatus.NoChangesApplied);

public override Task InitialUpdatesApplied(CancellationToken cancellationToken)
=> _hostApplier.InitialUpdatesApplied(cancellationToken);
}
113 changes: 108 additions & 5 deletions src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.


using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.Build.Graph;
@@ -175,7 +174,7 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project
var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray();
if (updatesToApply.Any())
{
_ = await deltaApplier.Apply(updatesToApply, processCommunicationCancellationSource.Token);
_ = await deltaApplier.ApplyManagedCodeUpdates(updatesToApply, processCommunicationCancellationSource.Token);
}

appliedUpdateCount += updatesToApply.Length;
@@ -244,7 +243,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C
}
}

public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleFileChangesAsync(
public async ValueTask<(ImmutableDictionary<ProjectId, string> projectsToRebuild, ImmutableArray<RunningProject> terminatedProjects)> HandleManagedCodeChangesAsync(
Func<IEnumerable<string>, CancellationToken, Task> restartPrompt,
CancellationToken cancellationToken)
{
@@ -304,7 +303,7 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT
try
{
using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken);
var applySucceded = await runningProject.DeltaApplier.Apply(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
var applySucceded = await runningProject.DeltaApplier.ApplyManagedCodeUpdates(updates.ProjectUpdates, processCommunicationCancellationSource.Token) != ApplyStatus.Failed;
if (applySucceded)
{
runningProject.Reporter.Report(MessageDescriptor.HotReloadSucceeded);
@@ -331,7 +330,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates update
switch (updates.Status)
{
case ModuleUpdateStatus.None:
_reporter.Report(MessageDescriptor.NoHotReloadChangesToApply);
_reporter.Report(MessageDescriptor.NoCSharpChangesToApply);
break;

case ModuleUpdateStatus.Ready:
@@ -432,6 +431,102 @@ await ForEachProjectAsync(
cancellationToken);
}

public async ValueTask<bool> HandleStaticAssetChangesAsync(IReadOnlyList<ChangedFile> files, ProjectNodeMap projectMap, CancellationToken cancellationToken)
{
var allFilesHandled = true;

var updates = new Dictionary<RunningProject, List<(string filePath, string relativeUrl, ProjectGraphNode containingProject)>>();

foreach (var changedFile in files)
{
var file = changedFile.Item;

if (file.StaticWebAssetPath is null)
{
allFilesHandled = false;
continue;
}

foreach (var containingProjectPath in file.ContainingProjectPaths)
{
if (!projectMap.Map.TryGetValue(containingProjectPath, out var containingProjectNodes))
{
// Shouldn't happen.
_reporter.Warn($"Project '{containingProjectPath}' not found in the project graph.");
continue;
}

foreach (var containingProjectNode in containingProjectNodes)
{
foreach (var referencingProjectNode in new[] { containingProjectNode }.GetTransitivelyReferencingProjects())
{
if (TryGetRunningProject(referencingProjectNode.ProjectInstance.FullPath, out var runningProjects))
{
foreach (var runningProject in runningProjects)
{
if (!updates.TryGetValue(runningProject, out var updatesPerRunningProject))
{
updates.Add(runningProject, updatesPerRunningProject = []);
}

updatesPerRunningProject.Add((file.FilePath, file.StaticWebAssetPath, containingProjectNode));
}
}
}
}
}
}

if (updates.Count == 0)
{
return allFilesHandled;
}

var tasks = updates.Select(async entry =>
{
var (runningProject, assets) = entry;

if (runningProject.BrowserRefreshServer != null)
{
await runningProject.BrowserRefreshServer.UpdateStaticAssetsAsync(assets.Select(a => a.relativeUrl), cancellationToken);
}
else
{
var updates = new List<StaticAssetUpdate>();

foreach (var (filePath, relativeUrl, containingProject) in assets)
{
byte[] content;
try
{
content = await File.ReadAllBytesAsync(filePath, cancellationToken);
}
catch (Exception e)
{
_reporter.Error(e.Message);
continue;
}

updates.Add(new StaticAssetUpdate(
relativePath: relativeUrl,
assemblyName: containingProject.GetAssemblyName(),
content: content,
isApplicationProject: containingProject == runningProject.ProjectNode));

_reporter.Verbose($"Sending static file update request for asset '{relativeUrl}'.");
}

await runningProject.DeltaApplier.ApplyStaticAssetUpdates([.. updates], cancellationToken);
}
});

await Task.WhenAll(tasks).WaitAsync(cancellationToken);

_reporter.Output("Hot reload of static files succeeded.", emoji: "🔥");

return allFilesHandled;
}

/// <summary>
/// Terminates all processes launched for projects with <paramref name="projectPaths"/>,
/// or all running non-root project processes if <paramref name="projectPaths"/> is null.
@@ -506,6 +601,14 @@ private void UpdateRunningProjects(Func<ImmutableDictionary<string, ImmutableArr
}
}

public bool TryGetRunningProject(string projectPath, out ImmutableArray<RunningProject> projects)
{
lock (_runningProjectsAndUpdatesGuard)
{
return _runningProjects.TryGetValue(projectPath, out projects);
}
}

private static async ValueTask<IReadOnlyList<int>> TerminateRunningProjects(IEnumerable<RunningProject> projects, CancellationToken cancellationToken)
{
// cancel first, this will cause the process tasks to complete:
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.