From 1ea419e8b7ccd4b73e22fcdf261fac2c1ae5436e Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sat, 7 Dec 2024 09:40:39 -0800 Subject: [PATCH 1/5] Refactor environment variables usage in the agent --- .../DotNetDeltaApplier/StartupHook.cs | 32 ++--------------- .../EnvironmentVariableNames.cs | 35 +++++++++++++++++++ .../HotReloadAgent/HotReloadAgent.cs | 29 +++++++++++++++ .../dotnet-watch/EnvironmentVariables.cs | 9 +++-- .../EnvironmentVariablesBuilder.cs | 2 +- .../dotnet-watch/HotReload/ProjectLauncher.cs | 6 ++-- .../dotnet-watch/dotnet-watch.csproj | 1 + .../HotReloadAgentTest.cs | 23 ++++++++++++ .../StartupHookTests.cs | 22 ++---------- 9 files changed, 104 insertions(+), 55 deletions(-) create mode 100644 src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 5da690a314df..761840c35cb3 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -10,8 +10,8 @@ /// internal sealed class StartupHook { - private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.HotReloadDeltaClientLogMessages) == "1"; - private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadNamedPipeName); + private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(EnvironmentVariableNames.HotReloadDeltaClientLogMessages) == "1"; + private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName); /// /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. @@ -22,7 +22,7 @@ public static void Initialize() Log($"Loaded into process: {processPath}"); - ClearHotReloadEnvironmentVariables(); + HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook)); _ = Task.Run(async () => { @@ -101,32 +101,6 @@ public static bool IsMatchingProcess(string processPath, string targetProcessPat string.Equals(processPath[..^4], targetProcessPath[..^4], comparison); } - internal static void ClearHotReloadEnvironmentVariables() - { - // Clear any hot-reload specific environment variables. This prevents child processes from being - // affected by the current app's hot reload settings. See https://github.com/dotnet/runtime/issues/58000 - - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetStartupHooks, - RemoveCurrentAssembly(Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetStartupHooks))); - - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadNamedPipeName, ""); - Environment.SetEnvironmentVariable(EnvironmentVariables.Names.HotReloadDeltaClientLogMessages, ""); - } - - internal static string RemoveCurrentAssembly(string environment) - { - if (environment is "") - { - return environment; - } - - var assemblyLocation = typeof(StartupHook).Assembly.Location; - var updatedValues = environment.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) - .Where(e => !string.Equals(e, assemblyLocation, StringComparison.OrdinalIgnoreCase)); - - return string.Join(Path.PathSeparator, updatedValues); - } - private static void Log(string message) { if (s_logToStandardOutput) diff --git a/src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs b/src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs new file mode 100644 index 000000000000..65a200ba38bc --- /dev/null +++ b/src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.HotReload; + +internal static class EnvironmentVariableNames +{ + /// + /// Intentionally different from the variable name used by the debugger. + /// This is to avoid the debugger colliding with dotnet-watch pipe connection when debugging dotnet-watch (or tests). + /// + public const string DotNetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME"; + + /// + /// The full path to the process being launched by dotnet run. + /// Workaround for https://github.com/dotnet/sdk/issues/40484 + /// + public const string DotNetWatchHotReloadTargetProcessPath = "DOTNET_WATCH_HOTRELOAD_TARGET_PROCESS_PATH"; + + /// + /// Enables logging from the client delta applier agent. + /// + public const string HotReloadDeltaClientLogMessages = "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES"; + + /// + /// dotnet runtime environment variable. + /// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_startup_hooks + /// + public const string DotNetStartupHooks = "DOTNET_STARTUP_HOOKS"; + + /// + /// dotnet runtime environment variable. + /// + public const string DotNetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES"; +} diff --git a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs index c94b55e0e766..057766a7f41f 100644 --- a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs +++ b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; @@ -195,4 +196,32 @@ private void ApplyDeltas(Assembly assembly, IReadOnlyList deltas) return default; } + + /// + /// Clear any hot-reload specific environment variables. This prevents child processes from being + /// affected by the current app's hot reload settings. See https://github.com/dotnet/runtime/issues/58000 + /// + public static void ClearHotReloadEnvironmentVariables(Type startupHookType) + { + Environment.SetEnvironmentVariable(EnvironmentVariableNames.DotNetStartupHooks, + RemoveCurrentAssembly(startupHookType, Environment.GetEnvironmentVariable(EnvironmentVariableNames.DotNetStartupHooks)!)); + + Environment.SetEnvironmentVariable(EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName, ""); + Environment.SetEnvironmentVariable(EnvironmentVariableNames.HotReloadDeltaClientLogMessages, ""); + } + + // internal for testing + internal static string RemoveCurrentAssembly(Type startupHookType, string environment) + { + if (environment is "") + { + return environment; + } + + var assemblyLocation = startupHookType.Assembly.Location; + var updatedValues = environment.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(e => !string.Equals(e, assemblyLocation, StringComparison.OrdinalIgnoreCase)); + + return string.Join(Path.PathSeparator, updatedValues); + } } diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs index c31413b1d3f2..51bb2ef6c10a 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs @@ -3,9 +3,9 @@ namespace Microsoft.DotNet.Watch; -internal static partial class EnvironmentVariables +internal static class EnvironmentVariables { - public static partial class Names + public static class Names { public const string DotnetWatch = "DOTNET_WATCH"; public const string DotnetWatchIteration = "DOTNET_WATCH_ITERATION"; @@ -16,6 +16,11 @@ public static partial class Names public const string AspNetCoreHostingStartupAssemblies = "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"; public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; + + public const string DotNetWatchHotReloadNamedPipeName = HotReload.EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName; + public const string DotNetWatchHotReloadTargetProcessPath = HotReload.EnvironmentVariableNames.DotNetWatchHotReloadTargetProcessPath; + public const string DotNetStartupHooks = HotReload.EnvironmentVariableNames.DotNetStartupHooks; + public const string DotNetModifiableAssemblies = HotReload.EnvironmentVariableNames.DotNetModifiableAssemblies; } public static bool VerboseCliOutput => ReadBool("DOTNET_CLI_CONTEXT_VERBOSE"); diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs index 987e9910ab31..2beffbb40b8f 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs @@ -22,7 +22,7 @@ public static EnvironmentVariablesBuilder FromCurrentEnvironment() { var builder = new EnvironmentVariablesBuilder(); - if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetStartupHooks) is { } dotnetStartupHooks) + if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotNetStartupHooks) is { } dotnetStartupHooks) { builder.DotNetStartupHookDirective.AddRange(dotnetStartupHooks.Split(s_startupHooksSeparator)); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs index 4befce53dc52..d25af1edf6b4 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs @@ -64,7 +64,7 @@ public EnvironmentOptions EnvironmentOptions { // ignore dotnet-watch reserved variables -- these shouldn't be set by the project if (name.Equals(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, StringComparison.OrdinalIgnoreCase) || - name.Equals(EnvironmentVariables.Names.DotnetStartupHooks, StringComparison.OrdinalIgnoreCase)) + name.Equals(EnvironmentVariables.Names.DotNetStartupHooks, StringComparison.OrdinalIgnoreCase)) { continue; } @@ -81,12 +81,12 @@ public EnvironmentOptions EnvironmentOptions // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotnetModifiableAssemblies, "debug"); + environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetModifiableAssemblies, "debug"); if (injectDeltaApplier) { environmentBuilder.DotNetStartupHookDirective.Add(DeltaApplier.StartupHookPath); - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotnetWatchHotReloadNamedPipeName, namedPipeName); + environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetWatchHotReloadNamedPipeName, namedPipeName); if (context.Options.Verbose) { diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index b602aab0a0ca..8432ed4fe5e1 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -40,6 +40,7 @@ + diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs index 3c0673244bed..2053f7dda24d 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs @@ -9,6 +9,29 @@ namespace Microsoft.DotNet.Watch.UnitTests { public class HotReloadAgentTest { + [Fact] + public void ClearHotReloadEnvironmentVariables_ClearsStartupHook() + { + Assert.Equal("", + HotReloadAgent.RemoveCurrentAssembly(typeof(StartupHook), typeof(StartupHook).Assembly.Location)); + } + + [Fact] + public void ClearHotReloadEnvironmentVariables_PreservedOtherStartupHooks() + { + var customStartupHook = "/path/mycoolstartup.dll"; + Assert.Equal(customStartupHook, + HotReloadAgent.RemoveCurrentAssembly(typeof(StartupHook), typeof(StartupHook).Assembly.Location + Path.PathSeparator + customStartupHook)); + } + + [Fact] + public void ClearHotReloadEnvironmentVariables_RemovesHotReloadStartup_InCaseInvariantManner() + { + var customStartupHook = "/path/mycoolstartup.dll"; + Assert.Equal(customStartupHook, + HotReloadAgent.RemoveCurrentAssembly(typeof(StartupHook), customStartupHook + Path.PathSeparator + typeof(StartupHook).Assembly.Location.ToUpperInvariant())); + } + [Fact] public void TopologicalSort_Works() { diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StartupHookTests.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StartupHookTests.cs index 547f0618f049..6914eeb525a9 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StartupHookTests.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StartupHookTests.cs @@ -1,30 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.HotReload; + namespace Microsoft.DotNet.Watch.UnitTests { public class StartupHookTests { - [Fact] - public void ClearHotReloadEnvironmentVariables_ClearsStartupHook() - { - Assert.Equal("", StartupHook.RemoveCurrentAssembly(typeof(StartupHook).Assembly.Location)); - } - - [Fact] - public void ClearHotReloadEnvironmentVariables_PreservedOtherStartupHooks() - { - var customStartupHook = "/path/mycoolstartup.dll"; - Assert.Equal(customStartupHook, StartupHook.RemoveCurrentAssembly(typeof(StartupHook).Assembly.Location + Path.PathSeparator + customStartupHook)); - } - - [Fact] - public void ClearHotReloadEnvironmentVariables_RemovesHotReloadStartup_InCaseInvariantManner() - { - var customStartupHook = "/path/mycoolstartup.dll"; - Assert.Equal(customStartupHook, StartupHook.RemoveCurrentAssembly(customStartupHook + Path.PathSeparator + typeof(StartupHook).Assembly.Location.ToUpperInvariant())); - } - [Theory] [CombinatorialData] public void IsMatchingProcess_Matching_SimpleName( From 3bf466ce7a418d8772f33d7575f9895f927d94d0 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 8 Dec 2024 13:49:45 -0800 Subject: [PATCH 2/5] Initial updates and content update --- ...osoft.Extensions.DotNetDeltaApplier.csproj | 4 - .../DotNetDeltaApplier/StartupHook.cs | 177 ++++++++++++----- .../NamedPipeContract.cs | 41 ++-- ...eNames.cs => AgentEnvironmentVariables.cs} | 2 +- .../HotReloadAgent/HotReloadAgent.cs | 20 +- .../MetadataUpdateHandlerInvoker.cs | 187 ++++++++++++++---- .../HotReloadAgent/StaticAssetUpdate.cs | 13 ++ .../dotnet-watch/EnvironmentVariables.cs | 9 +- .../EnvironmentVariablesBuilder.cs | 18 +- .../EnvironmentVariables_StartupHook.cs | 24 --- .../BlazorWebAssemblyDeltaApplier.cs | 3 + .../BlazorWebAssemblyHostedDeltaApplier.cs | 3 + .../HotReload/CompilationHandler.cs | 68 ++----- .../HotReload/DefaultDeltaApplier.cs | 55 ++++-- .../dotnet-watch/HotReload/DeltaApplier.cs | 2 + .../dotnet-watch/HotReload/ProjectLauncher.cs | 5 +- .../dotnet-watch/HotReload/RunningProject.cs | 4 +- .../dotnet-watch/dotnet-watch.csproj | 3 +- .../HotReloadAgentTest.cs | 87 ++++---- ...Extensions.DotNetDeltaApplier.Tests.csproj | 4 + .../StaticAssetUpdateRequestTests.cs | 3 +- .../HotReload/ApplyDeltaTests.cs | 4 +- .../EnvironmentVariablesBuilderTests.cs | 12 +- test/dotnet-watch.Tests/Utilities/AssertEx.cs | 44 +++-- 24 files changed, 507 insertions(+), 285 deletions(-) rename src/BuiltInTools/HotReloadAgent/{EnvironmentVariableNames.cs => AgentEnvironmentVariables.cs} (96%) create mode 100644 src/BuiltInTools/HotReloadAgent/StaticAssetUpdate.cs delete mode 100644 src/BuiltInTools/dotnet-watch/EnvironmentVariables_StartupHook.cs diff --git a/src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj b/src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj index 4066a9794d98..97436e7b061e 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj +++ b/src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj @@ -13,10 +13,6 @@ enable - - - - diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 761840c35cb3..555e0de6ef28 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -2,16 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Pipes; -using Microsoft.DotNet.Watch; using Microsoft.DotNet.HotReload; +using System.Diagnostics; /// /// The runtime startup hook looks for top-level type named "StartupHook". /// internal sealed class StartupHook { - private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(EnvironmentVariableNames.HotReloadDeltaClientLogMessages) == "1"; - private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName); + private const int ConnectionTimeoutMS = 5000; + + private static readonly bool s_logToStandardOutput = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages) == "1"; + private static readonly string s_namedPipeName = Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName); /// /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. @@ -24,63 +26,142 @@ public static void Initialize() HotReloadAgent.ClearHotReloadEnvironmentVariables(typeof(StartupHook)); - _ = Task.Run(async () => + Log($"Connecting to hot-reload server"); + + // Connect to the pipe synchronously. + // + // If a debugger is attached and there is a breakpoint in the startup code connecting asynchronously would + // set up a race between this code connecting to the server, and the breakpoint being hit. If the breakpoint + // hits first, applying changes will throw an error that the client is not connected. + // + // Updates made before the process is launched need to be applied before loading the affected modules. + + var pipeClient = new NamedPipeClientStream(".", s_namedPipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + try { - Log($"Connecting to hot-reload server"); + pipeClient.Connect(ConnectionTimeoutMS); + Log("Connected."); + } + catch (TimeoutException) + { + Log($"Failed to connect in {ConnectionTimeoutMS}ms."); + return; + } - const int TimeOutMS = 5000; + using var agent = new HotReloadAgent(); + try + { + // block until initialization completes: + InitializeAsync(pipeClient, agent, CancellationToken.None).GetAwaiter().GetResult(); - using var pipeClient = new NamedPipeClientStream(".", s_namedPipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); - try - { - await pipeClient.ConnectAsync(TimeOutMS); - Log("Connected."); - } - catch (TimeoutException) - { - Log($"Failed to connect in {TimeOutMS}ms."); - return; - } + // fire and forget: + _ = ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: false, CancellationToken.None); + } + catch (Exception ex) + { + Log(ex.Message); + pipeClient.Dispose(); + } + } - using var agent = new HotReloadAgent(); - try - { - agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose); - var initPayload = new ClientInitializationRequest(agent.Capabilities); - await initPayload.WriteAsync(pipeClient, CancellationToken.None); + private static async ValueTask InitializeAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken) + { + agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose); - while (pipeClient.IsConnected) + var initPayload = new ClientInitializationResponse(agent.Capabilities); + await initPayload.WriteAsync(pipeClient, cancellationToken); + + // Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules. + await ReceiveAndApplyUpdatesAsync(pipeClient, agent, initialUpdates: true, cancellationToken); + } + + private static async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, bool initialUpdates, CancellationToken cancellationToken) + { + try + { + while (pipeClient.IsConnected) + { + var payloadType = (RequestType)await pipeClient.ReadByteAsync(cancellationToken); + switch (payloadType) { - var update = await ManagedCodeUpdateRequest.ReadAsync(pipeClient, CancellationToken.None); - Log($"ResponseLoggingLevel = {update.ResponseLoggingLevel}"); - - bool success; - try - { - agent.ApplyDeltas(update.Deltas); - success = true; - } - catch (Exception e) - { - agent.Reporter.Report($"The runtime failed to applying the change: {e.Message}", AgentMessageSeverity.Error); - agent.Reporter.Report("Further changes won't be applied to this process.", AgentMessageSeverity.Warning); - success = false; - } - - var logEntries = agent.GetAndClearLogEntries(update.ResponseLoggingLevel); - - var response = new UpdateResponse(logEntries, success); - await response.WriteAsync(pipeClient, CancellationToken.None); + case RequestType.ManagedCodeUpdate: + // 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); + await ReadAndApplyManagedCodeUpdateAsync(pipeClient, agent, cancellationToken); + break; + + case RequestType.StaticAssetUpdate: + await ReadAndApplyStaticAssetUpdateAsync(pipeClient, agent, cancellationToken); + break; + + case RequestType.InitialUpdatesCompleted when initialUpdates: + return; + + default: + // can't continue, the pipe content is in an unknown state + Log($"Unexpected payload type: {payloadType}. Terminating agent."); + return; } } - catch (Exception e) + } + catch (Exception ex) + { + Log(ex.Message); + } + finally + { + if (!pipeClient.IsConnected) { - Log(e.ToString()); + await pipeClient.DisposeAsync(); } + } + } + + private static async ValueTask ReadAndApplyManagedCodeUpdateAsync( + NamedPipeClientStream pipeClient, + HotReloadAgent agent, + CancellationToken cancellationToken) + { + var request = await ManagedCodeUpdateRequest.ReadAsync(pipeClient, cancellationToken); + + bool success; + try + { + agent.ApplyDeltas(request.Deltas); + success = true; + } + catch (Exception e) + { + agent.Reporter.Report($"The runtime failed to applying the change: {e.Message}", AgentMessageSeverity.Error); + agent.Reporter.Report("Further changes won't be applied to this process.", AgentMessageSeverity.Warning); + success = false; + } + + var logEntries = agent.GetAndClearLogEntries(request.ResponseLoggingLevel); + + var response = new UpdateResponse(logEntries, success); + await response.WriteAsync(pipeClient, cancellationToken); + } + + private static async ValueTask ReadAndApplyStaticAssetUpdateAsync( + NamedPipeClientStream pipeClient, + HotReloadAgent agent, + CancellationToken cancellationToken) + { + var request = await StaticAssetUpdateRequest.ReadAsync(pipeClient, cancellationToken); + + agent.ApplyStaticAssetUpdate(new StaticAssetUpdate(request.AssemblyName, request.RelativePath, request.Contents, request.IsApplicationProject)); + + var logEntries = agent.GetAndClearLogEntries(request.ResponseLoggingLevel); + + // Updating static asset only invokes ContentUpdate metadata update handlers. + // Failures of these handlers are reported to the log and ignored. + // Therefore, this request always succeeds. + var response = new UpdateResponse(logEntries, success: true); - Log("Stopped received delta updates. Server is no longer connected."); - }); + await response.WriteAsync(pipeClient, cancellationToken); } public static bool IsMatchingProcess(string processPath, string targetProcessPath) diff --git a/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs b/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs index a0ff1ccc7def..62686ddc226c 100644 --- a/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs +++ b/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs @@ -12,9 +12,14 @@ namespace Microsoft.DotNet.HotReload; internal interface IRequest { + RequestType Type { get; } ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken); } +internal interface IUpdateRequest : IRequest +{ +} + internal enum RequestType { ManagedCodeUpdate = 1, @@ -22,16 +27,14 @@ internal enum RequestType InitialUpdatesCompleted = 3, } -internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList deltas, ResponseLoggingLevel responseLoggingLevel) : IRequest +internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList deltas, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest { private const byte Version = 4; public IReadOnlyList Deltas { get; } = deltas; public ResponseLoggingLevel ResponseLoggingLevel { get; } = responseLoggingLevel; + public RequestType Type => RequestType.ManagedCodeUpdate; - /// - /// Called by the dotnet-watch. - /// public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) { await stream.WriteAsync(Version, cancellationToken); @@ -49,9 +52,6 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT await stream.WriteAsync((byte)ResponseLoggingLevel, cancellationToken); } - /// - /// Called by delta applier. - /// public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) { var version = await stream.ReadByteAsync(cancellationToken); @@ -114,25 +114,19 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT } } -internal readonly struct ClientInitializationRequest(string capabilities) : IRequest +internal readonly struct ClientInitializationResponse(string capabilities) { private const byte Version = 0; public string Capabilities { get; } = capabilities; - /// - /// Called by delta applier. - /// public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) { await stream.WriteAsync(Version, cancellationToken); await stream.WriteAsync(Capabilities, cancellationToken); } - /// - /// Called by dotnet-watch. - /// - public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) { var version = await stream.ReadByteAsync(cancellationToken); if (version != Version) @@ -141,7 +135,7 @@ public static async ValueTask ReadAsync(Stream stre } var capabilities = await stream.ReadStringAsync(cancellationToken); - return new ClientInitializationRequest(capabilities); + return new ClientInitializationResponse(capabilities); } } @@ -149,14 +143,18 @@ internal readonly struct StaticAssetUpdateRequest( string assemblyName, string relativePath, byte[] contents, - bool isApplicationProject) : IRequest + bool isApplicationProject, + ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest { - private const byte Version = 1; + private const byte Version = 2; public string AssemblyName { get; } = assemblyName; public bool IsApplicationProject { get; } = isApplicationProject; public string RelativePath { get; } = relativePath; public byte[] Contents { get; } = contents; + public ResponseLoggingLevel ResponseLoggingLevel { get; } = responseLoggingLevel; + + public RequestType Type => RequestType.StaticAssetUpdate; public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) { @@ -165,6 +163,7 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT await stream.WriteAsync(IsApplicationProject, cancellationToken); await stream.WriteAsync(RelativePath, cancellationToken); await stream.WriteByteArrayAsync(Contents, cancellationToken); + await stream.WriteAsync((byte)ResponseLoggingLevel, cancellationToken); } public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) @@ -176,14 +175,16 @@ public static async ValueTask ReadAsync(Stream stream, } var assemblyName = await stream.ReadStringAsync(cancellationToken); - var isAppProject = await stream.ReadBooleanAsync(cancellationToken); + var isApplicationProject = await stream.ReadBooleanAsync(cancellationToken); var relativePath = await stream.ReadStringAsync(cancellationToken); var contents = await stream.ReadByteArrayAsync(cancellationToken); + var responseLoggingLevel = (ResponseLoggingLevel)await stream.ReadByteAsync(cancellationToken); return new StaticAssetUpdateRequest( assemblyName: assemblyName, relativePath: relativePath, contents: contents, - isApplicationProject: isAppProject); + isApplicationProject, + responseLoggingLevel); } } diff --git a/src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs b/src/BuiltInTools/HotReloadAgent/AgentEnvironmentVariables.cs similarity index 96% rename from src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs rename to src/BuiltInTools/HotReloadAgent/AgentEnvironmentVariables.cs index 65a200ba38bc..606e5be8f881 100644 --- a/src/BuiltInTools/HotReloadAgent/EnvironmentVariableNames.cs +++ b/src/BuiltInTools/HotReloadAgent/AgentEnvironmentVariables.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.HotReload; -internal static class EnvironmentVariableNames +internal static class AgentEnvironmentVariables { /// /// Intentionally different from the variable name used by the debugger. diff --git a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs index 057766a7f41f..d226bd31d9f5 100644 --- a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs +++ b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs @@ -127,7 +127,9 @@ public void ApplyDeltas(IEnumerable deltas) cachedDeltas.Add(delta); } - _metadataUpdateHandlerInvoker.Invoke(GetMetadataUpdateTypes(deltas)); + _metadataUpdateHandlerInvoker.MetadataUpdated(GetMetadataUpdateTypes(deltas)); + + Reporter.Report("Deltas applied.", AgentMessageSeverity.Verbose); } private Type[] GetMetadataUpdateTypes(IEnumerable deltas) @@ -197,17 +199,25 @@ private void ApplyDeltas(Assembly assembly, IReadOnlyList deltas) return default; } + /// + /// Applies the content update. + /// + public void ApplyStaticAssetUpdate(StaticAssetUpdate update) + { + _metadataUpdateHandlerInvoker.ContentUpdated(update); + } + /// /// Clear any hot-reload specific environment variables. This prevents child processes from being /// affected by the current app's hot reload settings. See https://github.com/dotnet/runtime/issues/58000 /// public static void ClearHotReloadEnvironmentVariables(Type startupHookType) { - Environment.SetEnvironmentVariable(EnvironmentVariableNames.DotNetStartupHooks, - RemoveCurrentAssembly(startupHookType, Environment.GetEnvironmentVariable(EnvironmentVariableNames.DotNetStartupHooks)!)); + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.DotNetStartupHooks, + RemoveCurrentAssembly(startupHookType, Environment.GetEnvironmentVariable(AgentEnvironmentVariables.DotNetStartupHooks)!)); - Environment.SetEnvironmentVariable(EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName, ""); - Environment.SetEnvironmentVariable(EnvironmentVariableNames.HotReloadDeltaClientLogMessages, ""); + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName, ""); + Environment.SetEnvironmentVariable(AgentEnvironmentVariables.HotReloadDeltaClientLogMessages, ""); } // internal for testing diff --git a/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs b/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs index fa8b0630e840..5924d9fbc3ad 100644 --- a/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs +++ b/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Threading; +using System.Runtime.CompilerServices; namespace Microsoft.DotNet.HotReload; @@ -18,34 +19,71 @@ namespace Microsoft.DotNet.HotReload; #endif internal sealed class MetadataUpdateHandlerInvoker(AgentReporter reporter) { - internal sealed class RegisteredActions(IReadOnlyList> clearCache, IReadOnlyList> updateApplication) + internal delegate void ContentUpdateAction(StaticAssetUpdate update); + internal delegate void MetadataUpdateAction(Type[]? updatedTypes); + + internal readonly struct UpdateHandler(TAction action, MethodInfo method) + where TAction : Delegate + { + public TAction Action { get; } = action; + public MethodInfo Method { get; } = method; + + public void ReportInvocation(AgentReporter reporter) + => reporter.Report(GetHandlerDisplayString(Method), AgentMessageSeverity.Verbose); + } + + internal sealed class RegisteredActions( + IReadOnlyList> clearCacheHandlers, + IReadOnlyList> updateApplicationHandlers, + List> updateContentHandlers) { - public void Invoke(Type[] updatedTypes) + public void MetadataUpdated(AgentReporter reporter, Type[] updatedTypes) { - foreach (var action in clearCache) + foreach (var handler in clearCacheHandlers) { - action(updatedTypes); + handler.ReportInvocation(reporter); + handler.Action(updatedTypes); } - foreach (var action in updateApplication) + foreach (var handler in updateApplicationHandlers) { - action(updatedTypes); + handler.ReportInvocation(reporter); + handler.Action(updatedTypes); } } + public void UpdateContent(AgentReporter reporter, StaticAssetUpdate update) + { + foreach (var handler in updateContentHandlers) + { + handler.ReportInvocation(reporter); + handler.Action(update); + } + } + + /// + /// For testing. + /// + internal IEnumerable> ClearCacheHandlers => clearCacheHandlers; + /// /// For testing. /// - internal IEnumerable> ClearCache => clearCache; + internal IEnumerable> UpdateApplicationHandlers => updateApplicationHandlers; /// /// For testing. /// - internal IEnumerable> UpdateApplication => updateApplication; + internal IEnumerable> UpdateContentHandlers => updateContentHandlers; } private const string ClearCacheHandlerName = "ClearCache"; private const string UpdateApplicationHandlerName = "UpdateApplication"; + private const string UpdateContentHandlerName = "UpdateContent"; + private const BindingFlags HandlerMethodBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + + private static readonly Type[] s_contentUpdateSignature = [typeof(string), typeof(bool), typeof(string), typeof(byte[])]; + private static readonly Type[] s_metadataUpdateSignature = [typeof(Type[])]; private RegisteredActions? _actions; @@ -55,27 +93,47 @@ public void Invoke(Type[] updatedTypes) internal void Clear() => Interlocked.Exchange(ref _actions, null); + private RegisteredActions GetActions() + { + // Defer discovering metadata updata handlers until after hot reload deltas have been applied. + // This should give enough opportunity for AppDomain.GetAssemblies() to be sufficiently populated. + var actions = _actions; + if (actions == null) + { + Interlocked.CompareExchange(ref _actions, GetUpdateHandlerActions(), null); + actions = _actions; + } + + return actions; + } + /// - /// Invokes all registerd handlers. + /// Invokes all registered mtadata update handlers. /// - internal void Invoke(Type[] updatedTypes) + internal void MetadataUpdated(Type[] updatedTypes) { try { - // Defer discovering metadata updata handlers until after hot reload deltas have been applied. - // This should give enough opportunity for AppDomain.GetAssemblies() to be sufficiently populated. - var actions = _actions; - if (actions == null) - { - Interlocked.CompareExchange(ref _actions, GetMetadataUpdateHandlerActions(), null); - actions = _actions; - } - reporter.Report("Invoking metadata update handlers.", AgentMessageSeverity.Verbose); - actions.Invoke(updatedTypes); + GetActions().MetadataUpdated(reporter, updatedTypes); + } + catch (Exception e) + { + reporter.Report(e.ToString(), AgentMessageSeverity.Warning); + } + } + + /// + /// Invokes all registered content update handlers. + /// + internal void ContentUpdated(StaticAssetUpdate update) + { + try + { + reporter.Report("Invoking content update handlers.", AgentMessageSeverity.Verbose); - reporter.Report("Deltas applied.", AgentMessageSeverity.Verbose); + GetActions().UpdateContent(reporter, update); } catch (Exception e) { @@ -116,67 +174,111 @@ private IEnumerable GetHandlerTypes() } } - public RegisteredActions GetMetadataUpdateHandlerActions() - => GetMetadataUpdateHandlerActions(GetHandlerTypes()); + public RegisteredActions GetUpdateHandlerActions() + => GetUpdateHandlerActions(GetHandlerTypes()); /// /// Internal for testing. /// - internal RegisteredActions GetMetadataUpdateHandlerActions(IEnumerable handlerTypes) + internal RegisteredActions GetUpdateHandlerActions(IEnumerable handlerTypes) { - var clearCacheActions = new List>(); - var updateApplicationActions = new List>(); + var clearCacheHandlers = new List>(); + var applicationUpdateHandlers = new List>(); + var contentUpdateHandlers = new List>(); foreach (var handlerType in handlerTypes) { bool methodFound = false; - if (GetUpdateMethod(handlerType, ClearCacheHandlerName) is MethodInfo clearCache) + if (GetMetadataUpdateMethod(handlerType, ClearCacheHandlerName) is MethodInfo clearCache) { - clearCacheActions.Add(CreateAction(clearCache)); + clearCacheHandlers.Add(CreateMetadataUpdateAction(clearCache)); methodFound = true; } - if (GetUpdateMethod(handlerType, UpdateApplicationHandlerName) is MethodInfo updateApplication) + if (GetMetadataUpdateMethod(handlerType, UpdateApplicationHandlerName) is MethodInfo updateApplication) { - updateApplicationActions.Add(CreateAction(updateApplication)); + applicationUpdateHandlers.Add(CreateMetadataUpdateAction(updateApplication)); + methodFound = true; + } + + if (GetContentUpdateMethod(handlerType, UpdateContentHandlerName) is MethodInfo updateContent) + { + contentUpdateHandlers.Add(CreateContentUpdateAction(updateContent)); methodFound = true; } if (!methodFound) { reporter.Report( - $"Expected to find a static method '{ClearCacheHandlerName}' or '{UpdateApplicationHandlerName}' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", + $"Expected to find a static method '{ClearCacheHandlerName}', '{UpdateApplicationHandlerName}' or '{UpdateContentHandlerName}' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", AgentMessageSeverity.Warning); } } - return new RegisteredActions(clearCacheActions, updateApplicationActions); + return new RegisteredActions(clearCacheHandlers, applicationUpdateHandlers, contentUpdateHandlers); - Action CreateAction(MethodInfo update) + UpdateHandler CreateMetadataUpdateAction(MethodInfo method) { - var action = (Action)update.CreateDelegate(typeof(Action)); - return types => + var action = (MetadataUpdateAction)method.CreateDelegate(typeof(MetadataUpdateAction)); + return new(types => { try { action(types); } - catch (Exception ex) + catch (Exception e) { - reporter.Report($"Exception from '{action}': {ex}", AgentMessageSeverity.Warning); + ReportException(e, method); } - }; + }, method); } - MethodInfo? GetUpdateMethod(Type handlerType, string name) + UpdateHandler CreateContentUpdateAction(MethodInfo method) + { + var action = (Action)method.CreateDelegate(typeof(Action)); + return new(update => + { + try + { + action(update.AssemblyName, update.IsApplicationProject, update.RelativePath, update.Contents); + } + catch (Exception e) + { + ReportException(e, method); + } + }, method); + } + + void ReportException(Exception e, MethodInfo method) + => reporter.Report($"Exception from '{GetHandlerDisplayString(method)}': {e}", AgentMessageSeverity.Warning); + + MethodInfo? GetMetadataUpdateMethod(Type handlerType, string name) { - if (handlerType.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, binder: null, [typeof(Type[])], modifiers: null) is MethodInfo updateMethod && + if (handlerType.GetMethod(name, HandlerMethodBindingFlags, binder: null, s_metadataUpdateSignature, modifiers: null) is MethodInfo updateMethod && updateMethod.ReturnType == typeof(void)) { return updateMethod; } + ReportSignatureMismatch(handlerType, name); + return null; + } + + MethodInfo? GetContentUpdateMethod(Type handlerType, string name) + { + if (handlerType.GetMethod(name, HandlerMethodBindingFlags, binder: null, s_contentUpdateSignature, modifiers: null) is MethodInfo updateMethod && + updateMethod.ReturnType == typeof(void)) + { + return updateMethod; + } + + ReportSignatureMismatch(handlerType, name); + return null; + } + + void ReportSignatureMismatch(Type handlerType, string name) + { foreach (MethodInfo method in handlerType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) { if (method.Name == name) @@ -185,11 +287,12 @@ internal RegisteredActions GetMetadataUpdateHandlerActions(IEnumerable han break; } } - - return null; } } + private static string GetHandlerDisplayString(MethodInfo method) + => $"{method.DeclaringType!.FullName}.{method.Name}"; + private IList TryGetCustomAttributesData(Assembly assembly) { try diff --git a/src/BuiltInTools/HotReloadAgent/StaticAssetUpdate.cs b/src/BuiltInTools/HotReloadAgent/StaticAssetUpdate.cs new file mode 100644 index 000000000000..3601980b93ee --- /dev/null +++ b/src/BuiltInTools/HotReloadAgent/StaticAssetUpdate.cs @@ -0,0 +1,13 @@ +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticAssetUpdate( + string assemblyName, + string relativePath, + byte[] contents, + bool isApplicationProject) +{ + public string AssemblyName { get; } = assemblyName; + public bool IsApplicationProject { get; } = isApplicationProject; + public string RelativePath { get; } = relativePath; + public byte[] Contents { get; } = contents; +} diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs index 51bb2ef6c10a..0074a6c5daab 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs @@ -17,10 +17,11 @@ public static class Names public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; - public const string DotNetWatchHotReloadNamedPipeName = HotReload.EnvironmentVariableNames.DotNetWatchHotReloadNamedPipeName; - public const string DotNetWatchHotReloadTargetProcessPath = HotReload.EnvironmentVariableNames.DotNetWatchHotReloadTargetProcessPath; - public const string DotNetStartupHooks = HotReload.EnvironmentVariableNames.DotNetStartupHooks; - public const string DotNetModifiableAssemblies = HotReload.EnvironmentVariableNames.DotNetModifiableAssemblies; + public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; + public const string DotNetWatchHotReloadTargetProcessPath = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadTargetProcessPath; + public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; + public const string DotNetModifiableAssemblies = HotReload.AgentEnvironmentVariables.DotNetModifiableAssemblies; + public const string HotReloadDeltaClientLogMessages = HotReload.AgentEnvironmentVariables.HotReloadDeltaClientLogMessages; } public static bool VerboseCliOutput => ReadBool("DOTNET_CLI_CONTEXT_VERBOSE"); diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs index 2beffbb40b8f..640389af9175 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs @@ -10,8 +10,8 @@ internal sealed class EnvironmentVariablesBuilder private static readonly char s_startupHooksSeparator = Path.PathSeparator; private const char AssembliesSeparator = ';'; - public List DotNetStartupHookDirective { get; } = []; - public List AspNetCoreHostingStartupAssembliesVariable { get; } = []; + public List DotNetStartupHooks { get; } = []; + public List AspNetCoreHostingStartupAssemblies { get; } = []; /// /// Environment variables set on the dotnet run process. @@ -24,12 +24,12 @@ public static EnvironmentVariablesBuilder FromCurrentEnvironment() if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotNetStartupHooks) is { } dotnetStartupHooks) { - builder.DotNetStartupHookDirective.AddRange(dotnetStartupHooks.Split(s_startupHooksSeparator)); + builder.DotNetStartupHooks.AddRange(dotnetStartupHooks.Split(s_startupHooksSeparator)); } if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies) is { } assemblies) { - builder.AspNetCoreHostingStartupAssembliesVariable.AddRange(assemblies.Split(AssembliesSeparator)); + builder.AspNetCoreHostingStartupAssemblies.AddRange(assemblies.Split(AssembliesSeparator)); } return builder; @@ -39,7 +39,7 @@ public void SetVariable(string name, string value) { // should use AspNetCoreHostingStartupAssembliesVariable/DotNetStartupHookDirective Debug.Assert(!name.Equals(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, StringComparison.OrdinalIgnoreCase)); - Debug.Assert(!name.Equals(EnvironmentVariables.Names.DotnetStartupHooks, StringComparison.OrdinalIgnoreCase)); + Debug.Assert(!name.Equals(EnvironmentVariables.Names.DotNetStartupHooks, StringComparison.OrdinalIgnoreCase)); _variables[name] = value; } @@ -59,14 +59,14 @@ public void SetProcessEnvironmentVariables(ProcessSpec processSpec) yield return (name, value); } - if (DotNetStartupHookDirective is not []) + if (DotNetStartupHooks is not []) { - yield return (EnvironmentVariables.Names.DotnetStartupHooks, string.Join(s_startupHooksSeparator, DotNetStartupHookDirective)); + yield return (EnvironmentVariables.Names.DotNetStartupHooks, string.Join(s_startupHooksSeparator, DotNetStartupHooks)); } - if (AspNetCoreHostingStartupAssembliesVariable is not []) + if (AspNetCoreHostingStartupAssemblies is not []) { - yield return (EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, string.Join(AssembliesSeparator, AspNetCoreHostingStartupAssembliesVariable)); + yield return (EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, string.Join(AssembliesSeparator, AspNetCoreHostingStartupAssemblies)); } } } diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentVariables_StartupHook.cs b/src/BuiltInTools/dotnet-watch/EnvironmentVariables_StartupHook.cs deleted file mode 100644 index 7804e4b358fb..000000000000 --- a/src/BuiltInTools/dotnet-watch/EnvironmentVariables_StartupHook.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.DotNet.Watch; - -internal static partial class EnvironmentVariables -{ - public static partial class Names - { - /// - /// Intentionally different from the variable name used by the debugger. - /// This is to avoid the debugger colliding with dotnet-watch pipe connection when debugging dotnet-watch (or tests). - /// - public const string DotnetWatchHotReloadNamedPipeName = "DOTNET_WATCH_HOTRELOAD_NAMEDPIPE_NAME"; - - /// - /// Enables logging from the client delta applier agent. - /// - public const string HotReloadDeltaClientLogMessages = "HOTRELOAD_DELTA_CLIENT_LOG_MESSAGES"; - - public const string DotnetStartupHooks = "DOTNET_STARTUP_HOOKS"; - public const string DotnetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES"; - } -} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs index 35d0828ad56a..118844e36602 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs @@ -135,6 +135,9 @@ await browserRefreshServer.SendAndReceiveAsync( return (!anySuccess && anyFailure) ? ApplyStatus.Failed : (applicableUpdates.Count < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied; } + public override Task InitialUpdatesApplied(CancellationToken cancellationToken) + => Task.CompletedTask; + private readonly struct JsonApplyHotReloadDeltasRequest { public string Type => "BlazorHotReloadDeltav3"; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs index f2eb00de24bc..0d16366424b7 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs @@ -79,5 +79,8 @@ void ReportStatus(ApplyStatus status, string target) } } } + + public override Task InitialUpdatesApplied(CancellationToken cancellationToken) + => _hostApplier.InitialUpdatesApplied(cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 838c3f97ab70..046d3f360bc2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -37,19 +37,13 @@ internal sealed class CompilationHandler : IDisposable /// private ImmutableList _previousUpdates = []; - /// - /// Set of capabilities aggregated across the current set of . - /// Default if not calculated yet. - /// - private ImmutableArray _currentAggregateCapabilities; - private bool _isDisposed; public CompilationHandler(IReporter reporter) { _reporter = reporter; Workspace = new IncrementalMSBuildWorkspace(reporter); - _hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, GetAggregateCapabilitiesAsync); + _hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } public void Dispose() @@ -150,7 +144,10 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project return null; } - var capabilityProvider = deltaApplier.GetApplyUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token); + // Wait for agent to create the name pipe and send capabilities over. + // the agent blocks the app execution until initial updates are applied (if any). + var capabilities = await deltaApplier.GetApplyUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token); + var runningProject = new RunningProject( projectNode, projectOptions, @@ -163,20 +160,18 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project processTerminationSource: processTerminationSource, restartOperation: restartOperation, disposables: [processCommunicationCancellationSource], - capabilityProvider); + capabilities); // ownership transferred to running project: disposables.Items.Clear(); disposables.Items.Add(runningProject); - ImmutableArray observedCapabilities = default; - var appliedUpdateCount = 0; while (true) { // Observe updates that need to be applied to the new process // and apply them before adding it to running processes. - // Do bot block on udpates being made to other processes to avoid delaying the new process being up-to-date. + // Do not block on udpates being made to other processes to avoid delaying the new process being up-to-date. var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); if (updatesToApply.Any()) { @@ -205,26 +200,18 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject)); - // reset capabilities: - observedCapabilities = _currentAggregateCapabilities; - _currentAggregateCapabilities = default; - // ownership transferred to _runningProjects disposables.Items.Clear(); break; } } + // Notifies the agent that it can unblock the execution of the process: + await deltaApplier.InitialUpdatesApplied(cancellationToken); + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) { - // If capabilities have been observed by an edit session, restart the session. Next time EnC service needs - // capabilities it calls GetAggregateCapabilitiesAsync which uses the set of projects assigned above to calculate them. - if (!observedCapabilities.IsDefault) - { - _hotReloadService.CapabilitiesChanged(); - } - // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. PrepareCompilations(currentSolution, projectPath, cancellationToken); } @@ -232,33 +219,13 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project return runningProject; } - private async ValueTask> GetAggregateCapabilitiesAsync() + private ImmutableArray GetAggregateCapabilities() { - var capabilities = _currentAggregateCapabilities; - if (!capabilities.IsDefault) - { - return capabilities; - } - - while (true) - { - var runningProjects = _runningProjects; - var capabilitiesByProvider = await Task.WhenAll(runningProjects.SelectMany(p => p.Value).Select(p => p.CapabilityProvider)); - capabilities = capabilitiesByProvider.SelectMany(c => c).Distinct(StringComparer.Ordinal).ToImmutableArray(); - - lock (_runningProjectsAndUpdatesGuard) - { - if (runningProjects != _runningProjects) - { - // Another process has been launched while we were retrieving capabilities, query the providers again. - // The providers cache the result so we won't be calling into the respective processes again. - continue; - } - - _currentAggregateCapabilities = capabilities; - break; - } - } + var capabilities = _runningProjects + .SelectMany(p => p.Value) + .SelectMany(p => p.Capabilities) + .Distinct(StringComparer.Ordinal) + .ToImmutableArray(); _reporter.Verbose($"Hot reload capabilities: {string.Join(" ", capabilities)}.", emoji: "🔥"); return capabilities; @@ -536,9 +503,6 @@ private void UpdateRunningProjects(Func>? _capabilitiesTask; private NamedPipeServerStream? _pipe; - private bool _changeApplicationErrorFailed; + private bool _managedCodeUpdateFailedOrCancelled; public override void CreateConnection(string namedPipeName, CancellationToken cancellationToken) { @@ -36,7 +36,7 @@ async Task> ConnectAsync() // When the client connects, the first payload it sends is the initialization payload which includes the apply capabilities. - var capabilities = (await ClientInitializationRequest.ReadAsync(_pipe, cancellationToken)).Capabilities; + var capabilities = (await ClientInitializationResponse.ReadAsync(_pipe, cancellationToken)).Capabilities; Reporter.Verbose($"Capabilities: '{capabilities}'"); return [.. capabilities.Split(' ')]; } @@ -74,7 +74,7 @@ public override async Task Apply(ImmutableArray Apply(ImmutableArray Apply(ImmutableArray Apply(ImmutableArray SendAndReceiveUpdate(TRequest request, CancellationToken cancellationToken) + where TRequest : IUpdateRequest + { + // Should not be disposed: + Debug.Assert(_pipe != null); + + await _pipe.WriteAsync((byte)request.Type, cancellationToken); + await request.WriteAsync(_pipe, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + + var (success, log) = await UpdateResponse.ReadAsync(_pipe, cancellationToken); + + await foreach (var (message, severity) in log) + { + ReportLogEntry(Reporter, message, severity); + } + + return success; + } + + public override async Task InitialUpdatesApplied(CancellationToken cancellationToken) + { + // Should only be called after CreateConnection + Debug.Assert(_capabilitiesTask != null); + + // Should not be disposed: + Debug.Assert(_pipe != null); + + if (_managedCodeUpdateFailedOrCancelled) + { + return; + } + + await _pipe.WriteAsync((byte)RequestType.InitialUpdatesCompleted, cancellationToken); + await _pipe.FlushAsync(cancellationToken); + } + private void DisposePipe() { Reporter.Verbose("Disposing agent communication pipe"); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs index 78f6a02d01a6..fb0630ad37c3 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs @@ -26,6 +26,8 @@ internal abstract class DeltaApplier(IReporter reporter) : IDisposable public abstract Task Apply(ImmutableArray updates, CancellationToken cancellationToken); + public abstract Task InitialUpdatesApplied(CancellationToken cancellationToken); + public abstract void Dispose(); public static void ReportLogEntry(IReporter reporter, string message, AgentMessageSeverity severity) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs index d25af1edf6b4..5ee8c2073361 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; namespace Microsoft.DotNet.Watch; @@ -85,7 +86,9 @@ public EnvironmentOptions EnvironmentOptions if (injectDeltaApplier) { - environmentBuilder.DotNetStartupHookDirective.Add(DeltaApplier.StartupHookPath); + // HotReload startup hook should be loaded before any other startup hooks: + environmentBuilder.DotNetStartupHooks.Insert(0, DeltaApplier.StartupHookPath); + environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetWatchHotReloadNamedPipeName, namedPipeName); if (context.Options.Verbose) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs b/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs index 49d8268cf0cf..84a537c1c7b8 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs @@ -21,13 +21,13 @@ internal sealed class RunningProject( CancellationTokenSource processTerminationSource, RestartOperation restartOperation, IReadOnlyList disposables, - Task> capabilityProvider) : IDisposable + ImmutableArray capabilities) : IDisposable { public readonly ProjectGraphNode ProjectNode = projectNode; public readonly ProjectOptions Options = options; public readonly BrowserRefreshServer? BrowserRefreshServer = browserRefreshServer; public readonly DeltaApplier DeltaApplier = deltaApplier; - public readonly Task> CapabilityProvider = capabilityProvider; + public readonly ImmutableArray Capabilities = capabilities; public readonly IReporter Reporter = reporter; public readonly Task RunningProcess = runningProcess; public readonly int ProcessId = processId; diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index 8432ed4fe5e1..c4415d25b96c 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -40,10 +40,11 @@ - + + diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs index 2053f7dda24d..8f8dae9cd3db 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs @@ -78,40 +78,49 @@ public void TopologicalSort_WithCycles() Assert.Equal(new[] { assembly1, assembly3, assembly2, assembly4, assembly5 }, sortedList); } - [Fact] - public void GetHandlerActions_DiscoversActionsOnTypeWithClearCache() + [Theory] + [InlineData(typeof(HandlerWithClearCache))] + [InlineData(typeof(HandlerWithUpdateApplication))] + [InlineData(typeof(HandlerWithUpdateContent))] + public void GetHandlerActions_SingleAction(Type handlerType) { var reporter = new AgentReporter(); var invoker = new MetadataUpdateHandlerInvoker(reporter); - var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithClearCache)]); + var actions = invoker.GetUpdateHandlerActions([handlerType]); Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); - Assert.Single(actions.ClearCache); - Assert.Empty(actions.UpdateApplication); - } - - [Fact] - public void GetHandlerActions_DiscoversActionsOnTypeWithUpdateApplication() - { - var reporter = new AgentReporter(); - var invoker = new MetadataUpdateHandlerInvoker(reporter); - var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithUpdateApplication)]); - Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); - Assert.Empty(actions.ClearCache); - Assert.Single(actions.UpdateApplication); + if (handlerType == typeof(HandlerWithUpdateContent)) + { + Assert.Single(actions.UpdateContentHandlers); + Assert.Empty(actions.ClearCacheHandlers); + Assert.Empty(actions.UpdateApplicationHandlers); + } + else if (handlerType == typeof(HandlerWithUpdateApplication)) + { + Assert.Single(actions.UpdateApplicationHandlers); + Assert.Empty(actions.ClearCacheHandlers); + Assert.Empty(actions.UpdateContentHandlers); + } + else if (handlerType == typeof(HandlerWithClearCache)) + { + Assert.Single(actions.ClearCacheHandlers); + Assert.Empty(actions.UpdateContentHandlers); + Assert.Empty(actions.UpdateApplicationHandlers); + } } [Fact] - public void GetHandlerActions_DiscoversActionsOnTypeWithBothActions() + public void GetHandlerActions_DiscoversActionsOnTypeWithAllActions() { var reporter = new AgentReporter(); var invoker = new MetadataUpdateHandlerInvoker(reporter); - var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithBothActions)]); + var actions = invoker.GetUpdateHandlerActions([typeof(HandlerWithAllActions)]); - Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); - Assert.Single(actions.ClearCache); - Assert.Single(actions.UpdateApplication); + AssertEx.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); + Assert.Equal(typeof(HandlerWithAllActions).GetMethod("ClearCache", BindingFlags.Static | BindingFlags.NonPublic), actions.ClearCacheHandlers.Single().Method); + Assert.Equal(typeof(HandlerWithAllActions).GetMethod("UpdateApplication", BindingFlags.Static | BindingFlags.NonPublic), actions.UpdateApplicationHandlers.Single().Method); + Assert.Equal(typeof(HandlerWithAllActions).GetMethod("UpdateContent", BindingFlags.Static | BindingFlags.NonPublic), actions.UpdateContentHandlers.Single().Method); } [Fact] @@ -121,14 +130,19 @@ public void GetHandlerActions_LogsMessageIfMethodHasIncorrectSignature() var invoker = new MetadataUpdateHandlerInvoker(reporter); var handlerType = typeof(HandlerWithIncorrectSignature); - var actions = invoker.GetMetadataUpdateHandlerActions([handlerType]); + var actions = invoker.GetUpdateHandlerActions([handlerType]); var log = reporter.GetAndClearLogEntries(ResponseLoggingLevel.WarningsAndErrors); - var logEntry = Assert.Single(log); - Assert.Equal($"Type '{handlerType}' has method 'Void ClearCache()' that does not match the required signature.", logEntry.message); - Assert.Equal(AgentMessageSeverity.Warning, logEntry.severity); - Assert.Empty(actions.ClearCache); - Assert.Single(actions.UpdateApplication); + AssertEx.SequenceEqual( + [ + $"Warning: Type '{handlerType}' has method 'Void ClearCache()' that does not match the required signature.", + $"Warning: Type '{handlerType}' has method 'Void UpdateContent()' that does not match the required signature." + ], + log.Select(e => $"{e.severity}: {e.message}")); + + Assert.Empty(actions.ClearCacheHandlers); + Assert.Empty(actions.UpdateContentHandlers); + Assert.Single(actions.UpdateApplicationHandlers); } [Fact] @@ -138,16 +152,16 @@ public void GetHandlerActions_LogsMessageIfNoActionsAreDiscovered() var invoker = new MetadataUpdateHandlerInvoker(reporter); var handlerType = typeof(HandlerWithNoActions); - var actions = invoker.GetMetadataUpdateHandlerActions([handlerType]); + var actions = invoker.GetUpdateHandlerActions([handlerType]); var log = reporter.GetAndClearLogEntries(ResponseLoggingLevel.WarningsAndErrors); var logEntry = Assert.Single(log); Assert.Equal( - $"Expected to find a static method 'ClearCache' or 'UpdateApplication' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", logEntry.message); + $"Expected to find a static method 'ClearCache', 'UpdateApplication' or 'UpdateContent' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", logEntry.message); Assert.Equal(AgentMessageSeverity.Warning, logEntry.severity); - Assert.Empty(actions.ClearCache); - Assert.Empty(actions.UpdateApplication); + Assert.Empty(actions.ClearCacheHandlers); + Assert.Empty(actions.UpdateApplicationHandlers); } private static Assembly GetAssembly(string fullName, AssemblyName[] dependencies) @@ -170,15 +184,22 @@ private class HandlerWithUpdateApplication internal static void UpdateApplication(Type[]? _) { } } - private class HandlerWithBothActions + private class HandlerWithUpdateContent + { + public static void UpdateContent(string assemblyName, bool isApplicationProject, string relativePath, byte[] contents) { } + } + + private class HandlerWithAllActions { internal static void ClearCache(Type[]? _) { } internal static void UpdateApplication(Type[]? _) { } + internal static void UpdateContent(string assemblyName, bool isApplicationProject, string relativePath, byte[] contents) { } } private class HandlerWithIncorrectSignature { - internal static void ClearCache() { } + internal static void ClearCache() { } + internal static void UpdateContent() { } internal static void UpdateApplication(Type[]? _) { } } diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj index fd5ec4160e7a..22a4e69809df 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj @@ -8,6 +8,10 @@ Exe + + + + diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StaticAssetUpdateRequestTests.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StaticAssetUpdateRequestTests.cs index 7d271e167ed4..4486270c472b 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StaticAssetUpdateRequestTests.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/StaticAssetUpdateRequestTests.cs @@ -14,7 +14,8 @@ public async Task Roundtrip() assemblyName: "assembly name", relativePath: "some path", [1, 2, 3], - isApplicationProject: true); + isApplicationProject: true, + responseLoggingLevel: ResponseLoggingLevel.WarningsAndErrors); using var stream = new MemoryStream(); await initial.WriteAsync(stream, CancellationToken.None); diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index c73380b7f4b8..59549dbf7b21 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -248,7 +248,7 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); await App.WaitUntilOutputContains( - $"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Expected to find a static method 'ClearCache' or 'UpdateApplication' on type 'AppUpdateHandler, WatchHotReloadApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' but neither exists."); + $"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Expected to find a static method 'ClearCache', 'UpdateApplication' or 'UpdateContent' on type 'AppUpdateHandler, WatchHotReloadApp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' but neither exists."); } [Theory] @@ -287,7 +287,7 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); - await App.WaitUntilOutputContains($"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!"); + await App.WaitUntilOutputContains($"dotnet watch ⚠ [WatchHotReloadApp ({ToolsetInfo.CurrentTargetFramework})] Exception from 'AppUpdateHandler.ClearCache': System.InvalidOperationException: Bug!"); if (verbose) { diff --git a/test/dotnet-watch.Tests/Internal/EnvironmentVariablesBuilderTests.cs b/test/dotnet-watch.Tests/Internal/EnvironmentVariablesBuilderTests.cs index b9b4b29ccce1..509cadb17854 100644 --- a/test/dotnet-watch.Tests/Internal/EnvironmentVariablesBuilderTests.cs +++ b/test/dotnet-watch.Tests/Internal/EnvironmentVariablesBuilderTests.cs @@ -9,8 +9,8 @@ public class EnvironmentVariablesBuilderTests public void Value() { var builder = new EnvironmentVariablesBuilder(); - builder.DotNetStartupHookDirective.Add("a"); - builder.AspNetCoreHostingStartupAssembliesVariable.Add("b"); + builder.DotNetStartupHooks.Add("a"); + builder.AspNetCoreHostingStartupAssemblies.Add("b"); var env = builder.GetEnvironment(); AssertEx.SequenceEqual( @@ -24,10 +24,10 @@ public void Value() public void MultipleValues() { var builder = new EnvironmentVariablesBuilder(); - builder.DotNetStartupHookDirective.Add("a1"); - builder.DotNetStartupHookDirective.Add("a2"); - builder.AspNetCoreHostingStartupAssembliesVariable.Add("b1"); - builder.AspNetCoreHostingStartupAssembliesVariable.Add("b2"); + builder.DotNetStartupHooks.Add("a1"); + builder.DotNetStartupHooks.Add("a2"); + builder.AspNetCoreHostingStartupAssemblies.Add("b1"); + builder.AspNetCoreHostingStartupAssemblies.Add("b2"); var env = builder.GetEnvironment(); AssertEx.SequenceEqual( diff --git a/test/dotnet-watch.Tests/Utilities/AssertEx.cs b/test/dotnet-watch.Tests/Utilities/AssertEx.cs index ca4e251fa288..07e37a604de4 100644 --- a/test/dotnet-watch.Tests/Utilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/Utilities/AssertEx.cs @@ -1,7 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#pragma warning disable IDE0240 // nullable is redundant +#nullable enable + using System.Collections; +using System.Diagnostics; using Xunit.Sdk; namespace Microsoft.DotNet.Watch.UnitTests @@ -34,7 +38,7 @@ public static bool Equals(T left, T right) return Instance.Equals(left, right); } - bool IEqualityComparer.Equals(T x, T y) + bool IEqualityComparer.Equals(T? x, T? y) { if (CanBeNull()) { @@ -49,7 +53,7 @@ bool IEqualityComparer.Equals(T x, T y) } } - if (x.GetType() != y.GetType()) + if (x!.GetType() != y!.GetType()) { return false; } @@ -103,7 +107,7 @@ int IEqualityComparer.GetHashCode(T obj) } } - public static void Equal(T expected, T actual, IEqualityComparer comparer = null, string message = null) + public static void Equal(T expected, T actual, IEqualityComparer? comparer = null, string? message = null) { if (ReferenceEquals(expected, actual)) { @@ -134,19 +138,19 @@ public static void Equal(T expected, T actual, IEqualityComparer comparer public static void Equal( IEnumerable expected, IEnumerable actual, - IEqualityComparer comparer = null, - string message = null, - string itemSeparator = null, - Func itemInspector = null) + IEqualityComparer? comparer = null, + string? message = null, + string? itemSeparator = null, + Func? itemInspector = null) => SequenceEqual(expected, actual, comparer, message, itemSeparator, itemInspector); public static void SequenceEqual( IEnumerable expected, IEnumerable actual, - IEqualityComparer comparer = null, - string message = null, - string itemSeparator = null, - Func itemInspector = null) + IEqualityComparer? comparer = null, + string? message = null, + string? itemSeparator = null, + Func? itemInspector = null) { if (expected == null) { @@ -157,7 +161,10 @@ public static void SequenceEqual( Assert.NotNull(actual); } - if (!expected.SequenceEqual(actual, comparer ?? EqualityComparer.Default)) + Debug.Assert(expected != null); + Debug.Assert(actual != null); + + if (!expected.SequenceEqual(actual, comparer)) { Fail(GetAssertMessage(expected, actual, message, itemInspector, itemSeparator)); } @@ -166,11 +173,11 @@ public static void SequenceEqual( private static string GetAssertMessage( IEnumerable expected, IEnumerable actual, - string prefix = null, - Func itemInspector = null, - string itemSeparator = null) + string? prefix = null, + Func? itemInspector = null, + string? itemSeparator = null) { - itemInspector ??= (typeof(T) == typeof(byte)) ? b => $"0x{b:X2}" : new Func(obj => (obj != null) ? obj.ToString() : ""); + itemInspector ??= (typeof(T) == typeof(byte)) ? b => $"0x{b:X2}" : new Func(obj => (obj != null) ? obj.ToString() ?? "" : ""); itemSeparator ??= (typeof(T) == typeof(byte)) ? ", " : "," + Environment.NewLine; var expectedString = string.Join(itemSeparator, expected.Take(10).Select(itemInspector)); @@ -197,9 +204,12 @@ private static string GetAssertMessage( return message.ToString(); } - public static void Empty(string actual, string message = null) + public static void Empty(string actual, string? message = null) => Equal("", actual, message: message); + public static void Empty(IEnumerable collection) + => SequenceEqual([], collection); + public static void Fail(string message) => throw new XunitException(message); From d49206f435fb132bb46a7ac62dbbfd5abac945d6 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 13 Jan 2025 18:15:24 -0800 Subject: [PATCH 3/5] Implement static asset updating --- .../DotNetDeltaApplier/StartupHook.cs | 8 +- .../dotnet-watch/Browser/BrowserConnector.cs | 14 ++- .../Browser/BrowserRefreshServer.cs | 25 +++- .../BlazorWebAssemblyDeltaApplier.cs | 6 +- .../BlazorWebAssemblyHostedDeltaApplier.cs | 10 +- .../HotReload/CompilationHandler.cs | 113 +++++++++++++++++- .../HotReload/DefaultDeltaApplier.cs | 57 ++++++++- .../dotnet-watch/HotReload/DeltaApplier.cs | 3 +- .../IStaticAssetChangeApplierProvider.cs | 16 +++ .../HotReload/ScopedCssFileHandler.cs | 33 ++--- .../HotReload/StaticAssetUpdate.cs | 12 ++ .../HotReload/StaticFileHandler.cs | 21 +--- .../dotnet-watch/HotReloadDotNetWatcher.cs | 5 +- .../dotnet-watch/Internal/IReporter.cs | 2 +- .../Properties/launchSettings.json | 2 +- .../Utilities/ProjectGraphNodeExtensions.cs | 3 + .../HotReload/ApplyDeltaTests.cs | 23 ++-- .../HotReload/RuntimeProcessLauncherTests.cs | 2 +- 18 files changed, 272 insertions(+), 83 deletions(-) create mode 100644 src/BuiltInTools/dotnet-watch/HotReload/IStaticAssetChangeApplierProvider.cs create mode 100644 src/BuiltInTools/dotnet-watch/HotReload/StaticAssetUpdate.cs diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 555e0de6ef28..8bc118e9bdef 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -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(); + } } } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs index 9126b8ec54b6..8256a67ab420 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs @@ -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) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 0f7c737e5687..ec9e12e07a87 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -22,7 +22,7 @@ namespace Microsoft.DotNet.Watch /// /// Communicates with aspnetcore-browser-refresh.js loaded in the browser. /// - internal sealed class BrowserRefreshServer : IAsyncDisposable + internal sealed class BrowserRefreshServer : IAsyncDisposable, IStaticAssetChangeApplier { private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); private static readonly ReadOnlyMemory 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 messageBytes, CancellationToken cancellationToken) + private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); public async ValueTask SendAndReceiveAsync( @@ -354,6 +354,17 @@ public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray co } } + public async ValueTask UpdateStaticAssetsAsync(IEnumerable 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 Diagnostics { get; init; } } + + private readonly struct UpdateStaticFileMessage + { + public string Type => "UpdateStaticFile"; + public string Path { get; init; } + } } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs index 118844e36602..617d7d733856 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs @@ -67,7 +67,7 @@ public override Task> GetApplyUpdateCapabilitiesAsync(Can return Task.FromResult(capabilities); } - public override async Task Apply(ImmutableArray updates, CancellationToken cancellationToken) + public override async Task ApplyManagedCodeUpdates(ImmutableArray 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 ApplyStaticAssetUpdates(ImmutableArray updates, CancellationToken cancellationToken) + // static asset updates are handled by browser refresh server: + => Task.FromResult(ApplyStatus.NoChangesApplied); + public override Task InitialUpdatesApplied(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs index 0d16366424b7..2499e85e13c1 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs @@ -41,7 +41,7 @@ public override async Task> GetApplyUpdateCapabilitiesAsy return result[0].Union(result[1], StringComparer.OrdinalIgnoreCase).ToImmutableArray(); } - public override async Task Apply(ImmutableArray updates, CancellationToken cancellationToken) + public override async Task ApplyManagedCodeUpdates(ImmutableArray 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 Apply(ImmutableArray ApplyStaticAssetUpdates(ImmutableArray 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); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 046d3f360bc2..6266a7815411 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -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 projectsToRebuild, ImmutableArray terminatedProjects)> HandleFileChangesAsync( + public async ValueTask<(ImmutableDictionary projectsToRebuild, ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( Func, 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 HandleStaticAssetChangesAsync(IReadOnlyList files, ProjectNodeMap projectMap, CancellationToken cancellationToken) + { + var allFilesHandled = true; + + var updates = new Dictionary>(); + + 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(); + + 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; + } + /// /// Terminates all processes launched for projects with , /// or all running non-root project processes if is null. @@ -506,6 +601,14 @@ private void UpdateRunningProjects(Func projects) + { + lock (_runningProjectsAndUpdatesGuard) + { + return _runningProjects.TryGetValue(projectPath, out projects); + } + } + private static async ValueTask> TerminateRunningProjects(IEnumerable projects, CancellationToken cancellationToken) { // cancel first, this will cause the process tasks to complete: diff --git a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs index 38eb1c98d986..5d7f507984cc 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs @@ -66,7 +66,10 @@ public override Task> GetApplyUpdateCapabilitiesAsync(Can // Should only be called after CreateConnection => _capabilitiesTask ?? throw new InvalidOperationException(); - public override async Task Apply(ImmutableArray updates, CancellationToken cancellationToken) + private ResponseLoggingLevel ResponseLoggingLevel + => Reporter.IsVerbose ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors; + + public override async Task ApplyManagedCodeUpdates(ImmutableArray updates, CancellationToken cancellationToken) { // Should only be called after CreateConnection Debug.Assert(_capabilitiesTask != null); @@ -94,7 +97,7 @@ public override async Task Apply(ImmutableArray Apply(ImmutableArray ApplyStaticAssetUpdates(ImmutableArray updates, CancellationToken cancellationToken) + { + var appliedUpdateCount = 0; + + foreach (var update in updates) + { + var request = new StaticAssetUpdateRequest( + update.AssemblyName, + update.RelativePath, + update.Content, + update.IsApplicationProject, + ResponseLoggingLevel); + + var success = false; + var canceled = false; + try + { + success = await SendAndReceiveUpdate(request, cancellationToken); + } + catch (OperationCanceledException) when (!(canceled = true)) + { + // unreachable + } + catch (Exception e) when (e is not OperationCanceledException) + { + success = false; + Reporter.Error($"Change failed to apply (error: '{e.Message}')."); + Reporter.Verbose($"Exception stack trace: {e.StackTrace}", "❌"); + } + finally + { + if (canceled) + { + Reporter.Verbose("Change application cancelled.", "🔥"); + } + } + + if (success) + { + appliedUpdateCount++; + } + } + + Reporter.Report(MessageDescriptor.UpdatesApplied, appliedUpdateCount, updates.Length); + + return + (appliedUpdateCount == 0) ? ApplyStatus.Failed : + (appliedUpdateCount < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied; + } + private async ValueTask SendAndReceiveUpdate(TRequest request, CancellationToken cancellationToken) where TRequest : IUpdateRequest { diff --git a/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs index fb0630ad37c3..852c9295e949 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DeltaApplier.cs @@ -24,7 +24,8 @@ internal abstract class DeltaApplier(IReporter reporter) : IDisposable public abstract Task> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken); - public abstract Task Apply(ImmutableArray updates, CancellationToken cancellationToken); + public abstract Task ApplyManagedCodeUpdates(ImmutableArray updates, CancellationToken cancellationToken); + public abstract Task ApplyStaticAssetUpdates(ImmutableArray updates, CancellationToken cancellationToken); public abstract Task InitialUpdatesApplied(CancellationToken cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/IStaticAssetChangeApplierProvider.cs b/src/BuiltInTools/dotnet-watch/HotReload/IStaticAssetChangeApplierProvider.cs new file mode 100644 index 000000000000..ad2f9491cc70 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/HotReload/IStaticAssetChangeApplierProvider.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch; + +internal interface IStaticAssetChangeApplierProvider +{ + bool TryGetApplier(ProjectGraphNode projectNode, [NotNullWhen(true)] out IStaticAssetChangeApplier? applier); +} + +internal interface IStaticAssetChangeApplier +{ +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs index d9f241eb0fab..6c30ef91c0d9 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs @@ -4,7 +4,6 @@ using Microsoft.Build.Framework; using Microsoft.Build.Graph; -using Microsoft.TemplateEngine.Utils; namespace Microsoft.DotNet.Watch { @@ -81,12 +80,13 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList files, { if (browserConnector.TryGetRefreshServer(projectNode, out var browserRefreshServer)) { - reporter.Verbose($"[{projectNode.GetDisplayName()}] Refreshing browser."); - await HandleBrowserRefresh(browserRefreshServer, projectNode.ProjectInstance.FullPath, cancellationToken); - } - else - { - reporter.Verbose($"[{projectNode.GetDisplayName()}] No refresh server."); + // We'd like an accurate scoped css path, but this needs a lot of work to wire-up now. + // We'll handle this as part of https://github.com/dotnet/aspnetcore/issues/31217. + // For now, we'll make it look like some css file which would cause JS to update a + // single file if it's from the current project, or all locally hosted css files if it's a file from + // referenced project. + var relativeUrl = Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath) + ".css"; + await browserRefreshServer.UpdateStaticAssetsAsync([relativeUrl], cancellationToken); } }); @@ -107,24 +107,5 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList files, reporter.Output("Hot reload of scoped css failed.", emoji: "🔥"); } } - - private static async Task HandleBrowserRefresh(BrowserRefreshServer browserRefreshServer, string containingProjectPath, CancellationToken cancellationToken) - { - // We'd like an accurate scoped css path, but this needs a lot of work to wire-up now. - // We'll handle this as part of https://github.com/dotnet/aspnetcore/issues/31217. - // For now, we'll make it look like some css file which would cause JS to update a - // single file if it's from the current project, or all locally hosted css files if it's a file from - // referenced project. - var cssFilePath = Path.GetFileNameWithoutExtension(containingProjectPath) + ".css"; - var message = new UpdateStaticFileMessage { Path = cssFilePath }; - await browserRefreshServer.SendJsonMessageAsync(message, cancellationToken); - } - - private readonly struct UpdateStaticFileMessage - { - public string Type => "UpdateStaticFile"; - - public string Path { get; init; } - } } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/StaticAssetUpdate.cs b/src/BuiltInTools/dotnet-watch/HotReload/StaticAssetUpdate.cs new file mode 100644 index 000000000000..8a2438ac1ea1 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/HotReload/StaticAssetUpdate.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watch; + +internal readonly struct StaticAssetUpdate(string relativePath, string assemblyName, byte[] content, bool isApplicationProject) +{ + public string RelativePath { get; } = relativePath; + public string AssemblyName { get; } = assemblyName; + public byte[] Content { get; } = content; + public bool IsApplicationProject { get; } = isApplicationProject; +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs index 05fde9ab4001..c89ee64fb86a 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs @@ -1,20 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. - -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.Build.Graph; namespace Microsoft.DotNet.Watch { internal sealed class StaticFileHandler(IReporter reporter, ProjectNodeMap projectMap, BrowserConnector browserConnector) { - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken) { var allFilesHandled = true; @@ -67,20 +59,11 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f return allFilesHandled; } - var tasks = refreshRequests.Select(async request => - { - // Serialize all requests sent to a single server: - foreach (var path in request.Value) - { - reporter.Verbose($"Sending static file update request for asset '{path}'."); - var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = path }, s_jsonSerializerOptions); - await request.Key.SendAsync(message, cancellationToken); - } - }); + var tasks = refreshRequests.Select(request => request.Key.UpdateStaticAssetsAsync(request.Value, cancellationToken).AsTask()); await Task.WhenAll(tasks).WaitAsync(cancellationToken); - reporter.Output("Hot Reload of static files succeeded.", emoji: "🔥"); + reporter.Output("Hot reload of static files succeeded.", emoji: "🔥"); return allFilesHandled; } diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index 563fb89fc7f8..581fbe0531ef 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -102,7 +102,6 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke await using var browserConnector = new BrowserConnector(Context); var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter); compilationHandler = new CompilationHandler(Context.Reporter); - var staticFileHandler = new StaticFileHandler(Context.Reporter, projectMap, browserConnector); var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector); var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration); var outputDirectories = GetProjectOutputDirectories(evaluationResult.ProjectGraph); @@ -255,7 +254,7 @@ void FileChangedCallback(ChangedPath change) var stopwatch = Stopwatch.StartNew(); HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); - await staticFileHandler.HandleFileChangesAsync(changedFiles, iterationCancellationToken); + await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, iterationCancellationToken); HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.ScopedCssHandler); @@ -264,7 +263,7 @@ void FileChangedCallback(ChangedPath change) HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); - var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleFileChangesAsync(restartPrompt: async (projectNames, cancellationToken) => + var (projectsToRebuild, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync(restartPrompt: async (projectNames, cancellationToken) => { if (_rudeEditRestartPrompt != null) { diff --git a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs index e216f30af874..e00058d62221 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/IReporter.cs @@ -75,7 +75,7 @@ public bool TryGetMessage(string? prefix, object?[] args, [NotNullWhen(true)] ou public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = new("Ignoring change in hidden directory '{0}': {1} '{2}'", "⌚", MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = new("Ignoring change in output directory: {0} '{1}'", "⌚", MessageSeverity.Verbose, s_id++); public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = new("File addition triggered re-evaluation.", "⌚", MessageSeverity.Verbose, s_id++); - public static readonly MessageDescriptor NoHotReloadChangesToApply = new ("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++); + public static readonly MessageDescriptor NoCSharpChangesToApply = new ("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++); } internal interface IReporter diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index de41cecc021a..e23d24fc9c66 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose /bl:DotnetRun.binlog", - "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", + "workingDirectory": "C:\\sdk2\\artifacts\\tmp\\Debug\\Razor_Compone---4AB6877C\\RazorApp", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", diff --git a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs index d46945d500d9..42ff4703992c 100644 --- a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs +++ b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs @@ -36,6 +36,9 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVe public static string? GetOutputDirectory(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue("TargetPath") is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; + public static string GetAssemblyName(this ProjectGraphNode projectNode) + => projectNode.ProjectInstance.GetPropertyValue("TargetName"); + public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue("IntermediateOutputPath") is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null; diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 59549dbf7b21..4107dabcd335 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -88,7 +88,7 @@ public async Task BaselineCompilationError() System.Console.WriteLine(""); """); - await App.AssertOutputLineStartsWith(""); + await App.AssertOutputLineStartsWith("", failure: _ => false); } /// @@ -409,11 +409,9 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() UpdateSourceFile(scopedCssPath, newCss); await App.AssertOutputLineStartsWith("dotnet watch 🔥 Hot reload change handled"); - App.AssertOutputContains($"dotnet watch ⌚ Handling file change event for scoped css file {scopedCssPath}."); - App.AssertOutputContains($"dotnet watch ⌚ [RazorClassLibrary ({ToolsetInfo.CurrentTargetFramework})] No refresh server."); - App.AssertOutputContains($"dotnet watch ⌚ [RazorApp ({ToolsetInfo.CurrentTargetFramework})] Refreshing browser."); + App.AssertOutputContains($"dotnet watch ⌚ Sending static asset update request to browser: 'RazorApp.css'."); App.AssertOutputContains($"dotnet watch 🔥 Hot reload of scoped css succeeded."); - App.AssertOutputContains($"dotnet watch ⌚ No C# changes to apply."); + App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); App.Process.ClearOutput(); var cssPath = Path.Combine(testAsset.Path, "RazorApp", "wwwroot", "app.css"); @@ -421,10 +419,9 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() await App.AssertOutputLineStartsWith("dotnet watch 🔥 Hot reload change handled"); - App.AssertOutputContains($"dotnet watch ⌚ Sending static file update request for asset 'app.css'."); - App.AssertOutputContains($"dotnet watch ⌚ [RazorApp ({ToolsetInfo.CurrentTargetFramework})] Refreshing browser."); - App.AssertOutputContains($"dotnet watch 🔥 Hot Reload of static files succeeded."); - App.AssertOutputContains($"dotnet watch ⌚ No C# changes to apply."); + App.AssertOutputContains($"dotnet watch ⌚ Sending static asset update request to browser: 'app.css'."); + App.AssertOutputContains($"dotnet watch 🔥 Hot reload of static files succeeded."); + App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); App.Process.ClearOutput(); } @@ -459,17 +456,17 @@ public async Task MauiBlazor() await App.AssertOutputLineStartsWith("dotnet watch 🔥 Hot reload change handled"); // TODO: Warning is currently reported because UpdateContent is not recognized - // dotnet watch ⚠ [maui-blazor (net9.0-windows10.0.19041.0)] Expected to find a static method 'ClearCache' or 'UpdateApplication' on type 'Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager - App.AssertOutputContains("Expected to find a static method"); App.AssertOutputContains("Updates applied: 1 out of 1."); + App.AssertOutputContains("Microsoft.AspNetCore.Components.HotReload.HotReloadManager.UpdateApplication"); + App.Process.ClearOutput(); // update static asset: var cssPath = Path.Combine(testAsset.Path, "wwwroot", "css", "app.css"); UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;")); - await App.AssertOutputLineStartsWith("dotnet watch 🔥 Hot reload change handled"); - + App.AssertOutputContains("Updates applied: 1 out of 1."); + App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); App.AssertOutputContains("No C# changes to apply."); } diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index ee39c5954c1c..e21d47efa471 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -589,7 +589,7 @@ public async Task IgnoredChange(bool isExisting, bool isIncluded, DirectoryKind var ignoringChangeInHiddenDirectory = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInHiddenDirectory); var ignoringChangeInOutputDirectory = w.Reporter.RegisterSemaphore(MessageDescriptor.IgnoringChangeInOutputDirectory); var fileAdditionTriggeredReEvaluation = w.Reporter.RegisterSemaphore(MessageDescriptor.FileAdditionTriggeredReEvaluation); - var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoHotReloadChangesToApply); + var noHotReloadChangesToApply = w.Reporter.RegisterSemaphore(MessageDescriptor.NoCSharpChangesToApply); Log("Waiting for changes..."); await waitingForChanges.WaitAsync(w.ShutdownSource.Token); From eac97e91cd399c585be2533994b9b872c8e24792 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Thu, 16 Jan 2025 20:12:40 -0800 Subject: [PATCH 4/5] Feedback --- src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs | 2 +- src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs | 4 +++- src/BuiltInTools/dotnet-watch/Properties/launchSettings.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 8bc118e9bdef..59787ed4f47b 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -1,9 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.IO.Pipes; using Microsoft.DotNet.HotReload; -using System.Diagnostics; /// /// The runtime startup hook looks for top-level type named "StartupHook". diff --git a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs index d226bd31d9f5..f88d44707ce3 100644 --- a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs +++ b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs @@ -228,9 +228,11 @@ internal static string RemoveCurrentAssembly(Type startupHookType, string enviro return environment; } + var comparison = Path.DirectorySeparatorChar == '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var assemblyLocation = startupHookType.Assembly.Location; var updatedValues = environment.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) - .Where(e => !string.Equals(e, assemblyLocation, StringComparison.OrdinalIgnoreCase)); + .Where(e => !string.Equals(e, assemblyLocation, comparison)); return string.Join(Path.PathSeparator, updatedValues); } diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index e23d24fc9c66..de41cecc021a 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose /bl:DotnetRun.binlog", - "workingDirectory": "C:\\sdk2\\artifacts\\tmp\\Debug\\Razor_Compone---4AB6877C\\RazorApp", + "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", From d80a6a8bb4b5d40c7da1cac9dc083c8693ad7892 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 19 Jan 2025 14:02:25 -0800 Subject: [PATCH 5/5] Fix --- .../HotReloadAgentTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs index 8f8dae9cd3db..ce3644bd4e6a 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs @@ -24,7 +24,7 @@ public void ClearHotReloadEnvironmentVariables_PreservedOtherStartupHooks() HotReloadAgent.RemoveCurrentAssembly(typeof(StartupHook), typeof(StartupHook).Assembly.Location + Path.PathSeparator + customStartupHook)); } - [Fact] + [PlatformSpecificFact(TestPlatforms.Windows)] public void ClearHotReloadEnvironmentVariables_RemovesHotReloadStartup_InCaseInvariantManner() { var customStartupHook = "/path/mycoolstartup.dll";