From 30b1fb4be374245a799cd627e26e10658f3aa88c Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Wed, 24 Sep 2025 16:50:25 -0700 Subject: [PATCH 1/2] Notify DCP of terminated session when process exits on its own --- .../AspireService/AspireServerService.cs | 36 ++- .../Models/SessionChangeNotification.cs | 9 + .../HotReloadClient/DefaultHotReloadClient.cs | 36 ++- .../HotReloadClient/HotReloadClient.cs | 21 +- .../HotReloadClient/HotReloadClients.cs | 11 + .../Aspire/AspireServiceFactory.cs | 38 ++- .../HotReload/CompilationHandler.cs | 243 +++++++++--------- .../HotReload/HotReloadDotNetWatcher.cs | 29 +-- .../dotnet-watch/Process/ProcessRunner.cs | 14 +- .../dotnet-watch/Process/ProjectLauncher.cs | 8 +- .../dotnet-watch/Process/RunningProject.cs | 59 +++-- .../Properties/launchSettings.json | 2 +- .../WatchAspire.ApiService/Program.cs | 2 +- .../WatchAspire.AppHost/Program.cs | 7 +- .../WatchAspire.AppHost.csproj | 7 +- .../WatchAspire.MigrationService/Program.cs | 10 + .../Properties/launchSettings.json | 12 + .../WatchAspire.MigrationService.csproj | 11 + .../WatchAspire.MigrationService/Worker.cs | 22 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 8 + .../WatchAspire/WatchAspire.Wasm/App.razor | 2 +- .../TestProjects/WatchAspire/WatchAspire.slnx | 8 + .../HotReload/ApplyDeltaTests.cs | 43 +++- .../HotReload/CompilationHandlerTests.cs | 2 +- .../HotReload/RuntimeProcessLauncherTests.cs | 3 +- .../TestUtilities/AssertEx.cs | 13 +- 27 files changed, 433 insertions(+), 231 deletions(-) create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Program.cs create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Properties/launchSettings.json create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/WatchAspire.MigrationService.csproj create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Worker.cs create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.Development.json create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.json create mode 100644 test/TestAssets/TestProjects/WatchAspire/WatchAspire.slnx diff --git a/src/BuiltInTools/AspireService/AspireServerService.cs b/src/BuiltInTools/AspireService/AspireServerService.cs index bf2f8f341b81..064de0ee7a50 100644 --- a/src/BuiltInTools/AspireService/AspireServerService.cs +++ b/src/BuiltInTools/AspireService/AspireServerService.cs @@ -123,6 +123,7 @@ public List> GetServerConnectionEnvironment() new(DebugSessionServerCertEnvVar, _certificateEncodedBytes), ]; + /// public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelationToken) => SendNotificationAsync( new SessionTerminatedNotification() @@ -136,6 +137,7 @@ public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int pro sessionId, cancelationToken); + /// public ValueTask NotifySessionStartedAsync(string dcpId, string sessionId, int processId, CancellationToken cancelationToken) => SendNotificationAsync( new ProcessRestartedNotification() @@ -148,6 +150,7 @@ public ValueTask NotifySessionStartedAsync(string dcpId, string sessionId, int p sessionId, cancelationToken); + /// public ValueTask NotifyLogMessageAsync(string dcpId, string sessionId, bool isStdErr, string data, CancellationToken cancelationToken) => SendNotificationAsync( new ServiceLogsNotification() @@ -161,23 +164,28 @@ public ValueTask NotifyLogMessageAsync(string dcpId, string sessionId, bool isSt sessionId, cancelationToken); - private async ValueTask SendNotificationAsync(TNotification notification, string dcpId, string sessionId, CancellationToken cancelationToken) + /// + private async ValueTask SendNotificationAsync(TNotification notification, string dcpId, string sessionId, CancellationToken cancellationToken) where TNotification : SessionNotification { try { - Log($"[#{sessionId}] Sending '{notification.NotificationType}'"); + Log($"[#{sessionId}] Sending '{notification.NotificationType}': {notification}"); var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions); - await SendMessageAsync(dcpId, jsonSerialized, cancelationToken); - } - catch (Exception e) when (e is not OperationCanceledException && LogAndPropagate(e)) - { - } + var success = await SendMessageAsync(dcpId, jsonSerialized, cancellationToken); - bool LogAndPropagate(Exception e) + if (!success) + { + cancellationToken.ThrowIfCancellationRequested(); + Log($"[#{sessionId}] Failed to send message: Connection not found (dcpId='{dcpId}')."); + } + } + catch (Exception e) when (e is not OperationCanceledException) { - Log($"[#{sessionId}] Sending '{notification.NotificationType}' failed: {e.Message}"); - return false; + if (!cancellationToken.IsCancellationRequested) + { + Log($"[#{sessionId}] Failed to send message: {e.Message}"); + } } } @@ -373,15 +381,13 @@ private async Task WriteResponseTextAsync(HttpResponse response, Exception ex, b } } - private async Task SendMessageAsync(string dcpId, byte[] messageBytes, CancellationToken cancellationToken) + private async ValueTask SendMessageAsync(string dcpId, byte[] messageBytes, CancellationToken cancellationToken) { // Find the connection for the passed in dcpId WebSocketConnection? connection = _socketConnectionManager.GetSocketConnection(dcpId); if (connection is null) { - // Most likely the connection has already gone away - Log($"Send message failure: Connection with the following dcpId was not found {dcpId}"); - return; + return false; } var success = false; @@ -405,6 +411,8 @@ private async Task SendMessageAsync(string dcpId, byte[] messageBytes, Cancellat _webSocketAccess.Release(); } + + return success; } private async ValueTask HandleStopSessionRequestAsync(HttpContext context, string sessionId) diff --git a/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs b/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs index 579fe6b4a88f..d4213593cf5a 100644 --- a/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs +++ b/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs @@ -56,6 +56,9 @@ internal sealed class SessionTerminatedNotification : SessionNotification [Required] [JsonPropertyName("exit_code")] public required int? ExitCode { get; init; } + + public override string ToString() + => $"pid={Pid}, exit_code={ExitCode}"; } /// @@ -70,6 +73,9 @@ internal sealed class ProcessRestartedNotification : SessionNotification [Required] [JsonPropertyName("pid")] public required int PID { get; init; } + + public override string ToString() + => $"pid={PID}"; } /// @@ -91,4 +97,7 @@ internal sealed class ServiceLogsNotification : SessionNotification [Required] [JsonPropertyName("log_message")] public required string LogMessage { get; init; } + + public override string ToString() + => $"log_message='{LogMessage}', is_std_err={IsStdErr}"; } diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 7587b7623117..1063ba1635f7 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -35,9 +35,14 @@ public override void Dispose() private void DisposePipe() { - Logger.LogDebug("Disposing agent communication pipe"); - _pipe?.Dispose(); - _pipe = null; + if (_pipe != null) + { + Logger.LogDebug("Disposing agent communication pipe"); + + // Dispose the pipe but do not set it to null, so that any in-progress + // operations throw the appropriate exception type. + _pipe.Dispose(); + } } // for testing @@ -101,8 +106,7 @@ private void RequireReadyForUpdates() // should only be called after connection has been created: _ = GetCapabilitiesTask(); - if (_pipe == null) - throw new InvalidOperationException("Pipe has been disposed."); + Debug.Assert(_pipe != null); } public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) @@ -152,7 +156,13 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr { if (!success) { - Logger.LogWarning("Further changes won't be applied to this process."); + // Don't report a warning when cancelled. 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 (!cancellationToken.IsCancellationRequested) + { + Logger.LogWarning("Further changes won't be applied to this process."); + } + _managedCodeUpdateFailedOrCancelled = true; DisposePipe(); } @@ -216,7 +226,7 @@ public async override Task ApplyStaticAssetUpdatesAsync(ImmutableAr private ValueTask SendAndReceiveUpdateAsync(TRequest request, bool isProcessSuspended, CancellationToken cancellationToken) where TRequest : IUpdateRequest { - // Should not be disposed: + // Should not initialized: Debug.Assert(_pipe != null); return SendAndReceiveUpdateAsync( @@ -241,8 +251,10 @@ async ValueTask SendAndReceiveAsync(int batchId, CancellationToken cancell Logger.LogDebug("Update batch #{UpdateId} failed.", batchId); } - catch (Exception e) when (e is not OperationCanceledException || isProcessSuspended) + catch (Exception e) { + // Don't report an error when cancelled. 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 (cancellationToken.IsCancellationRequested) { Logger.LogDebug("Update batch #{UpdateId} canceled.", batchId); @@ -267,7 +279,7 @@ async ValueTask WriteRequestAsync(CancellationToken cancellationToken) private async ValueTask ReceiveUpdateResponseAsync(CancellationToken cancellationToken) { - // Should not be disposed: + // Should be initialized: Debug.Assert(_pipe != null); var (success, log) = await UpdateResponse.ReadAsync(_pipe, cancellationToken); @@ -296,10 +308,12 @@ public override async Task InitialUpdatesAppliedAsync(CancellationToken cancella } catch (Exception e) when (e is not OperationCanceledException) { - // pipe might throw another exception when forcibly closed on process termination: + // Pipe might throw another exception when forcibly closed on process termination. + // Don't report an error when cancelled. 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 (!cancellationToken.IsCancellationRequested) { - Logger.LogError("Failed to send InitialUpdatesCompleted: {Message}", e.Message); + Logger.LogError("Failed to send {RequestType}: {Message}", nameof(RequestType.InitialUpdatesCompleted), e.Message); } } } diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs index fe14176a8b6e..e5efa4db2a7b 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs @@ -41,23 +41,42 @@ internal Task PendingUpdates /// /// Initiates connection with the agent in the target process. /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract void InitiateConnection(CancellationToken cancellationToken); /// /// Waits until the connection with the agent is established. /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken); + /// + /// Returns update capabilities of the target process. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken); + /// + /// Applies managed code updates to the target process. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken); + + /// + /// Applies static asset updates to the target process. + /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract Task ApplyStaticAssetUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken); /// /// Notifies the agent that the initial set of updates has been applied and the user code in the process can start executing. /// + /// Cancellation token. The cancellation should trigger on process terminatation. public abstract Task InitialUpdatesAppliedAsync(CancellationToken cancellationToken); + /// + /// Disposes the client. Can occur unexpectedly whenever the process exits. + /// public abstract void Dispose(); public static void ReportLogEntry(ILogger logger, string message, AgentMessageSeverity severity) @@ -72,7 +91,7 @@ public static void ReportLogEntry(ILogger logger, string message, AgentMessageSe logger.Log(level, message); } - public async Task> FilterApplicableUpdatesAsync(ImmutableArray updates, CancellationToken cancellationToken) + protected async Task> FilterApplicableUpdatesAsync(ImmutableArray updates, CancellationToken cancellationToken) { var availableCapabilities = await GetUpdateCapabilitiesAsync(cancellationToken); var applicableUpdates = new List(); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index bef9b5244cff..58676fdcf5d7 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -23,6 +23,9 @@ public HotReloadClients(HotReloadClient client, AbstractBrowserRefreshServer? br { } + /// + /// Disposes all clients. Can occur unexpectedly whenever the process exits. + /// public void Dispose() { foreach (var (client, _) in clients) @@ -56,6 +59,7 @@ internal void ConfigureLaunchEnvironment(IDictionary environment browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: true); } + /// Cancellation token. The cancellation should trigger on process terminatation. internal void InitiateConnection(CancellationToken cancellationToken) { foreach (var (client, _) in clients) @@ -64,11 +68,13 @@ internal void InitiateConnection(CancellationToken cancellationToken) } } + /// Cancellation token. The cancellation should trigger on process terminatation. internal async ValueTask WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) { await Task.WhenAll(clients.Select(c => c.client.WaitForConnectionEstablishedAsync(cancellationToken))); } + /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) { if (clients is [var (singleClient, _)]) @@ -83,6 +89,7 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel return [.. results.SelectMany(r => r).Distinct(StringComparer.Ordinal).OrderBy(c => c)]; } + /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) { var anyFailure = false; @@ -139,6 +146,7 @@ public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArrayCancellation token. The cancellation should trigger on process terminatation. public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) { if (clients is [var (singleClient, _)]) @@ -151,6 +159,7 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation } } + /// Cancellation token. The cancellation should trigger on process terminatation. public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject)> assets, CancellationToken cancellationToken) { if (browserRefreshServer != null) @@ -190,6 +199,7 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, str } } + /// Cancellation token. The cancellation should trigger on process terminatation. public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) { if (clients is [var (singleClient, _)]) @@ -202,6 +212,7 @@ public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArrayCancellation token. The cancellation should trigger on process terminatation. public ValueTask ReportCompilationErrorsInApplicationAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) => browserRefreshServer?.ReportCompilationErrorsInBrowserAsync(compilationErrors, cancellationToken) ?? ValueTask.CompletedTask; } diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs index 1e84fb05a408..3942cb4e2ea7 100644 --- a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs @@ -43,6 +43,7 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r private readonly Dictionary _sessions = []; private int _sessionIdDispenser; + private volatile bool _isDisposed; public SessionManager(ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) @@ -82,10 +83,7 @@ public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancell _sessions.Clear(); } - foreach (var session in sessions) - { - await TerminateSessionAsync(session, cancellationToken); - } + await Task.WhenAll(sessions.Select(TerminateSessionAsync)).WaitAsync(cancellationToken); } public IEnumerable<(string name, string value)> GetEnvironmentVariables() @@ -113,7 +111,9 @@ public async ValueTask StartProjectAsync(string dcpId, string se var processTerminationSource = new CancellationTokenSource(); var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); - var runningProject = await _projectLauncher.TryLaunchProcessAsync( + RunningProject? runningProject = null; + + runningProject = await _projectLauncher.TryLaunchProcessAsync( projectOptions, processTerminationSource, onOutput: line => @@ -121,6 +121,21 @@ public async ValueTask StartProjectAsync(string dcpId, string se var writeResult = outputChannel.Writer.TryWrite(line); Debug.Assert(writeResult); }, + onExit: async (processId, exitCode) => + { + // Project can be null if the process exists while it's being initialized. + if (runningProject?.IsRestarting == false) + { + try + { + await _service.NotifySessionEndedAsync(dcpId, sessionId, processId, exitCode, cancellationToken); + } + catch (OperationCanceledException) + { + // canceled on shutdown, ignore + } + } + }, restartOperation: cancellationToken => StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: true, cancellationToken), cancellationToken); @@ -134,7 +149,7 @@ public async ValueTask StartProjectAsync(string dcpId, string se await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken); // cancel reading output when the process terminates: - var outputReader = StartChannelReader(processTerminationSource.Token); + var outputReader = StartChannelReader(runningProject.ProcessExitedCancellationToken); lock (_guard) { @@ -159,7 +174,7 @@ async Task StartChannelReader(CancellationToken cancellationToken) } catch (Exception e) { - if (e is not OperationCanceledException) + if (!cancellationToken.IsCancellationRequested) { _logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e); } @@ -185,18 +200,15 @@ async ValueTask IAspireServerEvents.StopSessionAsync(string dcpId, string _sessions.Remove(sessionId); } - await TerminateSessionAsync(session, cancellationToken); + await TerminateSessionAsync(session); return true; } - private async ValueTask TerminateSessionAsync(Session session, CancellationToken cancellationToken) + private async Task TerminateSessionAsync(Session session) { _logger.LogDebug("Stop session #{SessionId}", session.Id); - var exitCode = await _projectLauncher.TerminateProcessAsync(session.RunningProject, cancellationToken); - - // Wait until the started notification has been sent so that we don't send out of order notifications: - await _service.NotifySessionEndedAsync(session.DcpId, session.Id, session.RunningProject.ProcessId, exitCode, cancellationToken); + await session.RunningProject.TerminateAsync(isRestarting: false); // process termination should cancel output reader task: await session.OutputReader; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index f99a3fd4c1db..55aea79f0d10 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -15,7 +15,6 @@ namespace Microsoft.DotNet.Watch internal sealed class CompilationHandler : IDisposable { public readonly IncrementalMSBuildWorkspace Workspace; - private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly WatchHotReloadService _hotReloadService; private readonly ProcessRunner _processRunner; @@ -40,9 +39,8 @@ internal sealed class CompilationHandler : IDisposable private bool _isDisposed; - public CompilationHandler(ILoggerFactory loggerFactory, ILogger logger, ProcessRunner processRunner) + public CompilationHandler(ILogger logger, ProcessRunner processRunner) { - _loggerFactory = loggerFactory; _logger = logger; _processRunner = processRunner; Workspace = new IncrementalMSBuildWorkspace(logger); @@ -57,15 +55,8 @@ public void Dispose() public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) { - _logger.LogDebug("Disposing remaining child processes."); - - var projectsToDispose = await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); - - foreach (var project in projectsToDispose) - { - project.Dispose(); - } - + _logger.LogDebug("Terminating remaining child processes."); + await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); Dispose(); } @@ -101,19 +92,35 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) CancellationToken cancellationToken) { var processExitedSource = new CancellationTokenSource(); - var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processExitedSource.Token, cancellationToken); + + // Cancel process communication as soon as process termination is requested, shutdown is requested, or the process exits (whichever comes first). + // If we only cancel after we process exit event handler is triggered the pipe might have already been closed and may fail unexpectedly. + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processTerminationSource.Token, processExitedSource.Token, cancellationToken); + var processCommunicationCancellationToken = processCommunicationCancellationSource.Token; // Dispose these objects on failure: - using var disposables = new Disposables([clients, processExitedSource, processCommunicationCancellationSource]); + using var disposables = new Disposables([clients, processExitedSource]); // It is important to first create the named pipe connection (Hot Reload client is the named pipe server) // and then start the process (named pipe client). Otherwise, the connection would fail. - clients.InitiateConnection(processCommunicationCancellationSource.Token); + clients.InitiateConnection(processCommunicationCancellationToken); + + RunningProject? publishedRunningProject = null; - processSpec.OnExit += (_, _) => + var previousOnExit = processSpec.OnExit; + processSpec.OnExit = async (processId, exitCode) => { - processExitedSource.Cancel(); - return ValueTask.CompletedTask; + // Await the previous action so that we only clean up after all requested "on exit" actions have been completed. + if (previousOnExit != null) + { + await previousOnExit(processId, exitCode); + } + + // Remove the running project if it has been published to _runningProjects (if it hasn't exited during initialization): + if (publishedRunningProject != null && RemoveRunningProject(publishedRunningProject)) + { + publishedRunningProject.Dispose(); + } }; var launchResult = new ProcessLaunchResult(); @@ -124,79 +131,88 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) return null; } - // 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 clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token); - - var runningProject = new RunningProject( - projectNode, - projectOptions, - clients, - runningProcess, - launchResult.ProcessId.Value, - processExitedSource: processExitedSource, - processTerminationSource: processTerminationSource, - restartOperation: restartOperation, - disposables: [processCommunicationCancellationSource], - capabilities); - var projectPath = projectNode.ProjectInstance.FullPath; - // ownership transferred to running project: - disposables.Items.Clear(); - disposables.Items.Add(runningProject); - - var appliedUpdateCount = 0; - while (true) + try { - // Observe updates that need to be applied to the new process - // and apply them before adding it to running processes. - // 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()) + // 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 clients.GetUpdateCapabilitiesAsync(processCommunicationCancellationToken); + + var runningProject = new RunningProject( + projectNode, + projectOptions, + clients, + runningProcess, + launchResult.ProcessId.Value, + processExitedSource: processExitedSource, + processTerminationSource: processTerminationSource, + restartOperation: restartOperation, + capabilities); + + // ownership transferred to running project: + disposables.Items.Clear(); + disposables.Items.Add(runningProject); + + var appliedUpdateCount = 0; + while (true) { - await clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updatesToApply), isProcessSuspended: false, processCommunicationCancellationSource.Token); - } - - appliedUpdateCount += updatesToApply.Length; - - lock (_runningProjectsAndUpdatesGuard) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - // More updates might have come in while we have been applying updates. - // If so, continue updating. - if (_previousUpdates.Count > appliedUpdateCount) + // Observe updates that need to be applied to the new process + // and apply them before adding it to running processes. + // 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()) { - continue; + await clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updatesToApply), isProcessSuspended: false, processCommunicationCancellationToken); } - // Only add the running process after it has been up-to-date. - // This will prevent new updates being applied before we have applied all the previous updates. - if (!_runningProjects.TryGetValue(projectPath, out var projectInstances)) + appliedUpdateCount += updatesToApply.Length; + + lock (_runningProjectsAndUpdatesGuard) { - projectInstances = []; - } + ObjectDisposedException.ThrowIf(_isDisposed, this); - _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject)); + // More updates might have come in while we have been applying updates. + // If so, continue updating. + if (_previousUpdates.Count > appliedUpdateCount) + { + continue; + } - // ownership transferred to _runningProjects - disposables.Items.Clear(); - break; + // Only add the running process after it has been up-to-date. + // This will prevent new updates being applied before we have applied all the previous updates. + if (!_runningProjects.TryGetValue(projectPath, out var projectInstances)) + { + projectInstances = []; + } + + _runningProjects = _runningProjects.SetItem(projectPath, projectInstances.Add(runningProject)); + + // ownership transferred to _runningProjects + publishedRunningProject = runningProject; + disposables.Items.Clear(); + break; + } } - } - // Notifies the agent that it can unblock the execution of the process: - await clients.InitialUpdatesAppliedAsync(cancellationToken); + // Notifies the agent that it can unblock the execution of the process: + await clients.InitialUpdatesAppliedAsync(processCommunicationCancellationToken); - // If non-empty solution is loaded into the workspace (a Hot Reload session is active): - if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + // If non-empty solution is loaded into the workspace (a Hot Reload session is active): + if (Workspace.CurrentSolution is { ProjectIds: not [] } currentSolution) + { + // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. + PrepareCompilations(currentSolution, projectPath, cancellationToken); + } + + return runningProject; + } + catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested) { - // Preparing the compilation is a perf optimization. We can skip it if the session hasn't been started yet. - PrepareCompilations(currentSolution, projectPath, cancellationToken); + // Process exited during initialization. This should not happen since we control the process during this time. + _logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); + return null; } - - return runningProject; } private ImmutableArray GetAggregateCapabilities() @@ -229,7 +245,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C ImmutableArray projectUpdates, ImmutableArray projectsToRebuild, ImmutableArray projectsToRedeploy, - ImmutableArray terminatedProjects)> HandleManagedCodeChangesAsync( + ImmutableArray projectsToRestart)> HandleManagedCodeChangesAsync( bool autoRestart, Func, CancellationToken, Task> restartPrompt, CancellationToken cancellationToken) @@ -284,11 +300,11 @@ private static void PrepareCompilations(Solution solution, string projectPath, C // Terminate all tracked processes that need to be restarted, // except for the root process, which will terminate later on. - var terminatedProjects = updates.ProjectsToRestart.IsEmpty + var projectsToRestart = updates.ProjectsToRestart.IsEmpty ? [] : await TerminateNonRootProcessesAsync(updates.ProjectsToRestart.Select(e => currentSolution.GetProject(e.Key)!.FilePath!), cancellationToken); - return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, terminatedProjects); + return (updates.ProjectUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart); } public async ValueTask ApplyUpdatesAsync(ImmutableArray updates, CancellationToken cancellationToken) @@ -312,10 +328,10 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT { try { - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken); + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken); await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, processCommunicationCancellationSource.Token); } - catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (runningProject.ProcessExitedCancellationToken.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { runningProject.Clients.ClientLogger.Log(MessageDescriptor.HotReloadCanceledProcessExited); } @@ -532,7 +548,8 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList { var (runningProject, assets) = entry; - await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, cancellationToken); + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken); + await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, processCommunicationCancellationSource.Token); }); await Task.WhenAll(tasks).WaitAsync(cancellationToken); @@ -543,76 +560,64 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList - /// Terminates all processes launched for projects with , + /// Terminates all processes launched for non-root projects with , /// or all running non-root project processes if is null. /// /// Removes corresponding entries from . /// /// Does not terminate the root project. /// + /// All processes (including root) to be restarted. internal async ValueTask> TerminateNonRootProcessesAsync( IEnumerable? projectPaths, CancellationToken cancellationToken) { ImmutableArray projectsToRestart = []; - UpdateRunningProjects(runningProjectsByPath => + lock (_runningProjectsAndUpdatesGuard) { - if (projectPaths == null) - { - projectsToRestart = _runningProjects.SelectMany(entry => entry.Value).Where(p => !p.Options.IsRootProject).ToImmutableArray(); - return _runningProjects.Clear(); - } - - projectsToRestart = projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : []).ToImmutableArray(); - return runningProjectsByPath.RemoveRange(projectPaths); - }); + projectsToRestart = projectPaths == null + ? [.. _runningProjects.SelectMany(entry => entry.Value)] + : [.. projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : [])]; + } // 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. - var projectsToTerminate = projectsToRestart.Where(p => !p.Options.IsRootProject); - - // wait for all processes to exit to release their resources, so we can rebuild: - _ = await TerminateRunningProjects(projectsToTerminate, cancellationToken); + // 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); return projectsToRestart; } - /// - /// Terminates process of the given . - /// Removes corresponding entries from . - /// - /// Should not be called with the root project. - /// - /// Exit code of the terminated process. - internal async ValueTask TerminateNonRootProcessAsync(RunningProject project, CancellationToken cancellationToken) + private bool RemoveRunningProject(RunningProject project) { - Debug.Assert(!project.Options.IsRootProject); - var projectPath = project.ProjectNode.ProjectInstance.FullPath; - UpdateRunningProjects(runningProjectsByPath => + return UpdateRunningProjects(runningProjectsByPath => { - if (!runningProjectsByPath.TryGetValue(projectPath, out var runningProjects) || - runningProjects.Remove(project) is var updatedRunningProjects && runningProjects == updatedRunningProjects) + if (!runningProjectsByPath.TryGetValue(projectPath, out var runningInstances)) { - _logger.LogDebug("Ignoring an attempt to terminate process {ProcessId} of project '{Path}' that has no associated running processes.", project.ProcessId, projectPath); return runningProjectsByPath; } + var updatedRunningProjects = runningInstances.Remove(project); return updatedRunningProjects is [] ? runningProjectsByPath.Remove(projectPath) : runningProjectsByPath.SetItem(projectPath, updatedRunningProjects); }); - - // wait for all processes to exit to release their resources: - return (await TerminateRunningProjects([project], cancellationToken)).Single(); } - private void UpdateRunningProjects(Func>, ImmutableDictionary>> updater) + private bool UpdateRunningProjects(Func>, ImmutableDictionary>> updater) { lock (_runningProjectsAndUpdatesGuard) { - _runningProjects = updater(_runningProjects); + var newRunningProjects = updater(_runningProjects); + if (newRunningProjects != _runningProjects) + { + _runningProjects = newRunningProjects; + return true; + } + + return false; } } @@ -624,12 +629,6 @@ public bool TryGetRunningProject(string projectPath, out ImmutableArray> TerminateRunningProjects(IEnumerable projects, CancellationToken cancellationToken) - { - // wait for all tasks to complete: - return await Task.WhenAll(projects.Select(p => p.TerminateAsync().AsTask())).WaitAsync(cancellationToken); - } - private static Task ForEachProjectAsync(ImmutableDictionary> projects, Func action, CancellationToken cancellationToken) => Task.WhenAll(projects.SelectMany(entry => entry.Value).Select(project => action(project, cancellationToken))).WaitAsync(cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 764a371ab309..6f4b8803ed91 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -108,7 +108,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) } var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); - compilationHandler = new CompilationHandler(_context.LoggerFactory, _context.Logger, _context.ProcessRunner); + compilationHandler = new CompilationHandler(_context.Logger, _context.ProcessRunner); var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, _context.BrowserRefreshServerFactory, _context.Options, _context.EnvironmentOptions); var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Logger); @@ -127,6 +127,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) rootProjectOptions, rootProcessTerminationSource, onOutput: null, + onExit: null, restartOperation: new RestartOperation(_ => throw new InvalidOperationException("Root project shouldn't be restarted")), iterationCancellationToken); @@ -138,7 +139,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) } // Cancel iteration as soon as the root process exits, so that we don't spent time loading solution, etc. when the process is already dead. - rootRunningProject.ProcessExitedSource.Token.Register(() => iterationCancellationSource.Cancel()); + rootRunningProject.ProcessExitedCancellationToken.Register(() => iterationCancellationSource.Cancel()); if (shutdownCancellationToken.IsCancellationRequested) { @@ -146,11 +147,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) return; } - try - { - await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken); - } - catch (OperationCanceledException) when (rootRunningProject.ProcessExitedSource.Token.IsCancellationRequested) + if (!await rootRunningProject.WaitForProcessRunningAsync(iterationCancellationToken)) { // Process might have exited while we were trying to communicate with it. // Cancel the iteration, but wait for a file change before starting a new one. @@ -384,19 +381,7 @@ await Task.WhenAll( projectsToRestart.Select(async runningProject => { var newRunningProject = await runningProject.RestartOperation(shutdownCancellationToken); - - try - { - await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); - } - catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested) - { - // Process might have exited while we were trying to communicate with it. - } - finally - { - runningProject.Dispose(); - } + _ = await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); })) .WaitAsync(shutdownCancellationToken); @@ -546,7 +531,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra if (rootRunningProject != null) { - await rootRunningProject.TerminateAsync(); + await rootRunningProject.TerminateAsync(isRestarting: false); } if (runtimeProcessLauncher != null) @@ -554,8 +539,6 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra await runtimeProcessLauncher.DisposeAsync(); } - rootRunningProject?.Dispose(); - if (waitForFileChangeBeforeRestarting && !shutdownCancellationToken.IsCancellationRequested && !forceRestartCancellationSource.IsCancellationRequested) diff --git a/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs index efb4ed6ec6c3..54ae7d130229 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs @@ -16,10 +16,15 @@ private sealed class ProcessState(Process process) : IDisposable public int ProcessId; public bool HasExited; + // True if Ctrl+C was sent to the process on Windows. + public bool SentWindowsCtrlC; + public void Dispose() => Process.Dispose(); } + private const int CtlrCExitCode = unchecked((int)0xC000013A); + // For testing purposes only, lock on access. private static readonly HashSet s_runningApplicationProcesses = []; @@ -106,7 +111,7 @@ public async Task RunAsync(ProcessSpec processSpec, ILogger logger, Process if (processSpec.IsUserApplication) { - if (exitCode == 0) + if (exitCode == 0 || state.SentWindowsCtrlC && exitCode == CtlrCExitCode) { logger.Log(MessageDescriptor.Exited); } @@ -238,7 +243,6 @@ private async ValueTask TerminateProcessAsync(Process process, ProcessSpec proce { var forceOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !processSpec.IsUserApplication; - // Ctrl+C hasn't been sent. TerminateProcess(process, state, logger, forceOnly); if (forceOnly) @@ -358,7 +362,11 @@ private static void TerminateWindowsProcess(Process process, ProcessState state, else { var error = ProcessUtilities.SendWindowsCtrlCEvent(state.ProcessId); - if (error != null) + if (error == null) + { + state.SentWindowsCtrlC = true; + } + else { logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error); } diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 3299710fd18f..475f7e2fe1d5 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -30,6 +30,7 @@ public EnvironmentOptions EnvironmentOptions ProjectOptions projectOptions, CancellationTokenSource processTerminationSource, Action? onOutput, + ProcessExitAction? onExit, RestartOperation restartOperation, CancellationToken cancellationToken) { @@ -66,12 +67,14 @@ public EnvironmentOptions EnvironmentOptions IsUserApplication = true, WorkingDirectory = projectOptions.WorkingDirectory, OnOutput = onOutput, + OnExit = onExit, }; // Stream output lines to the process output reporter. // The reporter synchronizes the output of the process with the logger output, // so that the printed lines don't interleave. - processSpec.OnOutput += line => + // Only send the output to the reporter if no custom output handler was provided (e.g. for Aspire child processes). + processSpec.OnOutput ??= line => { context.ProcessOutputReporter.ReportOutput(context.ProcessOutputReporter.PrefixProcessOutput ? line with { Content = $"[{projectDisplayName}] {line.Content}" } : line); }; @@ -129,7 +132,4 @@ private static IReadOnlyList GetProcessArguments(ProjectOptions projectO arguments.AddRange(projectOptions.CommandArguments); return arguments; } - - public ValueTask TerminateProcessAsync(RunningProject project, CancellationToken cancellationToken) - => compilationHandler.TerminateNonRootProcessAsync(project, cancellationToken); } diff --git a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs index 46ecd2c629a1..c2662ceec1f9 100644 --- a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs +++ b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs @@ -3,8 +3,10 @@ using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { @@ -19,7 +21,6 @@ internal sealed class RunningProject( CancellationTokenSource processExitedSource, CancellationTokenSource processTerminationSource, RestartOperation restartOperation, - IReadOnlyList disposables, ImmutableArray capabilities) : IDisposable { public readonly ProjectGraphNode ProjectNode = projectNode; @@ -31,44 +32,64 @@ internal sealed class RunningProject( public readonly RestartOperation RestartOperation = restartOperation; /// - /// Cancellation source triggered when the process exits. + /// Cancellation token triggered when the process exits. + /// Stores the token to allow callers to use the token even after the source has been disposed. /// - public readonly CancellationTokenSource ProcessExitedSource = processExitedSource; + public CancellationToken ProcessExitedCancellationToken = processExitedSource.Token; /// - /// Cancellation source to use to terminate the process. + /// 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 readonly CancellationTokenSource ProcessTerminationSource = processTerminationSource; + public bool IsRestarting { get; private set; } + + private volatile bool _isDisposed; /// - /// Misc disposable object to dispose when the object is disposed. + /// Disposes the project. Can occur unexpectedly whenever the process exits. + /// Must only be called once per project. /// - private readonly IReadOnlyList _disposables = disposables; - public void Dispose() { - Clients.Dispose(); - ProcessTerminationSource.Dispose(); - ProcessExitedSource.Dispose(); + ObjectDisposedException.ThrowIf(_isDisposed, this); - foreach (var disposable in _disposables) - { - disposable.Dispose(); - } + _isDisposed = true; + processExitedSource.Cancel(); + + Clients.Dispose(); + processTerminationSource.Dispose(); + processExitedSource.Dispose(); } /// /// Waits for the application process to start. /// Ensures that the build has been complete and the build outputs are available. + /// Returns false if the process has exited before the connection was established. /// - public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) + public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellationToken) { - await Clients.WaitForConnectionEstablishedAsync(cancellationToken); + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, ProcessExitedCancellationToken); + + try + { + await Clients.WaitForConnectionEstablishedAsync(processCommunicationCancellationSource.Token); + return true; + } + catch (OperationCanceledException) when (ProcessExitedCancellationToken.IsCancellationRequested) + { + return false; + } } - public async ValueTask TerminateAsync() + public async Task TerminateAsync(bool isRestarting) { - ProcessTerminationSource.Cancel(); + IsRestarting = isRestarting; + + if (!_isDisposed) + { + processTerminationSource.Cancel(); + } + return await RunningProcess; } } diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index b6981fd0ae9d..9e28729eb807 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", - "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", + "workingDirectory": "C:\\bugs\\9756\\aspire-watch-start-issue\\Aspire.AppHost", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs index 29ac848cd132..95a823165073 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.ApiService/Program.cs @@ -37,7 +37,7 @@ app.Run(); -internal record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +internal record WeatherForecast(DateOnly Date, int TemperatureC, string Summary) { public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); } diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs index 4e0bf9d4cee6..7ef5c7a828ee 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/Program.cs @@ -1,8 +1,13 @@ var builder = DistributedApplication.CreateBuilder(args); -var apiService = builder.AddProject("apiservice"); +var migration = builder.AddProject("migrationservice"); + +var apiService = builder + .AddProject("apiservice") + .WaitForCompletion(migration); builder.AddProject("webfrontend") + .WaitForCompletion(migration) .WithExternalHttpEndpoints() .WithReference(apiService) .WaitFor(apiService); diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj index 90483a02df60..3f394f6f83c0 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.AppHost/WatchAspire.AppHost.csproj @@ -9,9 +9,10 @@ - - - + + + + diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Program.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Program.cs new file mode 100644 index 000000000000..0abee487007d --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Program.cs @@ -0,0 +1,10 @@ +using MigrationService; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +host.Run(); diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Properties/launchSettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Properties/launchSettings.json new file mode 100644 index 000000000000..a312e8fb30bf --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "MigrationService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/WatchAspire.MigrationService.csproj b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/WatchAspire.MigrationService.csproj new file mode 100644 index 000000000000..621a330ee1b4 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/WatchAspire.MigrationService.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + + + + + \ No newline at end of file diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Worker.cs b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Worker.cs new file mode 100644 index 000000000000..4792261219db --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/Worker.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; + +namespace MigrationService; + +public class Worker(IHostApplicationLifetime hostApplicationLifetime, ILogger logger) : BackgroundService +{ + private static readonly ActivitySource s_activitySource = new("Migrations"); + + protected override Task ExecuteAsync(CancellationToken cancellationToken) + { + using var activity = s_activitySource.StartActivity( + "Migrating database", + ActivityKind.Client + ); + + logger.LogInformation("Migration complete"); + + hostApplicationLifetime.StopApplication(); + + return Task.CompletedTask; + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.Development.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.Development.json new file mode 100644 index 000000000000..cd7d0bc9100b --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.json b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.MigrationService/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/App.razor b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/App.razor index 13f3043f0c49..eba23da9b5ae 100644 --- a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/App.razor +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.Wasm/App.razor @@ -1,4 +1,4 @@ - + diff --git a/test/TestAssets/TestProjects/WatchAspire/WatchAspire.slnx b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.slnx new file mode 100644 index 000000000000..d9a238e555dc --- /dev/null +++ b/test/TestAssets/TestProjects/WatchAspire/WatchAspire.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 4068b5642871..104438d08082 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -1168,8 +1168,13 @@ public async Task Aspire_BuildError_ManualRestart() // check that Aspire server output is logged via dotnet-watch reporter: await App.WaitUntilOutputContains("dotnet watch ⭐ Now listening on:"); - // wait until after DCP session started: - await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #1"); + // wait until after all DCP sessions have started: + await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #3"); + App.AssertOutputContains("dotnet watch ⭐ Session started: #1"); + App.AssertOutputContains("dotnet watch ⭐ Session started: #2"); + + // MigrationService terminated: + App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'"); // working directory of the service should be it's project directory: await App.WaitUntilOutputContains($"ApiService working directory: '{Path.GetDirectoryName(serviceProjectPath)}'"); @@ -1209,9 +1214,7 @@ public async Task Aspire_BuildError_ManualRestart() App.AssertOutputContains("Application is shutting down..."); - // We don't have means to gracefully terminate process on Windows, see https://github.com/dotnet/runtime/issues/109432 App.AssertOutputContains($"[WatchAspire.ApiService ({tfm})] Exited"); - App.AssertOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.ApiService \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0")); App.AssertOutputContains(MessageDescriptor.Building.GetMessage(serviceProjectPath)); App.AssertOutputContains("error CS0246: The type or namespace name 'WeatherForecast' could not be found"); @@ -1222,11 +1225,12 @@ public async Task Aspire_BuildError_ManualRestart() serviceSourcePath, serviceSource.Replace("WeatherForecast", "WeatherForecast2")); - await App.WaitForOutputLineContaining(MessageDescriptor.Capabilities, $"WatchAspire.ApiService ({tfm})"); + await App.WaitForOutputLineContaining(MessageDescriptor.ProjectsRestarted.GetMessage(1)); App.AssertOutputContains(MessageDescriptor.BuildSucceeded.GetMessage(serviceProjectPath)); App.AssertOutputContains(MessageDescriptor.ProjectsRebuilt); App.AssertOutputContains($"dotnet watch ⭐ Starting project: {serviceProjectPath}"); + App.Process.ClearOutput(); App.SendControlC(); @@ -1234,13 +1238,14 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains($"[WatchAspire.ApiService ({tfm})] Exited"); await App.WaitUntilOutputContains($"[WatchAspire.AppHost ({tfm})] Exited"); - await App.WaitUntilOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.ApiService \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0")); - await App.WaitUntilOutputContains(new Regex(@"dotnet watch ⌚ \[WatchAspire.AppHost \(net.*\)\] Process id [0-9]+ ran for [0-9]+ms and exited with exit code 0")); await App.WaitUntilOutputContains("dotnet watch ⭐ Waiting for server to shutdown ..."); App.AssertOutputContains("dotnet watch ⭐ Stop session #1"); - App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'"); + App.AssertOutputContains("dotnet watch ⭐ Stop session #2"); + App.AssertOutputContains("dotnet watch ⭐ Stop session #3"); + App.AssertOutputContains("dotnet watch ⭐ [#2] Sending 'sessionTerminated'"); + App.AssertOutputContains("dotnet watch ⭐ [#3] Sending 'sessionTerminated'"); } [PlatformSpecificFact(TestPlatforms.Windows)] // https://github.com/dotnet/aspnetcore/issues/63759 @@ -1259,18 +1264,32 @@ public async Task Aspire_NoEffect_AutoRestart() await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForChanges); // wait until after DCP sessions have been started for all projects: - await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #2"); + await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #3"); + + // other services are waiting for completion of MigrationService: + App.AssertOutputContains("dotnet watch ⭐ Session started: #1"); + App.AssertOutputContains(MessageDescriptor.Exited, $"WatchAspire.MigrationService ({tfm})"); + App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'sessionTerminated'"); + + // migration service output should not be printed to dotnet-watch output, it hsould be sent via DCP as a notification: + App.AssertOutputContains("dotnet watch ⭐ [#1] Sending 'serviceLogs': log_message=' Migration complete', is_std_err=False"); + App.AssertOutputDoesNotContain(new Regex("^ +Migration complete")); + App.Process.ClearOutput(); // no-effect edit: UpdateSourceFile(webSourcePath, src => src.Replace("/* top-level placeholder */", "builder.Services.AddRazorComponents();")); await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled); - await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #2"); + await App.WaitUntilOutputContains("dotnet watch ⭐ Session started: #3"); App.AssertOutputContains(MessageDescriptor.ProjectsRestarted.GetMessage(1)); App.AssertOutputDoesNotContain("⚠"); + // The process exited and should not participate in Hot Reload: + App.AssertOutputDoesNotContain($"[WatchAspire.MigrationService ({tfm})]"); + App.AssertOutputDoesNotContain("dotnet watch ⭐ [#1]"); + App.Process.ClearOutput(); // lambda body edit: @@ -1281,6 +1300,10 @@ public async Task Aspire_NoEffect_AutoRestart() App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRebuilt); App.AssertOutputDoesNotContain(MessageDescriptor.ProjectsRestarted); App.AssertOutputDoesNotContain("⚠"); + + // The process exited and should not participate in Hot Reload: + App.AssertOutputDoesNotContain($"[WatchAspire.MigrationService ({tfm})]"); + App.AssertOutputDoesNotContain("dotnet watch ⭐ [#1]"); } } } diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index bedd9d2a7c5c..9ac5030c4eba 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -25,7 +25,7 @@ public async Task ReferenceOutputAssembly_False() var loggerFactory = new LoggerFactory(reporter); var logger = loggerFactory.CreateLogger("Test"); var projectGraph = ProjectGraphUtilities.TryLoadProjectGraph(options.ProjectPath, globalOptions: [], logger, projectGraphRequired: false, CancellationToken.None); - var handler = new CompilationHandler(loggerFactory, logger, processRunner); + var handler = new CompilationHandler(logger, processRunner); await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 76993fbe54e3..499c04e68837 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -78,6 +78,7 @@ private static async Task Launch(string projectPath, TestRuntime projectOptions, new CancellationTokenSource(), onOutput: null, + onExit: null, restartOperation: startOp!, cancellationToken); @@ -525,7 +526,7 @@ public async Task RudeEditInProjectWithoutRunningProcess() // Terminate the process: Log($"Terminating process {runningProject.ProjectNode.GetDisplayName()} ..."); - await w.Service.ProjectLauncher.TerminateProcessAsync(runningProject, CancellationToken.None); + await runningProject.TerminateAsync(isRestarting: false); // 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 efdc4720c243..aeaffc43f286 100644 --- a/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs +++ b/test/dotnet-watch.Tests/TestUtilities/AssertEx.cs @@ -248,7 +248,12 @@ private static void AssertSubstringPresence(string expected, IEnumerable } var message = new StringBuilder(); - message.AppendLine($"Expected output {(expectedPresent ? "not found" : "found")}:"); + + + message.AppendLine(expectedPresent + ? "Expected text found in the output:" + : "Text not expected to be found in the output:"); + message.AppendLine(expected); message.AppendLine(); message.AppendLine("Actual output:"); @@ -275,7 +280,11 @@ private static void AssertPatternPresence(Regex pattern, IEnumerable ite } var message = new StringBuilder(); - message.AppendLine($"Expected pattern {(expectedPresent ? "not found" : "found")} in the output:"); + + message.AppendLine(expectedPresent + ? "Expected pattern found in the output:" + : "Pattern not expected to be found in the output:"); + message.AppendLine(pattern.ToString()); message.AppendLine(); message.AppendLine("Actual output:"); From 0dd4e521263e10338e885c34e07f6b71eef67dfe Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 26 Sep 2025 09:19:23 -0700 Subject: [PATCH 2/2] Fix tests --- .../Build/EvaluationTests.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 5a6786875334..4c1bcbdbb30a 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -236,8 +236,13 @@ public async Task ProjectReferences_OneLevel() var project1 = new TestProject("Project1") { + IsExe = true, TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", ReferencedProjects = { project2 }, + SourceFiles = + { + { "Project1.cs", s_emptyProgram }, + }, }; var testAsset = _testAssets.CreateTestProject(project1); @@ -271,8 +276,13 @@ public async Task TransitiveProjectReferences_TwoLevels() var project1 = new TestProject("Project1") { + IsExe = true, TargetFrameworks = $"{ToolsetInfo.CurrentTargetFramework};net462", ReferencedProjects = { project2 }, + SourceFiles = + { + { "Project1.cs", s_emptyProgram }, + }, }; var testAsset = _testAssets.CreateTestProject(project1); @@ -305,8 +315,13 @@ public async Task SingleTargetRoot_MultiTargetedDependency(bool specifyTargetFra var project1 = new TestProject("Project1") { + IsExe = true, TargetFrameworks = ToolsetInfo.CurrentTargetFramework, ReferencedProjects = { project2 }, + SourceFiles = + { + { "Project1.cs", s_emptyProgram }, + }, }; var testAsset = _testAssets.CreateTestProject(project1, identifier: specifyTargetFramework.ToString()); @@ -479,8 +494,13 @@ public async Task MsbuildOutput() var project1 = new TestProject("Project1") { + IsExe = true, TargetFrameworks = "net462", ReferencedProjects = { project2 }, + SourceFiles = + { + { "Program.cs", s_emptyProgram }, + }, }; var testAsset = _testAssets.CreateTestProject(project1); @@ -495,9 +515,9 @@ public async Task MsbuildOutput() Assert.Null(result); // note: msbuild prints errors to stdout, we match the pattern and report as error: - AssertEx.Equal( + Assert.Contains( $"[Error] {project1Path} : error NU1201: Project Project2 is not compatible with net462 (.NETFramework,Version=v4.6.2). Project Project2 supports: netstandard2.1 (.NETStandard,Version=v2.1)", - _logger.GetAndClearMessages().Single(m => m.Contains("error NU1201"))); + _logger.GetAndClearMessages()); } private readonly struct ExpectedFile(string path, string? staticAssetUrl = null, bool targetsOnly = false, bool graphOnly = false)