diff --git a/src/BuiltInTools/DotNetDeltaApplier/PipeListener.cs b/src/BuiltInTools/DotNetDeltaApplier/PipeListener.cs index d596594e551d..6110f0fa631c 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/PipeListener.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/PipeListener.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 System.Diagnostics; using System.IO.Pipes; using System.Reflection; using System.Runtime.Loader; @@ -9,6 +10,17 @@ namespace Microsoft.DotNet.HotReload; internal sealed class PipeListener(string pipeName, IHotReloadAgent agent, Action log, int connectionTimeoutMS = 5000) { + /// + /// Messages to the client sent after the initial is sent + /// need to be sent while holding this lock in order to synchronize + /// 1) responses to requests received from the client (e.g. ) or + /// 2) notifications sent to the client that may be triggered at arbitrary times (e.g. ). + /// + private readonly SemaphoreSlim _messageToClientLock = new(initialCount: 1); + + // Not-null once initialized: + private NamedPipeClientStream? _pipeClient; + public Task Listen(CancellationToken cancellationToken) { // Connect to the pipe synchronously. @@ -21,23 +33,23 @@ public Task Listen(CancellationToken cancellationToken) log($"Connecting to hot-reload server via pipe {pipeName}"); - var pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); + _pipeClient = new NamedPipeClientStream(serverName: ".", pipeName, PipeDirection.InOut, PipeOptions.CurrentUserOnly | PipeOptions.Asynchronous); try { - pipeClient.Connect(connectionTimeoutMS); + _pipeClient.Connect(connectionTimeoutMS); log("Connected."); } catch (TimeoutException) { log($"Failed to connect in {connectionTimeoutMS}ms."); - pipeClient.Dispose(); + _pipeClient.Dispose(); return Task.CompletedTask; } try { // block execution of the app until initial updates are applied: - InitializeAsync(pipeClient, cancellationToken).GetAwaiter().GetResult(); + InitializeAsync(cancellationToken).GetAwaiter().GetResult(); } catch (Exception e) { @@ -46,7 +58,7 @@ public Task Listen(CancellationToken cancellationToken) log(e.Message); } - pipeClient.Dispose(); + _pipeClient.Dispose(); agent.Dispose(); return Task.CompletedTask; @@ -56,7 +68,7 @@ public Task Listen(CancellationToken cancellationToken) { try { - await ReceiveAndApplyUpdatesAsync(pipeClient, initialUpdates: false, cancellationToken); + await ReceiveAndApplyUpdatesAsync(initialUpdates: false, cancellationToken); } catch (Exception e) when (e is not OperationCanceledException) { @@ -64,40 +76,44 @@ public Task Listen(CancellationToken cancellationToken) } finally { - pipeClient.Dispose(); + _pipeClient.Dispose(); agent.Dispose(); } }, cancellationToken); } - private async Task InitializeAsync(NamedPipeClientStream pipeClient, CancellationToken cancellationToken) + private async Task InitializeAsync(CancellationToken cancellationToken) { + Debug.Assert(_pipeClient != null); + agent.Reporter.Report("Writing capabilities: " + agent.Capabilities, AgentMessageSeverity.Verbose); var initPayload = new ClientInitializationResponse(agent.Capabilities); - await initPayload.WriteAsync(pipeClient, cancellationToken); + await initPayload.WriteAsync(_pipeClient, cancellationToken); // Apply updates made before this process was launched to avoid executing unupdated versions of the affected modules. // We should only receive ManagedCodeUpdate when when the debugger isn't attached, // otherwise the initialization should send InitialUpdatesCompleted immediately. // The debugger itself applies these updates when launching process with the debugger attached. - await ReceiveAndApplyUpdatesAsync(pipeClient, initialUpdates: true, cancellationToken); + await ReceiveAndApplyUpdatesAsync(initialUpdates: true, cancellationToken); } - private async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient, bool initialUpdates, CancellationToken cancellationToken) + private async Task ReceiveAndApplyUpdatesAsync(bool initialUpdates, CancellationToken cancellationToken) { - while (pipeClient.IsConnected) + Debug.Assert(_pipeClient != null); + + while (_pipeClient.IsConnected) { - var payloadType = (RequestType)await pipeClient.ReadByteAsync(cancellationToken); + var payloadType = (RequestType)await _pipeClient.ReadByteAsync(cancellationToken); switch (payloadType) { case RequestType.ManagedCodeUpdate: - await ReadAndApplyManagedCodeUpdateAsync(pipeClient, cancellationToken); + await ReadAndApplyManagedCodeUpdateAsync(cancellationToken); break; case RequestType.StaticAssetUpdate: - await ReadAndApplyStaticAssetUpdateAsync(pipeClient, cancellationToken); + await ReadAndApplyStaticAssetUpdateAsync(cancellationToken); break; case RequestType.InitialUpdatesCompleted when initialUpdates: @@ -110,11 +126,11 @@ private async Task ReceiveAndApplyUpdatesAsync(NamedPipeClientStream pipeClient, } } - private async ValueTask ReadAndApplyManagedCodeUpdateAsync( - NamedPipeClientStream pipeClient, - CancellationToken cancellationToken) + private async ValueTask ReadAndApplyManagedCodeUpdateAsync(CancellationToken cancellationToken) { - var request = await ManagedCodeUpdateRequest.ReadAsync(pipeClient, cancellationToken); + Debug.Assert(_pipeClient != null); + + var request = await ManagedCodeUpdateRequest.ReadAsync(_pipeClient, cancellationToken); bool success; try @@ -131,15 +147,14 @@ private async ValueTask ReadAndApplyManagedCodeUpdateAsync( var logEntries = agent.Reporter.GetAndClearLogEntries(request.ResponseLoggingLevel); - var response = new UpdateResponse(logEntries, success); - await response.WriteAsync(pipeClient, cancellationToken); + await SendResponseAsync(new UpdateResponse(logEntries, success), cancellationToken); } - private async ValueTask ReadAndApplyStaticAssetUpdateAsync( - NamedPipeClientStream pipeClient, - CancellationToken cancellationToken) + private async ValueTask ReadAndApplyStaticAssetUpdateAsync(CancellationToken cancellationToken) { - var request = await StaticAssetUpdateRequest.ReadAsync(pipeClient, cancellationToken); + Debug.Assert(_pipeClient != null); + + var request = await StaticAssetUpdateRequest.ReadAsync(_pipeClient, cancellationToken); try { @@ -155,8 +170,22 @@ private async ValueTask ReadAndApplyStaticAssetUpdateAsync( // 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); + await SendResponseAsync(new UpdateResponse(logEntries, success: true), cancellationToken); + } - await response.WriteAsync(pipeClient, cancellationToken); + internal async ValueTask SendResponseAsync(T response, CancellationToken cancellationToken) + where T : IResponse + { + Debug.Assert(_pipeClient != null); + try + { + await _messageToClientLock.WaitAsync(cancellationToken); + await _pipeClient.WriteAsync((byte)response.Type, cancellationToken); + await response.WriteAsync(_pipeClient, cancellationToken); + } + finally + { + _messageToClientLock.Release(); + } } } diff --git a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs index 28890852482f..03c7b04a4fea 100644 --- a/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs +++ b/src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs @@ -40,14 +40,49 @@ public static void Initialize() RegisterSignalHandlers(); - var agent = new HotReloadAgent(assemblyResolvingHandler: (_, args) => - { - Log($"Resolving '{args.Name}, Version={args.Version}'"); - var path = Path.Combine(processDir, args.Name + ".dll"); - return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; - }); + PipeListener? listener = null; + + var agent = new HotReloadAgent( + assemblyResolvingHandler: (_, args) => + { + Log($"Resolving '{args.Name}, Version={args.Version}'"); + var path = Path.Combine(processDir, args.Name + ".dll"); + return File.Exists(path) ? AssemblyLoadContext.Default.LoadFromAssemblyPath(path) : null; + }, + hotReloadExceptionCreateHandler: (code, message) => + { + // Continue executing the code if the debugger is attached. + // It will throw the exception and the debugger will handle it. + if (Debugger.IsAttached) + { + return; + } + + Debug.Assert(listener != null); + Log($"Runtime rude edit detected: '{message}'"); + + SendAndForgetAsync().Wait(); + + // Handle Ctrl+C to terminate gracefully: + Console.CancelKeyPress += (_, _) => Environment.Exit(0); + + // wait for the process to be terminated by the Hot Reload client (other threads might still execute): + Thread.Sleep(Timeout.Infinite); + + async Task SendAndForgetAsync() + { + try + { + await listener.SendResponseAsync(new HotReloadExceptionCreatedNotification(code, message), CancellationToken.None); + } + catch + { + // do not crash the app + } + } + }); - var listener = new PipeListener(s_namedPipeName, agent, Log); + listener = new PipeListener(s_namedPipeName, agent, Log); // fire and forget: _ = listener.Listen(CancellationToken.None); diff --git a/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs b/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs index 66766e1c74e2..dfa0158c53cd 100644 --- a/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs +++ b/src/BuiltInTools/HotReloadAgent.PipeRpc/NamedPipeContract.cs @@ -12,23 +12,39 @@ namespace Microsoft.DotNet.HotReload; -internal interface IRequest +internal interface IMessage { - RequestType Type { get; } ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken); } +internal interface IRequest : IMessage +{ + RequestType Type { get; } +} + +internal interface IResponse : IMessage +{ + ResponseType Type { get; } +} + internal interface IUpdateRequest : IRequest { } -internal enum RequestType +internal enum RequestType : byte { ManagedCodeUpdate = 1, StaticAssetUpdate = 2, InitialUpdatesCompleted = 3, } +internal enum ResponseType : byte +{ + InitializationResponse = 1, + UpdateResponse = 2, + HotReloadExceptionNotification = 3, +} + internal readonly struct ManagedCodeUpdateRequest(IReadOnlyList updates, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest { private const byte Version = 4; @@ -81,8 +97,10 @@ public static async ValueTask ReadAsync(Stream stream, } } -internal readonly struct UpdateResponse(IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log, bool success) +internal readonly struct UpdateResponse(IReadOnlyCollection<(string message, AgentMessageSeverity severity)> log, bool success) : IResponse { + public ResponseType Type => ResponseType.UpdateResponse; + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) { await stream.WriteAsync(success, cancellationToken); @@ -116,10 +134,12 @@ public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationT } } -internal readonly struct ClientInitializationResponse(string capabilities) +internal readonly struct ClientInitializationResponse(string capabilities) : IResponse { private const byte Version = 0; + public ResponseType Type => ResponseType.InitializationResponse; + public string Capabilities { get; } = capabilities; public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) @@ -141,6 +161,26 @@ public static async ValueTask ReadAsync(Stream str } } +internal readonly struct HotReloadExceptionCreatedNotification(int code, string message) : IResponse +{ + public ResponseType Type => ResponseType.HotReloadExceptionNotification; + public int Code => code; + public string Message => message; + + public async ValueTask WriteAsync(Stream stream, CancellationToken cancellationToken) + { + await stream.WriteAsync(code, cancellationToken); + await stream.WriteAsync(message, cancellationToken); + } + + public static async ValueTask ReadAsync(Stream stream, CancellationToken cancellationToken) + { + var code = await stream.ReadInt32Async(cancellationToken); + var message = await stream.ReadStringAsync(cancellationToken); + return new HotReloadExceptionCreatedNotification(code, message); + } +} + internal readonly struct StaticAssetUpdateRequest( RuntimeStaticAssetUpdate update, ResponseLoggingLevel responseLoggingLevel) : IUpdateRequest diff --git a/src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs b/src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs index 2169ef3dbd9b..1aad8e47f072 100644 --- a/src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs +++ b/src/BuiltInTools/HotReloadAgent.WebAssembly.Browser/WebAssemblyHotReload.cs @@ -71,7 +71,8 @@ public static async Task InitializeAsync(string baseUri) { s_initialized = true; - var agent = new HotReloadAgent(assemblyResolvingHandler: null); + // TODO: Implement hotReloadExceptionCreateHandler: https://github.com/dotnet/sdk/issues/51056 + var agent = new HotReloadAgent(assemblyResolvingHandler: null, hotReloadExceptionCreateHandler: null); var existingAgent = Interlocked.CompareExchange(ref s_hotReloadAgent, agent, null); if (existingAgent != null) diff --git a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs index 4114bed5e6fd..f64180144078 100644 --- a/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs +++ b/src/BuiltInTools/HotReloadAgent/HotReloadAgent.cs @@ -38,11 +38,16 @@ internal sealed class HotReloadAgent : IDisposable, IHotReloadAgent private Func? _assemblyResolvingHandlerToInstall; private Func? _installedAssemblyResolvingHandler; - public HotReloadAgent(Func? assemblyResolvingHandler) + // handler to install to HotReloadException.Created: + private Action? _hotReloadExceptionCreateHandler; + + public HotReloadAgent( + Func? assemblyResolvingHandler, + Action? hotReloadExceptionCreateHandler) { _metadataUpdateHandlerInvoker = new(Reporter); _assemblyResolvingHandlerToInstall = assemblyResolvingHandler; - + _hotReloadExceptionCreateHandler = hotReloadExceptionCreateHandler; GetUpdaterMethodsAndCapabilities(out _applyUpdate, out _capabilities); AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad; @@ -148,11 +153,69 @@ public void ApplyManagedCodeUpdates(IEnumerable update cachedModuleUpdates.Add(update); } - _metadataUpdateHandlerInvoker.MetadataUpdated(GetMetadataUpdateTypes(updates)); + var updatedTypes = GetMetadataUpdateTypes(updates); + + InstallHotReloadExceptionCreatedHandler(updatedTypes); + + _metadataUpdateHandlerInvoker.MetadataUpdated(updatedTypes); Reporter.Report("Updates applied.", AgentMessageSeverity.Verbose); } + private void InstallHotReloadExceptionCreatedHandler(Type[] types) + { + if (_hotReloadExceptionCreateHandler is null) + { + // already installed or not available + return; + } + + var exceptionType = types.FirstOrDefault(static t => t.FullName == "System.Runtime.CompilerServices.HotReloadException"); + if (exceptionType == null) + { + return; + } + + var handler = Interlocked.Exchange(ref _hotReloadExceptionCreateHandler, null); + if (handler == null) + { + // already installed or not available + return; + } + + // HotReloadException has a private static field Action Created, unless emitted by previous versions of the compiler: + // See https://github.com/dotnet/roslyn/blob/06f2643e1268e4a7fcdf1221c052f9c8cce20b60/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedHotReloadExceptionSymbol.cs#L29 + var createdField = exceptionType.GetField("Created", BindingFlags.Static | BindingFlags.NonPublic); + var codeField = exceptionType.GetField("Code", BindingFlags.Public | BindingFlags.Instance); + if (createdField == null || codeField == null) + { + Reporter.Report($"Failed to install HotReloadException handler: not supported by the compiler", AgentMessageSeverity.Verbose); + return; + } + + try + { + createdField.SetValue(null, new Action(e => + { + try + { + handler(codeField.GetValue(e) is int code ? code : 0, e.Message); + } + catch + { + // do not crash the app + } + })); + } + catch (Exception e) + { + Reporter.Report($"Failed to install HotReloadException handler: {e.Message}", AgentMessageSeverity.Verbose); + return; + } + + Reporter.Report($"HotReloadException handler installed.", AgentMessageSeverity.Verbose); + } + private Type[] GetMetadataUpdateTypes(IEnumerable updates) { List? types = null; diff --git a/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs b/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs index 952ba4866def..e841af26513e 100644 --- a/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs +++ b/src/BuiltInTools/HotReloadAgent/MetadataUpdateHandlerInvoker.cs @@ -110,7 +110,7 @@ private RegisteredActions GetActions() } /// - /// Invokes all registered mtadata update handlers. + /// Invokes all registered metadata update handlers. /// internal void MetadataUpdated(Type[] updatedTypes) { diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 1063ba1635f7..c7b325a4c348 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -28,6 +28,9 @@ internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger private NamedPipeServerStream? _pipe; private bool _managedCodeUpdateFailedOrCancelled; + // The status of the last update response. + private TaskCompletionSource _updateStatusSource = new(); + public override void Dispose() { DisposePipe(); @@ -75,24 +78,65 @@ async Task> ConnectAsync() var capabilities = (await ClientInitializationResponse.ReadAsync(_pipe, cancellationToken)).Capabilities; Logger.Log(LogEvents.Capabilities, capabilities); + + // fire and forget: + _ = ListenForResponsesAsync(cancellationToken); + return [.. capabilities.Split(' ')]; } - catch (EndOfStreamException) + catch (Exception e) when (e is not OperationCanceledException) { - // process terminated before capabilities sent: + ReportPipeReadException(e, "capabilities", cancellationToken); return []; } - catch (Exception e) when (e is not OperationCanceledException) + } + } + + private void ReportPipeReadException(Exception e, string responseType, CancellationToken cancellationToken) + { + // Don't report a warning when cancelled or the pipe has been disposed. The process has terminated or the host is shutting down in that case. + // Best effort: There is an inherent race condition due to time between the process exiting and the cancellation token triggering. + if (e is ObjectDisposedException or EndOfStreamException || cancellationToken.IsCancellationRequested) + { + return; + } + + Logger.LogError("Failed to read {ResponseType} from the pipe: {Message}", responseType, e.Message); + } + + private async Task ListenForResponsesAsync(CancellationToken cancellationToken) + { + Debug.Assert(_pipe != null); + + try + { + while (!cancellationToken.IsCancellationRequested) { - // pipe might throw another exception when forcibly closed on process termination: - if (!cancellationToken.IsCancellationRequested) + var type = (ResponseType)await _pipe.ReadByteAsync(cancellationToken); + + switch (type) { - Logger.LogError("Failed to read capabilities: {Message}", e.Message); + case ResponseType.UpdateResponse: + // update request can't be issued again until the status is read and a new source is created: + _updateStatusSource.SetResult(await ReadUpdateResponseAsync(cancellationToken)); + break; + + case ResponseType.HotReloadExceptionNotification: + var notification = await HotReloadExceptionCreatedNotification.ReadAsync(_pipe, cancellationToken); + RuntimeRudeEditDetected(notification.Code, notification.Message); + break; + + default: + // can't continue, the pipe is in undefined state: + Logger.LogError("Unexpected response received from the agent: {ResponseType}", type); + return; } - - return []; } } + catch (Exception e) + { + ReportPipeReadException(e, "response", cancellationToken); + } } [MemberNotNull(nameof(_capabilitiesTask))] @@ -278,6 +322,13 @@ async ValueTask WriteRequestAsync(CancellationToken cancellationToken) } private async ValueTask ReceiveUpdateResponseAsync(CancellationToken cancellationToken) + { + var result = await _updateStatusSource.Task; + _updateStatusSource = new TaskCompletionSource(); + return result; + } + + private async ValueTask ReadUpdateResponseAsync(CancellationToken cancellationToken) { // Should be initialized: Debug.Assert(_pipe != null); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs index e5efa4db2a7b..2a563419e4f2 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs @@ -32,6 +32,11 @@ internal abstract class HotReloadClient(ILogger logger, ILogger agentLogger) : I private readonly object _pendingUpdatesGate = new(); private Task _pendingUpdates = Task.CompletedTask; + /// + /// Invoked when a rude edit is detected at runtime. + /// + public event Action? OnRuntimeRudeEdit; + // for testing internal Task PendingUpdates => _pendingUpdates; @@ -79,6 +84,9 @@ internal Task PendingUpdates /// public abstract void Dispose(); + protected void RuntimeRudeEditDetected(int errorCode, string message) + => OnRuntimeRudeEdit?.Invoke(errorCode, message); + public static void ReportLogEntry(ILogger logger, string message, AgentMessageSeverity severity) { var level = severity switch diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index 58676fdcf5d7..fcf541045fc6 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -37,6 +37,28 @@ public void Dispose() public AbstractBrowserRefreshServer? BrowserRefreshServer => browserRefreshServer; + /// + /// Invoked when a rude edit is detected at runtime. + /// May be invoked multiple times, by each client. + /// + public event Action OnRuntimeRudeEdit + { + add + { + foreach (var (client, _) in clients) + { + client.OnRuntimeRudeEdit += value; + } + } + remove + { + foreach (var (client, _) in clients) + { + client.OnRuntimeRudeEdit -= value; + } + } + } + /// /// All clients share the same loggers. /// @@ -90,7 +112,8 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel } /// Cancellation token. The cancellation should trigger on process terminatation. - public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) + /// True if the updates are initial updates applied automatically when a process starts. + public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, bool isInitial, CancellationToken cancellationToken) { var anyFailure = false; @@ -135,9 +158,13 @@ public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray + { + // fire and forget: + _ = HandleRuntimeRudeEditAsync(runningProject, message, cancellationToken); + }; + // Notifies the agent that it can unblock the execution of the process: await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); @@ -215,6 +221,41 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) } } + private async Task HandleRuntimeRudeEditAsync(RunningProject runningProject, string rudeEditMessage, CancellationToken cancellationToken) + { + var logger = runningProject.Clients.ClientLogger; + + try + { + // Always auto-restart on runtime rude edits regardless of the settings. + // Since there is no debugger attached the process would crash on an unhandled HotReloadException if + // we let it continue executing. + logger.LogWarning(rudeEditMessage); + logger.Log(MessageDescriptor.RestartingApplication); + + if (!runningProject.InitiateRestart()) + { + // Already in the process of restarting, possibly because of another runtime rude edit. + return; + } + + await runningProject.Clients.ReportCompilationErrorsInApplicationAsync([rudeEditMessage, MessageDescriptor.RestartingApplication.GetMessage()], cancellationToken); + + // Terminate the process. + await runningProject.TerminateAsync(); + + // Creates a new running project and launches it: + await runningProject.RestartOperation(cancellationToken); + } + catch (Exception e) + { + if (e is not OperationCanceledException) + { + logger.LogError("Failed to handle runtime rude edit: {Exception}", e.ToString()); + } + } + } + private ImmutableArray GetAggregateCapabilities() { var capabilities = _runningProjects @@ -329,7 +370,7 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT try { using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken); - await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, processCommunicationCancellationSource.Token); + await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, isInitial: false, processCommunicationCancellationSource.Token); } catch (OperationCanceledException) when (runningProject.ProcessExitedCancellationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -583,7 +624,7 @@ internal async ValueTask> TerminateNonRootProcess // Do not terminate root process at this time - it would signal the cancellation token we are currently using. // The process will be restarted later on. // Wait for all processes to exit to release their resources, so we can rebuild. - await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateAsync(isRestarting: true))).WaitAsync(cancellationToken); + await Task.WhenAll(projectsToRestart.Where(p => !p.Options.IsRootProject).Select(p => p.TerminateForRestartAsync())).WaitAsync(cancellationToken); return projectsToRestart; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 6f4b8803ed91..38e313f6f746 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -128,7 +128,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) rootProcessTerminationSource, onOutput: null, onExit: null, - restartOperation: new RestartOperation(_ => throw new InvalidOperationException("Root project shouldn't be restarted")), + restartOperation: new RestartOperation(_ => default), // the process will automatically restart iterationCancellationToken); if (rootRunningProject == null) @@ -531,7 +531,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra if (rootRunningProject != null) { - await rootRunningProject.TerminateAsync(isRestarting: false); + await rootRunningProject.TerminateAsync(); } if (runtimeProcessLauncher != null) @@ -541,7 +541,8 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra if (waitForFileChangeBeforeRestarting && !shutdownCancellationToken.IsCancellationRequested && - !forceRestartCancellationSource.IsCancellationRequested) + !forceRestartCancellationSource.IsCancellationRequested && + rootRunningProject?.IsRestarting != true) { using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token); await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token); diff --git a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs index c2662ceec1f9..8f5bfe94b004 100644 --- a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs +++ b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs @@ -41,8 +41,9 @@ internal sealed class RunningProject( /// Set to true when the process termination is being requested so that it can be restarted within /// the Hot Reload session (i.e. without restarting the root project). /// - public bool IsRestarting { get; private set; } + public bool IsRestarting => _isRestarting != 0; + private volatile int _isRestarting; private volatile bool _isDisposed; /// @@ -81,16 +82,34 @@ public async ValueTask WaitForProcessRunningAsync(CancellationToken cancel } } - public async Task TerminateAsync(bool isRestarting) + /// + /// Terminates the process if it hasn't terminated yet. + /// + public Task TerminateAsync() { - IsRestarting = isRestarting; - if (!_isDisposed) { processTerminationSource.Cancel(); } - return await RunningProcess; + return RunningProcess; + } + + /// + /// Marks the as restarting. + /// Subsequent process termination will be treated as a restart. + /// + /// True if the project hasn't been int restarting state prior the call. + public bool InitiateRestart() + => Interlocked.Exchange(ref _isRestarting, 1) == 0; + + /// + /// Terminates the process in preparation for a restart. + /// + public Task TerminateForRestartAsync() + { + InitiateRestart(); + return TerminateAsync(); } } } diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index 3d60e288a977..319fcc1740b0 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -215,6 +215,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, MessageSeverity.Output); + public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, MessageSeverity.Output); public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, MessageSeverity.Verbose); diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 104438d08082..501ce980b20f 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -440,6 +440,64 @@ public async Task AutoRestartOnRudeEdit(bool nonInteractive) await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadSucceeded); } + [Theory] + [CombinatorialData] + public async Task AutoRestartOnRuntimeRudeEdit(bool nonInteractive) + { + var testAsset = TestAssets.CopyTestAsset("WatchHotReloadApp") + .WithSource(); + + var tfm = ToolsetInfo.CurrentTargetFramework; + var programPath = Path.Combine(testAsset.Path, "Program.cs"); + + // Changes the type of lambda without updating top-level code. + // The loop will end up calling the old version of the lambda resulting in runtime rude edit. + + File.WriteAllText(programPath, """ + using System; + using System.Threading; + + var d = C.F(); + + while (true) + { + Thread.Sleep(250); + d(1); + } + + class C + { + public static Action F() + { + return a => + { + Console.WriteLine(a.GetType()); + }; + } + } + """); + + App.Start(testAsset, nonInteractive ? ["--non-interactive"] : []); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + await App.WaitUntilOutputContains("System.Int32"); + App.Process.ClearOutput(); + + UpdateSourceFile(programPath, src => src.Replace("Action", "Action")); + + await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); + await App.WaitUntilOutputContains("System.Byte"); + + App.AssertOutputContains($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] HotReloadException handler installed."); + App.AssertOutputContains($"dotnet watch 🕵️ [WatchHotReloadApp ({tfm})] Runtime rude edit detected:"); + + App.AssertOutputContains($"dotnet watch ⚠ [WatchHotReloadApp ({tfm})] " + + "Attempted to invoke a deleted lambda or local function implementation. " + + "This can happen when lambda or local function is deleted while the application is running."); + + App.AssertOutputContains(MessageDescriptor.RestartingApplication, $"WatchHotReloadApp ({tfm})"); + } + [Fact] public async Task AutoRestartOnRudeEditAfterRestartPrompt() { diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 499c04e68837..ff2c8491194f 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -526,7 +526,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() // Terminate the process: Log($"Terminating process {runningProject.ProjectNode.GetDisplayName()} ..."); - await runningProject.TerminateAsync(isRestarting: false); + await runningProject.TerminateAsync(); // rude edit in A (changing assembly level attribute): UpdateSourceFile(serviceSourceA2, """ diff --git a/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs b/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs index aeaffc43f286..c80299e56b73 100644 --- a/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs @@ -249,9 +249,8 @@ private static void AssertSubstringPresence(string expected, IEnumerable var message = new StringBuilder(); - message.AppendLine(expectedPresent - ? "Expected text found in the output:" + ? "Expected text not found in the output:" : "Text not expected to be found in the output:"); message.AppendLine(expected);