From 71498843ce2f4926821e40bbd50dd4f1a20e95d1 Mon Sep 17 00:00:00 2001 From: tmat Date: Tue, 24 Sep 2024 15:05:48 -0700 Subject: [PATCH 1/3] Improve logging from agent --- .../DotNetDeltaApplier/AgentReporter.cs | 28 ++ .../DotNetDeltaApplier/HotReloadAgent.cs | 237 +++++------------ .../MetadataUpdateHandlerInvoker.cs | 239 ++++++++++++++++++ .../DotNetDeltaApplier/StartupHook.cs | 71 ++---- .../dotnet-watch/EnvironmentOptions.cs | 6 + .../HotReload/DefaultDeltaApplier.cs | 44 +++- .../HotReload/NamedPipeContract.cs | 62 ++++- .../dotnet-watch/HotReloadDotNetWatcher.cs | 6 +- .../Internal/HotReloadFileSetWatcher.cs | 10 +- .../Internal/MessagePrefixingReporter.cs | 3 + .../HotReloadAgentTest.cs | 72 +++--- .../TestProjects/WatchHotReloadApp/Program.cs | 14 +- .../HotReload/ApplyDeltaTests.cs | 83 ++++++ .../HotReload/UpdatePayloadTests.cs | 11 +- .../Watch/Utilities/WatchableApp.cs | 6 +- 15 files changed, 589 insertions(+), 303 deletions(-) create mode 100644 src/BuiltInTools/DotNetDeltaApplier/AgentReporter.cs create mode 100644 src/BuiltInTools/DotNetDeltaApplier/MetadataUpdateHandlerInvoker.cs diff --git a/src/BuiltInTools/DotNetDeltaApplier/AgentReporter.cs b/src/BuiltInTools/DotNetDeltaApplier/AgentReporter.cs new file mode 100644 index 000000000000..37b50cd78318 --- /dev/null +++ b/src/BuiltInTools/DotNetDeltaApplier/AgentReporter.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.HotReload; + +internal sealed class AgentReporter +{ + private readonly List<(string message, AgentMessageSeverity severity)> _log = []; + + public void Report(string message, AgentMessageSeverity severity) + { + _log.Add((message, severity)); + } + + public IReadOnlyCollection<(string message, AgentMessageSeverity severity)> GetAndClearLogEntries(ResponseLoggingLevel level) + { + lock (_log) + { + var filteredLog = (level != ResponseLoggingLevel.Verbose) + ? _log.Where(static entry => entry.severity != AgentMessageSeverity.Verbose) + : _log; + + var log = filteredLog.ToArray(); + _log.Clear(); + return log; + } + } +} diff --git a/src/BuiltInTools/DotNetDeltaApplier/HotReloadAgent.cs b/src/BuiltInTools/DotNetDeltaApplier/HotReloadAgent.cs index 50301fdbf15c..36dbbae52846 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/HotReloadAgent.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/HotReloadAgent.cs @@ -3,219 +3,119 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.IO.Pipes; using System.Reflection; namespace Microsoft.Extensions.HotReload { internal sealed class HotReloadAgent : IDisposable { + private const string MetadataUpdaterTypeName = "System.Reflection.Metadata.MetadataUpdater"; + private const string ApplyUpdateMethodName = "ApplyUpdate"; + private const string GetCapabilitiesMethodName = "GetCapabilities"; + private delegate void ApplyUpdateDelegate(Assembly assembly, ReadOnlySpan metadataDelta, ReadOnlySpan ilDelta, ReadOnlySpan pdbDelta); - private readonly Action _log; + private readonly AgentReporter _reporter = new(); + private readonly NamedPipeClientStream _pipeClient; + private readonly Action _stdOutLog; private readonly AssemblyLoadEventHandler _assemblyLoad; private readonly ConcurrentDictionary> _deltas = new(); private readonly ConcurrentDictionary _appliedAssemblies = new(); private readonly ApplyUpdateDelegate? _applyUpdate; private readonly string? _capabilities; - private volatile UpdateHandlerActions? _handlerActions; + private readonly MetadataUpdateHandlerInvoker _metadataUpdateHandlerInvoker; - public HotReloadAgent(Action log) + public HotReloadAgent(NamedPipeClientStream pipeClient, Action stdOutLog) { - var metadataUpdater = Type.GetType("System.Reflection.Metadata.MetadataUpdater, System.Runtime.Loader", throwOnError: false); - - if (metadataUpdater != null) - { - _applyUpdate = (ApplyUpdateDelegate?)metadataUpdater.GetMethod("ApplyUpdate", BindingFlags.Public | BindingFlags.Static, binder: null, - new[] { typeof(Assembly), typeof(ReadOnlySpan), typeof(ReadOnlySpan), typeof(ReadOnlySpan) }, modifiers: null)?.CreateDelegate(typeof(ApplyUpdateDelegate)); - - if (_applyUpdate != null) - { - try - { - _capabilities = metadataUpdater.GetMethod("GetCapabilities", BindingFlags.NonPublic | BindingFlags.Static, binder: null, Type.EmptyTypes, modifiers: null)?. - Invoke(obj: null, parameters: null) as string; - } - catch - { - } - } - } - - _log = log; _assemblyLoad = OnAssemblyLoad; + _pipeClient = pipeClient; + _stdOutLog = stdOutLog; + _metadataUpdateHandlerInvoker = new(_reporter); + + GetUpdaterMethods(out _applyUpdate, out _capabilities); AppDomain.CurrentDomain.AssemblyLoad += _assemblyLoad; } - public string Capabilities => _capabilities ?? string.Empty; - - private void OnAssemblyLoad(object? _, AssemblyLoadEventArgs eventArgs) + private void GetUpdaterMethods(out ApplyUpdateDelegate? applyUpdate, out string? capabilities) { - _handlerActions = null; - var loadedAssembly = eventArgs.LoadedAssembly; - var moduleId = TryGetModuleId(loadedAssembly); - if (moduleId is null) + applyUpdate = null; + capabilities = null; + + var metadataUpdater = Type.GetType(MetadataUpdaterTypeName + ", System.Runtime.Loader", throwOnError: false); + if (metadataUpdater == null) { + _reporter.Report($"Type not found: {MetadataUpdaterTypeName}", AgentMessageSeverity.Error); return; } - if (_deltas.TryGetValue(moduleId.Value, out var updateDeltas) && _appliedAssemblies.TryAdd(loadedAssembly, loadedAssembly)) + var applyUpdateMethod = metadataUpdater.GetMethod(ApplyUpdateMethodName, BindingFlags.Public | BindingFlags.Static, binder: null, [typeof(Assembly), typeof(ReadOnlySpan), typeof(ReadOnlySpan), typeof(ReadOnlySpan)], modifiers: null); + if (applyUpdateMethod == null) { - // A delta for this specific Module exists and we haven't called ApplyUpdate on this instance of Assembly as yet. - ApplyDeltas(loadedAssembly, updateDeltas); + _reporter.Report($"{MetadataUpdaterTypeName}.{ApplyUpdateMethodName} not found.", AgentMessageSeverity.Error); + return; } - } - internal sealed class UpdateHandlerActions - { - public List> ClearCache { get; } = new(); - public List> UpdateApplication { get; } = new(); - } + applyUpdate = (ApplyUpdateDelegate)applyUpdateMethod.CreateDelegate(typeof(ApplyUpdateDelegate)); - private UpdateHandlerActions GetMetadataUpdateHandlerActions() - { - // We need to execute MetadataUpdateHandlers in a well-defined order. For v1, the strategy that is used is to topologically - // sort assemblies so that handlers in a dependency are executed before the dependent (e.g. the reflection cache action - // in System.Private.CoreLib is executed before System.Text.Json clears its own cache.) - // This would ensure that caches and updates more lower in the application stack are up to date - // before ones higher in the stack are recomputed. - var sortedAssemblies = TopologicalSort(AppDomain.CurrentDomain.GetAssemblies()); - var handlerActions = new UpdateHandlerActions(); - foreach (var assembly in sortedAssemblies) + var getCapabilities = metadataUpdater.GetMethod(GetCapabilitiesMethodName, BindingFlags.NonPublic | BindingFlags.Static, binder: null, Type.EmptyTypes, modifiers: null); + if (getCapabilities == null) { - foreach (var attr in TryGetCustomAttributesData(assembly)) - { - // Look up the attribute by name rather than by type. This would allow netstandard targeting libraries to - // define their own copy without having to cross-compile. - if (attr.AttributeType.FullName != "System.Reflection.Metadata.MetadataUpdateHandlerAttribute") - { - continue; - } - - IList ctorArgs = attr.ConstructorArguments; - if (ctorArgs.Count != 1 || - ctorArgs[0].Value is not Type handlerType) - { - _log($"'{attr}' found with invalid arguments."); - continue; - } - - GetHandlerActions(handlerActions, handlerType); - } + _reporter.Report($"{MetadataUpdaterTypeName}.{GetCapabilitiesMethodName} not found.", AgentMessageSeverity.Error); + return; } - return handlerActions; - } - - private IList TryGetCustomAttributesData(Assembly assembly) - { try { - return assembly.GetCustomAttributesData(); + capabilities = getCapabilities.Invoke(obj: null, parameters: null) as string; } catch (Exception e) { - // In cross-platform scenarios, such as debugging in VS through WSL, Roslyn - // runs on Windows, and the agent runs on Linux. Assemblies accessible to Windows - // may not be available or loaded on linux (such as WPF's assemblies). - // In such case, we can ignore the assemblies and continue enumerating handlers for - // the rest of the assemblies of current domain. - _log($"'{assembly.FullName}' is not loaded ({e.Message})"); - - return new List(); + _reporter.Report($"Error retrieving capabilities: {e.Message}", AgentMessageSeverity.Error); } } - internal void GetHandlerActions(UpdateHandlerActions handlerActions, Type handlerType) + public async Task ReceiveDeltasAsync() { - bool methodFound = false; + _reporter.Report("Writing capabilities: " + Capabilities, AgentMessageSeverity.Verbose); - if (GetUpdateMethod(handlerType, "ClearCache") is MethodInfo clearCache) - { - handlerActions.ClearCache.Add(CreateAction(clearCache)); - methodFound = true; - } + var initPayload = new ClientInitializationPayload(Capabilities); + initPayload.Write(_pipeClient); - if (GetUpdateMethod(handlerType, "UpdateApplication") is MethodInfo updateApplication) + while (_pipeClient.IsConnected) { - handlerActions.UpdateApplication.Add(CreateAction(updateApplication)); - methodFound = true; - } + var update = await UpdatePayload.ReadAsync(_pipeClient, CancellationToken.None); - if (!methodFound) - { - _log($"No invokable methods found on metadata handler type '{handlerType}'. " + - $"Allowed methods are ClearCache, UpdateApplication"); - } + _stdOutLog($"ResponseLoggingLevel = {update.ResponseLoggingLevel}"); - Action CreateAction(MethodInfo update) - { - var action = (Action)update.CreateDelegate(typeof(Action)); - return types => - { - try - { - action(types); - } - catch (Exception ex) - { - _log($"Exception from '{action}': {ex}"); - } - }; - } + _reporter.Report("Attempting to apply deltas.", AgentMessageSeverity.Verbose); - MethodInfo? GetUpdateMethod(Type handlerType, string name) - { - if (handlerType.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, binder: null, new[] { typeof(Type[]) }, modifiers: null) is MethodInfo updateMethod && - updateMethod.ReturnType == typeof(void)) - { - return updateMethod; - } + ApplyDeltas(update.Deltas); - foreach (MethodInfo method in handlerType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) - { - if (method.Name == name) - { - _log($"Type '{handlerType}' has method '{method}' that does not match the required signature."); - break; - } - } + _pipeClient.WriteByte(UpdatePayload.ApplySuccessValue); - return null; + UpdatePayload.WriteLog(_pipeClient, _reporter.GetAndClearLogEntries(update.ResponseLoggingLevel)); } } - internal static List TopologicalSort(Assembly[] assemblies) - { - var sortedAssemblies = new List(assemblies.Length); + public string Capabilities => _capabilities ?? string.Empty; - var visited = new HashSet(StringComparer.Ordinal); + private void OnAssemblyLoad(object? _, AssemblyLoadEventArgs eventArgs) + { + _metadataUpdateHandlerInvoker.Clear(); - foreach (var assembly in assemblies) + var loadedAssembly = eventArgs.LoadedAssembly; + var moduleId = TryGetModuleId(loadedAssembly); + if (moduleId is null) { - Visit(assemblies, assembly, sortedAssemblies, visited); + return; } - static void Visit(Assembly[] assemblies, Assembly assembly, List sortedAssemblies, HashSet visited) + if (_deltas.TryGetValue(moduleId.Value, out var updateDeltas) && _appliedAssemblies.TryAdd(loadedAssembly, loadedAssembly)) { - var assemblyIdentifier = assembly.GetName().Name!; - if (!visited.Add(assemblyIdentifier)) - { - return; - } - - foreach (var dependencyName in assembly.GetReferencedAssemblies()) - { - var dependency = Array.Find(assemblies, a => a.GetName().Name == dependencyName.Name); - if (dependency is not null) - { - Visit(assemblies, dependency, sortedAssemblies, visited); - } - } - - sortedAssemblies.Add(assembly); + // A delta for this specific Module exists and we haven't called ApplyUpdate on this instance of Assembly as yet. + ApplyDeltas(loadedAssembly, updateDeltas); } - - return sortedAssemblies; } public void ApplyDeltas(IReadOnlyList deltas) @@ -230,7 +130,7 @@ public void ApplyDeltas(IReadOnlyList deltas) { if (TryGetModuleId(assembly) is Guid moduleId && moduleId == item.ModuleId) { - _applyUpdate(assembly, item.MetadataDelta, item.ILDelta, ReadOnlySpan.Empty); + _applyUpdate(assembly, item.MetadataDelta, item.ILDelta, pdbDelta: []); } } @@ -239,24 +139,7 @@ public void ApplyDeltas(IReadOnlyList deltas) cachedDeltas.Add(item); } - 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. - _handlerActions ??= GetMetadataUpdateHandlerActions(); - var handlerActions = _handlerActions; - - Type[]? updatedTypes = GetMetadataUpdateTypes(deltas); - - handlerActions.ClearCache.ForEach(a => a(updatedTypes)); - handlerActions.UpdateApplication.ForEach(a => a(updatedTypes)); - - _log("Deltas applied."); - } - catch (Exception ex) - { - _log(ex.ToString()); - } + _metadataUpdateHandlerInvoker.Invoke(GetMetadataUpdateTypes(deltas)); } private Type[] GetMetadataUpdateTypes(IReadOnlyList deltas) @@ -285,7 +168,7 @@ private Type[] GetMetadataUpdateTypes(IReadOnlyList deltas) } catch (Exception e) { - _log($"Failed to load type 0x{updatedType:X8}: {e.Message}"); + _reporter.Report($"Failed to load type 0x{updatedType:X8}: {e.Message}", AgentMessageSeverity.Warning); } } } @@ -304,11 +187,11 @@ public void ApplyDeltas(Assembly assembly, IReadOnlyList deltas) _applyUpdate(assembly, item.MetadataDelta, item.ILDelta, ReadOnlySpan.Empty); } - _log("Deltas applied."); + _reporter.Report("Deltas applied.", AgentMessageSeverity.Verbose); } catch (Exception ex) { - _log(ex.ToString()); + _reporter.Report(ex.ToString(), AgentMessageSeverity.Warning); } } diff --git a/src/BuiltInTools/DotNetDeltaApplier/MetadataUpdateHandlerInvoker.cs b/src/BuiltInTools/DotNetDeltaApplier/MetadataUpdateHandlerInvoker.cs new file mode 100644 index 000000000000..223960330e82 --- /dev/null +++ b/src/BuiltInTools/DotNetDeltaApplier/MetadataUpdateHandlerInvoker.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection; + +namespace Microsoft.Extensions.HotReload; + +/// +/// Finds and invokes metadata update handlers. +/// +internal sealed class MetadataUpdateHandlerInvoker(AgentReporter reporter) +{ + internal sealed class RegisteredActions(IReadOnlyList> clearCache, IReadOnlyList> updateApplication) + { + public void Invoke(Type[] updatedTypes) + { + foreach (var action in clearCache) + { + action(updatedTypes); + } + + foreach (var action in updateApplication) + { + action(updatedTypes); + } + } + + /// + /// For testing. + /// + internal IEnumerable> ClearCache => clearCache; + + /// + /// For testing. + /// + internal IEnumerable> UpdateApplication => updateApplication; + } + + private const string ClearCacheHandlerName = "ClearCache"; + private const string UpdateApplicationHandlerName = "UpdateApplication"; + + private RegisteredActions? _actions; + + /// + /// Call when a new assembly is loaded. + /// + internal void Clear() + => Interlocked.Exchange(ref _actions, null); + + /// + /// Invokes all registerd handlers. + /// + internal void Invoke(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); + + reporter.Report("Deltas applied.", AgentMessageSeverity.Verbose); + } + catch (Exception e) + { + reporter.Report(e.ToString(), AgentMessageSeverity.Warning); + } + } + + private IEnumerable GetHandlerTypes() + { + // We need to execute MetadataUpdateHandlers in a well-defined order. For v1, the strategy that is used is to topologically + // sort assemblies so that handlers in a dependency are executed before the dependent (e.g. the reflection cache action + // in System.Private.CoreLib is executed before System.Text.Json clears its own cache.) + // This would ensure that caches and updates more lower in the application stack are up to date + // before ones higher in the stack are recomputed. + var sortedAssemblies = TopologicalSort(AppDomain.CurrentDomain.GetAssemblies()); + + foreach (var assembly in sortedAssemblies) + { + foreach (var attr in TryGetCustomAttributesData(assembly)) + { + // Look up the attribute by name rather than by type. This would allow netstandard targeting libraries to + // define their own copy without having to cross-compile. + if (attr.AttributeType.FullName != "System.Reflection.Metadata.MetadataUpdateHandlerAttribute") + { + continue; + } + + IList ctorArgs = attr.ConstructorArguments; + if (ctorArgs.Count != 1 || + ctorArgs[0].Value is not Type handlerType) + { + reporter.Report($"'{attr}' found with invalid arguments.", AgentMessageSeverity.Warning); + continue; + } + + yield return handlerType; + } + } + } + + public RegisteredActions GetMetadataUpdateHandlerActions() + => GetMetadataUpdateHandlerActions(GetHandlerTypes()); + + /// + /// Internal for testing. + /// + internal RegisteredActions GetMetadataUpdateHandlerActions(IEnumerable handlerTypes) + { + var clearCacheActions = new List>(); + var updateApplicationActions = new List>(); + + foreach (var handlerType in handlerTypes) + { + bool methodFound = false; + + if (GetUpdateMethod(handlerType, ClearCacheHandlerName) is MethodInfo clearCache) + { + clearCacheActions.Add(CreateAction(clearCache)); + methodFound = true; + } + + if (GetUpdateMethod(handlerType, UpdateApplicationHandlerName) is MethodInfo updateApplication) + { + updateApplicationActions.Add(CreateAction(updateApplication)); + methodFound = true; + } + + if (!methodFound) + { + reporter.Report( + $"Expected to find a static method '{ClearCacheHandlerName}' or '{UpdateApplicationHandlerName}' on type '{handlerType.AssemblyQualifiedName}' but neither exists.", + AgentMessageSeverity.Warning); + } + } + + return new RegisteredActions(clearCacheActions, updateApplicationActions); + + Action CreateAction(MethodInfo update) + { + var action = (Action)update.CreateDelegate(typeof(Action)); + return types => + { + try + { + action(types); + } + catch (Exception ex) + { + reporter.Report($"Exception from '{action}': {ex}", AgentMessageSeverity.Warning); + } + }; + } + + MethodInfo? GetUpdateMethod(Type handlerType, string name) + { + if (handlerType.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, binder: null, new[] { typeof(Type[]) }, modifiers: null) is MethodInfo updateMethod && + updateMethod.ReturnType == typeof(void)) + { + return updateMethod; + } + + foreach (MethodInfo method in handlerType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (method.Name == name) + { + reporter.Report($"Type '{handlerType}' has method '{method}' that does not match the required signature.", AgentMessageSeverity.Warning); + break; + } + } + + return null; + } + } + + private IList TryGetCustomAttributesData(Assembly assembly) + { + try + { + return assembly.GetCustomAttributesData(); + } + catch (Exception e) + { + // In cross-platform scenarios, such as debugging in VS through WSL, Roslyn + // runs on Windows, and the agent runs on Linux. Assemblies accessible to Windows + // may not be available or loaded on linux (such as WPF's assemblies). + // In such case, we can ignore the assemblies and continue enumerating handlers for + // the rest of the assemblies of current domain. + reporter.Report($"'{assembly.FullName}' is not loaded ({e.Message})", AgentMessageSeverity.Verbose); + return []; + } + } + + /// + /// Internal for testing. + /// + internal static List TopologicalSort(Assembly[] assemblies) + { + var sortedAssemblies = new List(assemblies.Length); + + var visited = new HashSet(StringComparer.Ordinal); + + foreach (var assembly in assemblies) + { + Visit(assemblies, assembly, sortedAssemblies, visited); + } + + static void Visit(Assembly[] assemblies, Assembly assembly, List sortedAssemblies, HashSet visited) + { + var assemblyIdentifier = assembly.GetName().Name!; + if (!visited.Add(assemblyIdentifier)) + { + return; + } + + foreach (var dependencyName in assembly.GetReferencedAssemblies()) + { + var dependency = Array.Find(assemblies, a => a.GetName().Name == dependencyName.Name); + if (dependency is not null) + { + Visit(assemblies, dependency, sortedAssemblies, visited); + } + } + + sortedAssemblies.Add(assembly); + } + + return sortedAssemblies; + } +} diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 1968f7d21aa3..49c209a2f90b 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -8,12 +8,9 @@ internal sealed class StartupHook { - private static readonly bool s_logDeltaClientMessages = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.HotReloadDeltaClientLogMessages) == "1"; + 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 string s_targetProcessPath = Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotnetWatchHotReloadTargetProcessPath); -#if DEBUG - private static readonly string s_logFile = Path.Combine(Path.GetTempPath(), $"HotReload_{s_namedPipeName}.log"); -#endif /// /// Invoked by the runtime when the containing assembly is listed in DOTNET_STARTUP_HOOKS. @@ -36,22 +33,37 @@ public static void Initialize() Log($"Loaded into process: {processPath}"); -#if DEBUG - Log($"Log path: {s_logFile}"); -#endif ClearHotReloadEnvironmentVariables(); - Task.Run(async () => + _ = Task.Run(async () => { - using var hotReloadAgent = new HotReloadAgent(Log); + Log($"Connecting to hot-reload server"); + + const int TimeOutMS = 5000; + + using var pipeClient = new NamedPipeClientStream(".", s_namedPipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); try { - await ReceiveDeltas(hotReloadAgent); + await pipeClient.ConnectAsync(TimeOutMS); + Log("Connected."); + } + catch (TimeoutException) + { + Log($"Failed to connect in {TimeOutMS}ms."); + return; + } + + using var agent = new HotReloadAgent(pipeClient, Log); + try + { + await agent.ReceiveDeltasAsync(); } catch (Exception ex) { Log(ex.Message); } + + Log("Stopped received delta updates. Server is no longer connected."); }); } @@ -81,50 +93,13 @@ internal static string RemoveCurrentAssembly(string environment) return string.Join(Path.PathSeparator, updatedValues); } - public static async Task ReceiveDeltas(HotReloadAgent hotReloadAgent) - { - Log($"Connecting to hot-reload server"); - - const int TimeOutMS = 5000; - - 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; - } - - var initPayload = new ClientInitializationPayload(hotReloadAgent.Capabilities); - Log("Writing capabilities: " + initPayload.Capabilities); - initPayload.Write(pipeClient); - - while (pipeClient.IsConnected) - { - var update = await UpdatePayload.ReadAsync(pipeClient, default); - Log("Attempting to apply deltas."); - - hotReloadAgent.ApplyDeltas(update.Deltas); - pipeClient.WriteByte(UpdatePayload.ApplySuccessValue); - } - - Log("Stopped received delta updates. Server is no longer connected."); - } - private static void Log(string message) { - if (s_logDeltaClientMessages) + if (s_logToStandardOutput) { Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine($"dotnet watch 🕵️ [{s_namedPipeName}] {message}"); Console.ResetColor(); -#if DEBUG - File.AppendAllText(s_logFile, message + Environment.NewLine); -#endif } } } diff --git a/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs b/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs index fd4df470eba7..f2fb13a8c123 100644 --- a/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs +++ b/src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher { @@ -11,6 +12,11 @@ internal enum TestFlags None = 0, RunningAsTest = 1 << 0, MockBrowser = 1 << 1, + + /// + /// Elevates the severity of from . + /// + ElevateWaitingForChangesMessageSeverity = 1 << 2, } internal sealed record EnvironmentOptions( diff --git a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs index 79f87d80e338..ca0fe1297410 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs @@ -83,11 +83,13 @@ public override async Task Apply(ImmutableArray new UpdateDelta( - update.ModuleId, - metadataDelta: update.MetadataDelta.ToArray(), - ilDelta: update.ILDelta.ToArray(), - update.UpdatedTypes.ToArray())).ToArray()); + var payload = new UpdatePayload( + deltas: applicableUpdates.Select(update => new UpdateDelta( + update.ModuleId, + metadataDelta: update.MetadataDelta.ToArray(), + ilDelta: update.ILDelta.ToArray(), + update.UpdatedTypes.ToArray())).ToArray(), + responseLoggingLevel: Reporter.IsVerbose ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors); var success = false; var canceled = false; @@ -131,21 +133,43 @@ private async Task ReceiveApplyUpdateResult(CancellationToken cancellation { Debug.Assert(_pipe != null); - var bytes = ArrayPool.Shared.Rent(1); + var status = ArrayPool.Shared.Rent(1); try { - var numBytes = await _pipe.ReadAsync(bytes, cancellationToken); - if (numBytes != 1 || bytes[0] != UpdatePayload.ApplySuccessValue) + var statusBytesRead = await _pipe.ReadAsync(status, cancellationToken); + if (statusBytesRead != 1 || status[0] != UpdatePayload.ApplySuccessValue) { - Reporter.Error($"Change failed to apply (error code: '{BitConverter.ToString(bytes, 0, numBytes)}'). Further changes won't be applied to this process."); + Reporter.Error($"Change failed to apply (error code: '{BitConverter.ToString(status, 0, statusBytesRead)}'). Further changes won't be applied to this process."); return false; } + foreach (var (message, severity) in UpdatePayload.ReadLog(_pipe)) + { + switch (severity) + { + case AgentMessageSeverity.Verbose: + Reporter.Verbose(message, emoji: "🕵️"); + break; + + case AgentMessageSeverity.Error: + Reporter.Error(message); + break; + + case AgentMessageSeverity.Warning: + Reporter.Warn(message, emoji: "⚠"); + break; + + default: + Reporter.Error($"Unexpected message severity: {severity}"); + return false; + } + } + return true; } finally { - ArrayPool.Shared.Return(bytes); + ArrayPool.Shared.Return(status); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/NamedPipeContract.cs b/src/BuiltInTools/dotnet-watch/HotReload/NamedPipeContract.cs index 20c069e81a91..d5ce198f6518 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/NamedPipeContract.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/NamedPipeContract.cs @@ -3,18 +3,27 @@ namespace Microsoft.Extensions.HotReload { - internal readonly struct UpdatePayload + internal enum ResponseLoggingLevel : byte { - public const byte ApplySuccessValue = 0; + WarningsAndErrors = 0, + Verbose = 1, + } - private static readonly byte Version = 1; + internal enum AgentMessageSeverity : byte + { + Verbose = 0, + Warning = 1, + Error = 2, + } - public IReadOnlyList Deltas { get; } + internal readonly struct UpdatePayload(IReadOnlyList deltas, ResponseLoggingLevel responseLoggingLevel) + { + public const byte ApplySuccessValue = 0; - public UpdatePayload(IReadOnlyList deltas) - { - Deltas = deltas; - } + private const byte Version = 2; + + public IReadOnlyList Deltas { get; } = deltas; + public ResponseLoggingLevel ResponseLoggingLevel { get; } = responseLoggingLevel; /// /// Called by the dotnet-watch. @@ -34,6 +43,8 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT WriteIntArray(binaryWriter, delta.UpdatedTypes); } + binaryWriter.Write((byte)ResponseLoggingLevel); + static ValueTask WriteBytesAsync(BinaryWriter binaryWriter, byte[] bytes, CancellationToken cancellationToken) { binaryWriter.Write(bytes.Length); @@ -57,6 +68,22 @@ static void WriteIntArray(BinaryWriter binaryWriter, int[] values) } } + /// + /// Called by the dotnet-watch. + /// + public static void WriteLog(Stream stream, IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log) + { + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + + writer.Write(log.Count); + + foreach (var (message, severity) in log) + { + writer.Write(message); + writer.Write((byte)severity); + } + } + /// /// Called by delta applier. /// @@ -82,7 +109,9 @@ public static async ValueTask ReadAsync(Stream stream, Cancellati deltas[i] = new UpdateDelta(moduleId, metadataDelta: metadataDelta, ilDelta: ilDelta, updatedTypes); } - return new UpdatePayload(deltas); + var responseLoggingLevel = (ResponseLoggingLevel)binaryReader.ReadByte(); + + return new UpdatePayload(deltas, responseLoggingLevel: responseLoggingLevel); static async ValueTask ReadBytesAsync(BinaryReader binaryReader, CancellationToken cancellationToken) { @@ -117,6 +146,21 @@ static int[] ReadIntArray(BinaryReader binaryReader) return values; } } + + /// + /// Called by delta applier. + /// + public static IEnumerable<(string message, AgentMessageSeverity severity)> ReadLog(Stream stream) + { + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + + var entryCount = reader.ReadInt32(); + + for (var i = 0; i < entryCount; i++) + { + yield return (reader.ReadString(), (AgentMessageSeverity)reader.ReadByte()); + } + } } internal readonly struct UpdateDelta diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index 24b543ebd9b3..da4f09764958 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -150,7 +150,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke return; } - fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter); + fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter, Context.EnvironmentOptions.TestFlags); // Hot Reload loop - exits when the root process needs to be restarted. while (true) @@ -203,7 +203,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke ReportFileChanges(changedFiles); - fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter); + fileSetWatcher = new HotReloadFileSetWatcher(evaluationResult.Files, buildCompletionTime, Context.Reporter, Context.EnvironmentOptions.TestFlags); } else { @@ -323,7 +323,7 @@ await Task.WhenAll( !shutdownCancellationToken.IsCancellationRequested && !forceRestartCancellationSource.IsCancellationRequested) { - fileSetWatcher ??= new HotReloadFileSetWatcher(evaluationResult.Files, DateTime.MinValue, Context.Reporter); + fileSetWatcher ??= new HotReloadFileSetWatcher(evaluationResult.Files, DateTime.MinValue, Context.Reporter, Context.EnvironmentOptions.TestFlags); Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting); using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); diff --git a/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs index 76692d5381eb..255f7c1474f1 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/HotReloadFileSetWatcher.cs @@ -8,7 +8,7 @@ namespace Microsoft.DotNet.Watcher.Internal { - internal sealed class HotReloadFileSetWatcher(IReadOnlyDictionary fileSet, DateTime buildCompletionTime, IReporter reporter) : IDisposable + internal sealed class HotReloadFileSetWatcher(IReadOnlyDictionary fileSet, DateTime buildCompletionTime, IReporter reporter, TestFlags testFlags) : IDisposable { private static readonly TimeSpan s_debounceInterval = TimeSpan.FromMilliseconds(50); private static readonly DateTime s_fileNotExistFileTime = DateTime.FromFileTime(0); @@ -48,7 +48,13 @@ private void EnsureInitialized() _fileWatcher.StartWatching(); _fileWatcher.OnFileChange += FileChangedCallback; - reporter.Report(MessageDescriptor.WaitingForChanges); + var waitingForChanges = MessageDescriptor.WaitingForChanges; + if (testFlags.HasFlag(TestFlags.ElevateWaitingForChangesMessageSeverity)) + { + waitingForChanges = waitingForChanges with { Severity = MessageSeverity.Output }; + } + + reporter.Report(waitingForChanges); Task.Factory.StartNew(async () => { diff --git a/src/BuiltInTools/dotnet-watch/Internal/MessagePrefixingReporter.cs b/src/BuiltInTools/dotnet-watch/Internal/MessagePrefixingReporter.cs index 8004a79af7d2..5aa93ba59f34 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/MessagePrefixingReporter.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/MessagePrefixingReporter.cs @@ -7,6 +7,9 @@ namespace Microsoft.DotNet.Watcher; internal sealed class MessagePrefixingReporter(string additionalPrefix, IReporter underlyingReporter) : IReporter { + public bool IsVerbose + => underlyingReporter.IsVerbose; + public bool ReportProcessOutput => underlyingReporter.ReportProcessOutput; diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs index 8452276d7dc0..ab1952b2f3e7 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadAgentTest.cs @@ -18,7 +18,7 @@ public void TopologicalSort_Works() var assembly3 = GetAssembly("Microsoft.AspNetCore.Components", new[] { new AssemblyName("System.Private.CoreLib"), }); var assembly4 = GetAssembly("Microsoft.AspNetCore.Components.Web", new[] { new AssemblyName("Microsoft.AspNetCore.Components"), new AssemblyName("System.Text.Json"), }); - var sortedList = HotReloadAgent.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3 }); + var sortedList = MetadataUpdateHandlerInvoker.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3 }); // Assert Assert.Equal(new[] { assembly1, assembly2, assembly3, assembly4 }, sortedList); @@ -33,7 +33,7 @@ public void TopologicalSort_IgnoresUnknownReferencedAssemblies() var assembly3 = GetAssembly("Microsoft.AspNetCore.Components", new[] { new AssemblyName("System.Private.CoreLib"), new AssemblyName("Microsoft.Extensions.DependencyInjection"), }); var assembly4 = GetAssembly("Microsoft.AspNetCore.Components.Web", new[] { new AssemblyName("Microsoft.AspNetCore.Components"), new AssemblyName("System.Text.Json"), }); - var sortedList = HotReloadAgent.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3 }); + var sortedList = MetadataUpdateHandlerInvoker.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3 }); // Assert Assert.Equal(new[] { assembly1, assembly2, assembly3, assembly4 }, sortedList); @@ -49,7 +49,7 @@ public void TopologicalSort_WithCycles() var assembly4 = GetAssembly("Microsoft.AspNetCore.Components", new[] { new AssemblyName("System.Private.CoreLib"), new AssemblyName("Microsoft.Extensions.DependencyInjection"), }); var assembly5 = GetAssembly("Microsoft.AspNetCore.Components.Web", new[] { new AssemblyName("Microsoft.AspNetCore.Components"), new AssemblyName("System.Text.Json"), }); - var sortedList = HotReloadAgent.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3, assembly5 }); + var sortedList = MetadataUpdateHandlerInvoker.TopologicalSort(new[] { assembly2, assembly4, assembly1, assembly3, assembly5 }); // Assert Assert.Equal(new[] { assembly1, assembly3, assembly2, assembly4, assembly5 }, sortedList); @@ -58,14 +58,11 @@ public void TopologicalSort_WithCycles() [Fact] public void GetHandlerActions_DiscoversActionsOnTypeWithClearCache() { - // Arrange - var actions = new HotReloadAgent.UpdateHandlerActions(); - var log = new List(); - var agent = new HotReloadAgent(message => log.Add(message)); - - agent.GetHandlerActions(actions, typeof(HandlerWithClearCache)); + var reporter = new AgentReporter(); + var invoker = new MetadataUpdateHandlerInvoker(reporter); + var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithClearCache)]); - Assert.Empty(log); + Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); Assert.Single(actions.ClearCache); Assert.Empty(actions.UpdateApplication); } @@ -73,14 +70,11 @@ public void GetHandlerActions_DiscoversActionsOnTypeWithClearCache() [Fact] public void GetHandlerActions_DiscoversActionsOnTypeWithUpdateApplication() { - // Arrange - var actions = new HotReloadAgent.UpdateHandlerActions(); - var log = new List(); - var agent = new HotReloadAgent(message => log.Add(message)); + var reporter = new AgentReporter(); + var invoker = new MetadataUpdateHandlerInvoker(reporter); + var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithUpdateApplication)]); - agent.GetHandlerActions(actions, typeof(HandlerWithUpdateApplication)); - - Assert.Empty(log); + Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); Assert.Empty(actions.ClearCache); Assert.Single(actions.UpdateApplication); } @@ -88,14 +82,11 @@ public void GetHandlerActions_DiscoversActionsOnTypeWithUpdateApplication() [Fact] public void GetHandlerActions_DiscoversActionsOnTypeWithBothActions() { - // Arrange - var actions = new HotReloadAgent.UpdateHandlerActions(); - var log = new List(); - var agent = new HotReloadAgent(message => log.Add(message)); - - agent.GetHandlerActions(actions, typeof(HandlerWithBothActions)); + var reporter = new AgentReporter(); + var invoker = new MetadataUpdateHandlerInvoker(reporter); + var actions = invoker.GetMetadataUpdateHandlerActions([typeof(HandlerWithBothActions)]); - Assert.Empty(log); + Assert.Empty(reporter.GetAndClearLogEntries(ResponseLoggingLevel.Verbose)); Assert.Single(actions.ClearCache); Assert.Single(actions.UpdateApplication); } @@ -103,16 +94,16 @@ public void GetHandlerActions_DiscoversActionsOnTypeWithBothActions() [Fact] public void GetHandlerActions_LogsMessageIfMethodHasIncorrectSignature() { - // Arrange - var actions = new HotReloadAgent.UpdateHandlerActions(); - var log = new List(); - var agent = new HotReloadAgent(message => log.Add(message)); - var handlerType = typeof(HandlerWithIncorrectSignature); + var reporter = new AgentReporter(); + var invoker = new MetadataUpdateHandlerInvoker(reporter); - agent.GetHandlerActions(actions, handlerType); + var handlerType = typeof(HandlerWithIncorrectSignature); + var actions = invoker.GetMetadataUpdateHandlerActions([handlerType]); - var message = Assert.Single(log); - Assert.Equal($"Type '{handlerType}' has method 'Void ClearCache()' that does not match the required signature.", message); + 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); } @@ -120,17 +111,18 @@ public void GetHandlerActions_LogsMessageIfMethodHasIncorrectSignature() [Fact] public void GetHandlerActions_LogsMessageIfNoActionsAreDiscovered() { - // Arrange - var actions = new HotReloadAgent.UpdateHandlerActions(); - var log = new List(); - var agent = new HotReloadAgent(message => log.Add(message)); + var reporter = new AgentReporter(); + var invoker = new MetadataUpdateHandlerInvoker(reporter); + var handlerType = typeof(HandlerWithNoActions); + var actions = invoker.GetMetadataUpdateHandlerActions([handlerType]); - agent.GetHandlerActions(actions, 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); - var message = Assert.Single(log); - Assert.Equal($"No invokable methods found on metadata handler type '{handlerType}'. " + - $"Allowed methods are ClearCache, UpdateApplication", message); + Assert.Equal(AgentMessageSeverity.Warning, logEntry.severity); Assert.Empty(actions.ClearCache); Assert.Empty(actions.UpdateApplication); } diff --git a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs index c5c0eb5a8b2e..957ac5f7cbae 100644 --- a/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs +++ b/test/TestAssets/TestProjects/WatchHotReloadApp/Program.cs @@ -7,6 +7,9 @@ using System.Reflection; using System.Runtime.Versioning; using System.Threading; +using System.Threading.Tasks; + +// var assembly = typeof(C).Assembly; @@ -21,15 +24,10 @@ Console.WriteLine($"TFM = {assembly.GetCustomAttributes().FirstOrDefault()?.FrameworkName ?? ""}"); Console.WriteLine($"Configuration = {assembly.GetCustomAttributes().FirstOrDefault()?.Configuration ?? ""}"); -Loop(); - -static void Loop() +while (true) { - while (true) - { - Console.WriteLine("."); - Thread.Sleep(1000); - } + Console.WriteLine("."); + await Task.Delay(1000); } class C { } diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index e3e3fef94f89..745949d61e2f 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -98,6 +98,89 @@ public static void Print() await App.AssertOutputLineStartsWith("Updated types: Printer"); } + [Fact] + public async Task MetadataUpdateHandler_NoActions() + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var sourcePath = Path.Combine(testAsset.Path, "Program.cs"); + + var source = File.ReadAllText(sourcePath, Encoding.UTF8) + .Replace("// ", """ + [assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(AppUpdateHandler))] + """) + + """ + class AppUpdateHandler + { + } + """; + + File.WriteAllText(sourcePath, source, Encoding.UTF8); + + App.Start(testAsset, []); + + await App.AssertWaitingForChanges(); + + UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"Updated\");")); + + await App.AssertOutputLineStartsWith("dotnet watch ⚠ [WatchHotReloadApp (net9.0)] 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."); + + await App.AssertOutputLineStartsWith("Updated"); + } + + [Theory] + [CombinatorialData] + public async Task MetadataUpdateHandler_Exception(bool verbose) + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp", identifier: verbose.ToString()) + .WithSource(); + + var sourcePath = Path.Combine(testAsset.Path, "Program.cs"); + + var source = File.ReadAllText(sourcePath, Encoding.UTF8) + .Replace("// ", """ + [assembly: System.Reflection.Metadata.MetadataUpdateHandler(typeof(AppUpdateHandler))] + """) + + """ + class AppUpdateHandler + { + public static void ClearCache(Type[] types) => throw new System.InvalidOperationException("Bug!"); + } + """; + + File.WriteAllText(sourcePath, source, Encoding.UTF8); + + if (!verbose) + { + // remove default --verbose arg + App.DotnetWatchArgs.Clear(); + } + + App.Start(testAsset, [], testFlags: TestFlags.ElevateWaitingForChangesMessageSeverity); + + await App.AssertWaitingForChanges(); + + UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"Updated\");")); + + + await App.AssertOutputLineStartsWith("dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!"); + + if (verbose) + { + await App.AssertOutputLineStartsWith("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied."); + } + else + { + // shouldn't see any agent messages: + await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, failure: line => line.Contains("🕵️")); + } + + await App.AssertOutputLineStartsWith(" at AppUpdateHandler.ClearCache(Type[] types)"); + + await App.AssertOutputLineStartsWith("Updated"); + } + [Fact] public async Task BlazorWasm() { diff --git a/test/dotnet-watch.Tests/HotReload/UpdatePayloadTests.cs b/test/dotnet-watch.Tests/HotReload/UpdatePayloadTests.cs index 513f1cd998a1..fa4f9060173d 100644 --- a/test/dotnet-watch.Tests/HotReload/UpdatePayloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/UpdatePayloadTests.cs @@ -23,7 +23,8 @@ public async Task UpdatePayload_CanRoundTrip() ilDelta: new byte[] { 1, 0, 0 }, metadataDelta: new byte[] { 1, 0, 1 }, updatedTypes: Array.Empty()) - }); + }, + responseLoggingLevel: ResponseLoggingLevel.Verbose); using var stream = new MemoryStream(); await initial.WriteAsync(stream, default); @@ -50,7 +51,8 @@ public async Task UpdatePayload_CanRoundTripUpdatedTypes() ilDelta: new byte[] { 1, 0, 0 }, metadataDelta: new byte[] { 1, 0, 1 }, updatedTypes: new int[] { -18 }) - }); + }, + responseLoggingLevel: ResponseLoggingLevel.WarningsAndErrors); using var stream = new MemoryStream(); await initial.WriteAsync(stream, default); @@ -72,7 +74,8 @@ public async Task UpdatePayload_WithLargeDeltas_CanRoundtrip() ilDelta: Enumerable.Range(0, 68200).Select(c => (byte)(c%2)).ToArray(), metadataDelta: new byte[] { 0, 1, 1 }, updatedTypes: Array.Empty()) - }); + }, + responseLoggingLevel: ResponseLoggingLevel.Verbose); using var stream = new MemoryStream(); await initial.WriteAsync(stream, default); @@ -104,6 +107,8 @@ private static void AssertEqual(UpdatePayload initial, UpdatePayload read) Assert.Equal(e.UpdatedTypes, a.UpdatedTypes); } } + + Assert.Equal(initial.ResponseLoggingLevel, read.ResponseLoggingLevel); } } } diff --git a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs index ea89c17f57bd..b79829dfa6ed 100644 --- a/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs +++ b/test/dotnet-watch.Tests/Watch/Utilities/WatchableApp.cs @@ -37,8 +37,8 @@ public WatchableApp(ITestOutputHelper logger) public static string GetLinePrefix(MessageDescriptor descriptor) => $"dotnet watch {descriptor.Emoji} {descriptor.Format}"; - public Task AssertOutputLineStartsWith(MessageDescriptor descriptor) - => AssertOutputLineStartsWith(GetLinePrefix(descriptor)); + public Task AssertOutputLineStartsWith(MessageDescriptor descriptor, Predicate failure = null) + => AssertOutputLineStartsWith(GetLinePrefix(descriptor), failure); /// /// Asserts that the watched process outputs a line starting with and returns the remainder of that line. @@ -127,7 +127,7 @@ public void Start(TestAsset asset, IEnumerable arguments, string relativ var encLogPath = Environment.GetEnvironmentVariable("HELIX_WORKITEM_UPLOAD_ROOT") is { } ciOutputRoot ? Path.Combine(ciOutputRoot, ".hotreload", asset.Name) - : Path.Combine(asset.Path, ".hotreload"); + : asset.Path + ".hotreload"; commandSpec.WithEnvironmentVariable("Microsoft_CodeAnalysis_EditAndContinue_LogDir", encLogPath); From 8faedb76d1d3598f188f9bce889cf1904cd14ef1 Mon Sep 17 00:00:00 2001 From: tmat Date: Wed, 25 Sep 2024 11:29:25 -0700 Subject: [PATCH 2/3] Fix --- .../HotReload/ApplyDeltaTests.cs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 745949d61e2f..55afba97478f 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -124,9 +124,11 @@ class AppUpdateHandler UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"Updated\");")); - await App.AssertOutputLineStartsWith("dotnet watch ⚠ [WatchHotReloadApp (net9.0)] 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."); - await App.AssertOutputLineStartsWith("Updated"); + + Assert.Contains( + "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] 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.", + App.Process.Output); } [Theory] @@ -163,22 +165,21 @@ class AppUpdateHandler UpdateSourceFile(sourcePath, source.Replace("Console.WriteLine(\".\");", "Console.WriteLine(\"Updated\");")); + await App.AssertOutputLineStartsWith("Updated"); - await App.AssertOutputLineStartsWith("dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!"); + Assert.Contains( + "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!", + App.Process.Output); if (verbose) { - await App.AssertOutputLineStartsWith("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied."); + Assert.Contains("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied.", App.Process.Output); } else { // shouldn't see any agent messages: - await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, failure: line => line.Contains("🕵️")); + Assert.DoesNotContain("🕵️", App.Process.Output); } - - await App.AssertOutputLineStartsWith(" at AppUpdateHandler.ClearCache(Type[] types)"); - - await App.AssertOutputLineStartsWith("Updated"); } [Fact] From dc483c38df73a5db024ba7c19af7cdef627dbc9f Mon Sep 17 00:00:00 2001 From: tmat Date: Wed, 25 Sep 2024 11:42:35 -0700 Subject: [PATCH 3/3] Fix --- .../HotReload/ApplyDeltaTests.cs | 9 ++++---- test/dotnet-watch.Tests/Utilities/AssertEx.cs | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 55afba97478f..2ad317fad6ba 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -1,6 +1,7 @@ // 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.Watcher.Tools; using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher.Tests @@ -126,7 +127,7 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); - Assert.Contains( + AssertEx.Contains( "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] 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.", App.Process.Output); } @@ -167,18 +168,18 @@ class AppUpdateHandler await App.AssertOutputLineStartsWith("Updated"); - Assert.Contains( + AssertEx.Contains( "dotnet watch ⚠ [WatchHotReloadApp (net9.0)] Exception from 'System.Action`1[System.Type[]]': System.InvalidOperationException: Bug!", App.Process.Output); if (verbose) { - Assert.Contains("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied.", App.Process.Output); + AssertEx.Contains("dotnet watch 🕵️ [WatchHotReloadApp (net9.0)] Deltas applied.", App.Process.Output); } else { // shouldn't see any agent messages: - Assert.DoesNotContain("🕵️", App.Process.Output); + AssertEx.DoesNotContain("🕵️", App.Process.Output); } } diff --git a/test/dotnet-watch.Tests/Utilities/AssertEx.cs b/test/dotnet-watch.Tests/Utilities/AssertEx.cs index d597cd589c3b..cca94b1aae94 100644 --- a/test/dotnet-watch.Tests/Utilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/Utilities/AssertEx.cs @@ -222,5 +222,26 @@ public static void EqualFileList(IEnumerable expectedFiles, IEnumerable< banner: "File sets should be equal"); } } + + public static void Contains(string expected, IEnumerable items) + { + if (items.Any(item => item == expected)) + { + return; + } + + var message = new StringBuilder(); + message.AppendLine($"'{expected}' not found in:"); + + foreach (var item in items) + { + message.AppendLine($"'{item}'"); + } + + Fail(message.ToString()); + } + + public static void DoesNotContain(string expected, IEnumerable items) + => Assert.DoesNotContain(expected, items); } }