diff --git a/src/BuiltInTools/AspireService/AspireServerService.cs b/src/BuiltInTools/AspireService/AspireServerService.cs index 9da15a1cf7bf..d3450d65ad18 100644 --- a/src/BuiltInTools/AspireService/AspireServerService.cs +++ b/src/BuiltInTools/AspireService/AspireServerService.cs @@ -49,9 +49,6 @@ internal partial class AspireServerService : IAsyncDisposable private readonly SocketConnectionManager _socketConnectionManager = new(); - // lock on access: - private readonly HashSet _activeSessions = []; - private volatile bool _isDisposed; private static readonly char[] s_charSeparator = { ' ' }; @@ -110,25 +107,6 @@ public async ValueTask DisposeAsync() _isDisposed = true; - ImmutableArray activeSessions; - lock (_activeSessions) - { - activeSessions = [.. _activeSessions]; - _activeSessions.Clear(); - } - - if (activeSessions is []) - { - Log("All sessions stopped."); - } - else - { - foreach (var activeSession in activeSessions) - { - Log($"DCP failed to stop session ${activeSession}."); - } - } - _socketConnectionManager.Dispose(); _certificate.Dispose(); _shutdownCancellationTokenSource.Dispose(); @@ -143,7 +121,7 @@ public List> GetServerConnectionEnvironment() new(DebugSessionServerCertEnvVar, _certificateEncodedBytes), ]; - public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int exitCode, CancellationToken cancelationToken) + public ValueTask NotifySessionEndedAsync(string dcpId, string sessionId, int processId, int? exitCode, CancellationToken cancelationToken) => SendNotificationAsync( new SessionTerminatedNotification() { @@ -186,7 +164,7 @@ private async ValueTask SendNotificationAsync(TNotification notif { try { - Log($"Sending '{notification.NotificationType}' for session {sessionId}"); + Log($"[#{sessionId}] Sending '{notification.NotificationType}'"); var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions); await SendMessageAsync(dcpId, jsonSerialized, cancelationToken); } @@ -196,7 +174,7 @@ private async ValueTask SendNotificationAsync(TNotification notif bool LogAndPropagate(Exception e) { - Log($"Sending notification '{notification.NotificationType}' failed: {e.Message}"); + Log($"[#{sessionId}] Sending '{notification.NotificationType}' failed: {e.Message}"); return false; } } @@ -355,15 +333,7 @@ private async Task HandleStartSessionRequestAsync(HttpContext context) projectPath = projectLaunchRequest.ProjectPath; - var sessionId = await LaunchProjectAsync(context.GetDcpId(), projectLaunchRequest); - - lock (_activeSessions) - { - if (!_activeSessions.Add(sessionId)) - { - throw new InvalidOperationException($"Session '{sessionId}' already started."); - } - } + var sessionId = await _aspireServerEvents.StartProjectAsync(context.GetDcpId(), projectLaunchRequest, _shutdownCancellationTokenSource.Token); context.Response.StatusCode = (int)HttpStatusCode.Created; context.Response.Headers.Location = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}/{sessionId}"; @@ -412,22 +382,25 @@ private async Task SendMessageAsync(string dcpId, byte[] messageBytes, Cancellat return; } + var success = false; try { - using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCancellationTokenSource.Token, - connection.HttpRequestAborted); + using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _shutdownCancellationTokenSource.Token, connection.HttpRequestAborted); + await _webSocketAccess.WaitAsync(cancelTokenSource.Token); await connection.Socket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token); - } - catch (Exception ex) - { - // If the connection throws it almost certainly means the client has gone away, so clean up that connection - _socketConnectionManager.RemoveSocketConnection(connection); - Log($"Send message failure: {ex.GetMessageFromException()}"); - throw; + + success = true; } finally { + if (!success) + { + // If the connection throws it almost certainly means the client has gone away, so clean up that connection + _socketConnectionManager.RemoveSocketConnection(connection); + } + _webSocketAccess.Release(); } } @@ -441,31 +414,15 @@ private async ValueTask HandleStopSessionRequestAsync(HttpContext context, strin throw new ObjectDisposedException(nameof(AspireServerService), "Received 'DELETE /run_session' request after the service has been disposed."); } - lock (_activeSessions) - { - if (!_activeSessions.Remove(sessionId)) - { - context.Response.StatusCode = (int)HttpStatusCode.NoContent; - return; - } - } - - await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationTokenSource.Token); - context.Response.StatusCode = (int)HttpStatusCode.OK; + var sessionExists = await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationTokenSource.Token); + context.Response.StatusCode = (int)(sessionExists ? HttpStatusCode.OK : HttpStatusCode.NoContent); } catch (Exception e) { - Log($"Failed to stop session '{sessionId}': {e}"); + Log($"[#{sessionId}] Failed to stop: {e}"); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; await WriteResponseTextAsync(context.Response, e, context.GetApiVersion() is not null); } } - - /// - /// Called to launch the project after first creating a LaunchProfile from the sessionRequest object. Returns the sessionId - /// for the launched process. If it throws an exception most likely the project couldn't be launched - /// - private Task LaunchProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo) - => _aspireServerEvents.StartProjectAsync(dcpId, projectLaunchInfo, _shutdownCancellationTokenSource.Token).AsTask(); } diff --git a/src/BuiltInTools/AspireService/Contracts/IAspireServerEvents.cs b/src/BuiltInTools/AspireService/Contracts/IAspireServerEvents.cs index bc5de8f2a42f..1a4b5b6ddee2 100644 --- a/src/BuiltInTools/AspireService/Contracts/IAspireServerEvents.cs +++ b/src/BuiltInTools/AspireService/Contracts/IAspireServerEvents.cs @@ -3,22 +3,22 @@ namespace Microsoft.WebTools.AspireServer.Contracts; -/// -/// Interface implemented on the VS side and pass -/// internal interface IAspireServerEvents { /// /// Called when a request to stop a session is received. /// + /// The id of the session to terminate. The session might have been stopped already. /// DCP/AppHost making the request. May be empty for older DCP versions. - ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancelToken); + /// Returns false if the session is not active. + ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken); /// - /// Called when a request to start a project is received. Returns the sessionId of the started project. + /// Called when a request to start a project is received. Returns the session id of the started project. /// /// DCP/AppHost making the request. May be empty for older DCP versions. - ValueTask StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancelToken); + /// New unique session id. + ValueTask StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken); } internal class ProjectLaunchRequest diff --git a/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs b/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs index 168719c3d311..d20e38f3360f 100644 --- a/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs +++ b/src/BuiltInTools/AspireService/Models/SessionChangeNotification.cs @@ -53,7 +53,7 @@ internal sealed class SessionTerminatedNotification : SessionNotification /// [Required] [JsonPropertyName("exit_code")] - public required int ExitCode { get; init; } + public required int? ExitCode { get; init; } } /// diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs deleted file mode 100644 index a2055d4a0a97..000000000000 --- a/src/BuiltInTools/dotnet-watch/Aspire/AspireServerService.Extensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.Watcher; - -namespace Microsoft.WebTools.AspireServer; - -internal partial class AspireServerService : IRuntimeProcessLauncher -{ - public bool SupportsPartialRestart => false; - - public IEnumerable<(string name, string value)> GetEnvironmentVariables() - => GetServerConnectionEnvironment().Select(kvp => (kvp.Key, kvp.Value)); -} diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs index cbc1fbd139a8..753d6dd66cfb 100644 --- a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs @@ -1,8 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; +using System.Diagnostics; using System.Globalization; +using System.Threading.Channels; using Microsoft.Build.Graph; +using Microsoft.DotNet.Watcher.Internal; using Microsoft.DotNet.Watcher.Tools; using Microsoft.Extensions.Tools.Internal; using Microsoft.WebTools.AspireServer; @@ -12,8 +16,26 @@ namespace Microsoft.DotNet.Watcher; internal class AspireServiceFactory : IRuntimeProcessLauncherFactory { - private sealed class ServerEvents(ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) : IAspireServerEvents + private sealed class SessionManager : IAspireServerEvents, IRuntimeProcessLauncher { + private readonly struct Session(string dcpId, string sessionId, RunningProject runningProject, Task outputReader) + { + public string DcpId { get; } = dcpId; + public string Id { get; } = sessionId; + public RunningProject RunningProject { get; } = runningProject; + public Task OutputReader { get; } = outputReader; + } + + private static readonly UnboundedChannelOptions s_outputChannelOptions = new() + { + SingleReader = true, + SingleWriter = true + }; + + private readonly ProjectLauncher _projectLauncher; + private readonly AspireServerService _service; + private readonly IReadOnlyList<(string name, string value)> _buildProperties; + /// /// Lock to access: /// @@ -21,56 +43,169 @@ private sealed class ServerEvents(ProjectLauncher projectLauncher, IReadOnlyList /// private readonly object _guard = new(); - private readonly Dictionary _sessions = []; + private readonly Dictionary _sessions = []; private int _sessionIdDispenser; + private volatile bool _isDisposed; + + public SessionManager(ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) + { + _projectLauncher = projectLauncher; + _buildProperties = buildProperties; + + _service = new AspireServerService( + this, + displayName: ".NET Watch Aspire Server", + m => projectLauncher.Reporter.Verbose(m, MessageEmoji)); + } + + public async ValueTask DisposeAsync() + { +#if DEBUG + lock (_guard) + { + Debug.Assert(_sessions.Count == 0); + } +#endif + _isDisposed = true; + + await _service.DisposeAsync(); + } + + public async ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + ImmutableArray sessions; + lock (_guard) + { + // caller guarantees the session is active + sessions = [.. _sessions.Values]; + _sessions.Clear(); + } + + foreach (var session in sessions) + { + await TerminateSessionAsync(session, cancellationToken); + } + } + + public IEnumerable<(string name, string value)> GetEnvironmentVariables() + => _service.GetServerConnectionEnvironment().Select(kvp => (kvp.Key, kvp.Value)); private IReporter Reporter - => projectLauncher.Reporter; + => _projectLauncher.Reporter; /// /// Implements /~https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#create-session-request. /// - public async ValueTask StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken) + async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken) { - Reporter.Verbose($"Starting project: {projectLaunchInfo.ProjectPath}", MessageEmoji); + ObjectDisposedException.ThrowIf(_isDisposed, this); var projectOptions = GetProjectOptions(projectLaunchInfo); + var sessionId = Interlocked.Increment(ref _sessionIdDispenser).ToString(CultureInfo.InvariantCulture); + await StartProjectAsync(dcpId, sessionId, projectOptions, build: false, isRestart: false, cancellationToken); + return sessionId; + } + + public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool build, bool isRestart, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + Reporter.Verbose($"Starting project: {projectOptions.ProjectPath}", MessageEmoji); var processTerminationSource = new CancellationTokenSource(); + var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); + + var runningProject = await _projectLauncher.TryLaunchProcessAsync( + projectOptions, + processTerminationSource, + onOutput: line => + { + var writeResult = outputChannel.Writer.TryWrite(line); + Debug.Assert(writeResult); + }, + restartOperation: (build, cancellationToken) => + StartProjectAsync(dcpId, sessionId, projectOptions, build, isRestart: true, cancellationToken), + build: build, + cancellationToken); - var runningProject = await projectLauncher.TryLaunchProcessAsync(projectOptions, processTerminationSource, build: false, cancellationToken); if (runningProject == null) { // detailed error already reported: - throw new ApplicationException($"Failed to launch project '{projectLaunchInfo.ProjectPath}'."); + throw new ApplicationException($"Failed to launch project '{projectOptions.ProjectPath}'."); } - string sessionId; + await _service.NotifySessionStartedAsync(dcpId, sessionId, runningProject.ProcessId, cancellationToken); + + // cancel reading output when the process terminates: + var outputReader = StartChannelReader(processTerminationSource.Token); + lock (_guard) { - sessionId = _sessionIdDispenser++.ToString(CultureInfo.InvariantCulture); - _sessions.Add(sessionId, runningProject); + // When process is restarted we reuse the session id. + // The session already exists, it needs to be updated with new info. + Debug.Assert(_sessions.ContainsKey(sessionId) == isRestart); + + _sessions[sessionId] = new Session(dcpId, sessionId, runningProject, outputReader); } - Reporter.Verbose($"Session started: {sessionId}"); - return sessionId; + Reporter.Verbose($"Session started: #{sessionId}", MessageEmoji); + return runningProject; + + async Task StartChannelReader(CancellationToken cancellationToken) + { + try + { + await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + { + await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken); + } + } + catch (OperationCanceledException) + { + // nop + } + catch (Exception e) + { + Reporter.Error($"Unexpected error reading output of session '{sessionId}': {e}"); + } + } } /// /// Implements /~https://github.com/dotnet/aspire/blob/445d2fc8a6a0b7ce3d8cc42def4d37b02709043b/docs/specs/IDE-execution.md#stop-session-request. /// - public async ValueTask StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken) + async ValueTask IAspireServerEvents.StopSessionAsync(string dcpId, string sessionId, CancellationToken cancellationToken) { - Reporter.Verbose($"Stop Session {sessionId}", MessageEmoji); + ObjectDisposedException.ThrowIf(_isDisposed, this); - RunningProject? runningProject; + Session session; lock (_guard) { - runningProject = _sessions[sessionId]; + if (!_sessions.TryGetValue(sessionId, out session)) + { + return false; + } + _sessions.Remove(sessionId); } - _ = await projectLauncher.TerminateProcessesAsync([runningProject.ProjectNode.ProjectInstance.FullPath], cancellationToken); + await TerminateSessionAsync(session, cancellationToken); + return true; + } + + private async ValueTask TerminateSessionAsync(Session session, CancellationToken cancellationToken) + { + Reporter.Verbose($"Stop session #{session.Id}", MessageEmoji); + + 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); + + // process termination should cancel output reader task: + await session.OutputReader; } private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) @@ -103,8 +238,8 @@ private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) { IsRootProject = false, ProjectPath = projectLaunchInfo.ProjectPath, - WorkingDirectory = projectLauncher.EnvironmentOptions.WorkingDirectory, // TODO: Should DCP protocol specify? - BuildProperties = buildProperties, // TODO: Should DCP protocol specify? + WorkingDirectory = _projectLauncher.EnvironmentOptions.WorkingDirectory, // TODO: Should DCP protocol specify? + BuildProperties = _buildProperties, // TODO: Should DCP protocol specify? Command = "run", CommandArguments = arguments, LaunchEnvironmentVariables = projectLaunchInfo.Environment?.Select(kvp => (kvp.Key, kvp.Value)).ToArray() ?? [], @@ -121,15 +256,7 @@ private ProjectOptions GetProjectOptions(ProjectLaunchRequest projectLaunchInfo) public const string AppHostProjectCapability = "Aspire"; public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, IReadOnlyList<(string name, string value)> buildProperties) - { - if (!projectNode.GetCapabilities().Contains(AppHostProjectCapability)) - { - return null; - } - - // TODO: implement notifications: - // 1) Process restarted notification - // 2) Session terminated notification - return new AspireServerService(new ServerEvents(projectLauncher, buildProperties), displayName: ".NET Watch Aspire Server", m => projectLauncher.Reporter.Verbose(m, MessageEmoji)); - } + => projectNode.GetCapabilities().Contains(AppHostProjectCapability) + ? new SessionManager(projectLauncher, buildProperties) + : null; } diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs index 09a70fd4a871..9c4ab9f433c7 100644 --- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections; using System.Diagnostics; using System.Globalization; -using Microsoft.Build.Definition; using Microsoft.Build.Graph; using Microsoft.DotNet.Watcher.Internal; using Microsoft.DotNet.Watcher.Tools; @@ -14,10 +12,10 @@ namespace Microsoft.DotNet.Watcher { internal sealed class DotNetWatcher(DotNetWatchContext context, MSBuildFileSetFactory fileSetFactory) : Watcher(context, fileSetFactory) { - public override async Task WatchAsync(CancellationToken cancellationToken) + public override async Task WatchAsync(CancellationToken shutdownCancellationToken) { var cancelledTaskSource = new TaskCompletionSource(); - cancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetResult(), + shutdownCancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetResult(), cancelledTaskSource); if (Context.EnvironmentOptions.SuppressMSBuildIncrementalism) @@ -33,7 +31,7 @@ public override async Task WatchAsync(CancellationToken cancellationToken) for (var iteration = 0;;iteration++) { - if (await buildEvaluator.EvaluateAsync(changedFile, cancellationToken) is not { } evaluationResult) + if (await buildEvaluator.EvaluateAsync(changedFile, shutdownCancellationToken) is not { } evaluationResult) { Context.Reporter.Error("Failed to find a list of files to watch"); return; @@ -67,7 +65,7 @@ public override async Task WatchAsync(CancellationToken cancellationToken) }; var browserRefreshServer = (projectRootNode != null) - ? await browserConnector.LaunchOrRefreshBrowserAsync(projectRootNode, processSpec, environmentBuilder, Context.RootProjectOptions, cancellationToken) + ? await browserConnector.LaunchOrRefreshBrowserAsync(projectRootNode, processSpec, environmentBuilder, Context.RootProjectOptions, shutdownCancellationToken) : null; environmentBuilder.ConfigureProcess(processSpec); @@ -75,16 +73,16 @@ public override async Task WatchAsync(CancellationToken cancellationToken) // Reset for next run buildEvaluator.RequiresRevaluation = false; - if (cancellationToken.IsCancellationRequested) + if (shutdownCancellationToken.IsCancellationRequested) { return; } using var currentRunCancellationSource = new CancellationTokenSource(); - using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, currentRunCancellationSource.Token); + using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, currentRunCancellationSource.Token); using var fileSetWatcher = new FileWatcher(evaluationResult.Files, Context.Reporter); - var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, processExitedSource: null, combinedCancellationSource.Token); + var processTask = ProcessRunner.RunAsync(processSpec, Context.Reporter, isUserApplication: true, launchResult: null, combinedCancellationSource.Token); Task fileSetTask; Task finishedTask; @@ -112,7 +110,7 @@ public override async Task WatchAsync(CancellationToken cancellationToken) await Task.WhenAll(processTask, fileSetTask); - if (finishedTask == cancelledTaskSource.Task || cancellationToken.IsCancellationRequested) + if (finishedTask == cancelledTaskSource.Task || shutdownCancellationToken.IsCancellationRequested) { return; } @@ -124,7 +122,7 @@ public override async Task WatchAsync(CancellationToken cancellationToken) // Now wait for a file to change before restarting process changedFile = await fileSetWatcher.GetChangedFileAsync( () => Context.Reporter.Report(MessageDescriptor.WaitingForFileChangeBeforeRestarting), - cancellationToken); + shutdownCancellationToken); } else { diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index cdfc4cf3f9c2..73b3329f8fdb 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -9,13 +9,12 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.EditAndContinue; using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api; -using Microsoft.CodeAnalysis.Text; using Microsoft.DotNet.Watcher.Internal; using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher.Tools { - internal sealed class CompilationHandler : IAsyncDisposable + internal sealed class CompilationHandler : IDisposable { public readonly IncrementalMSBuildWorkspace Workspace; @@ -55,38 +54,24 @@ public CompilationHandler(IReporter reporter) _hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, GetAggregateCapabilitiesAsync); } - public async ValueTask DisposeAsync() + public void Dispose() { _isDisposed = true; - Workspace?.Dispose(); - - IEnumerable projects; - lock (_runningProjectsAndUpdatesGuard) - { - projects = _runningProjects.SelectMany(entry => entry.Value).Where(p => !p.Options.IsRootProject); - _runningProjects = _runningProjects.Clear(); - } - - await TerminateAndDisposeRunningProjects(projects); } - private static async ValueTask TerminateAndDisposeRunningProjects(IEnumerable projects) + public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) { - // cancel first, this will cause the process tasks to complete: - foreach (var project in projects) - { - project.ProcessTerminationSource.Cancel(); - } + _reporter.Verbose("Disposing remaining child processes."); - // wait for all tasks to complete: - await Task.WhenAll(projects.Select(p => p.RunningProcess)).WaitAsync(CancellationToken.None); + var projectsToDispose = await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); - // dispose only after all tasks have completed to prevent the tasks from accessing disposed resources: - foreach (var project in projects) + foreach (var project in projectsToDispose) { project.Dispose(); } + + Dispose(); } public ValueTask RestartSessionAsync(IReadOnlySet projectsToBeRebuilt, CancellationToken cancellationToken) @@ -124,12 +109,13 @@ private DeltaApplier CreateDeltaApplier(ProjectGraphNode projectNode, BrowserRef _ => new DefaultDeltaApplier(processReporter), }; - public async Task TrackRunningProjectAsync( + public async Task TrackRunningProjectAsync( ProjectGraphNode projectNode, ProjectOptions projectOptions, string namedPipeName, BrowserRefreshServer? browserRefreshServer, ProcessSpec processSpec, + RestartOperation restartOperation, IReporter processReporter, CancellationTokenSource processTerminationSource, CancellationToken cancellationToken) @@ -146,7 +132,20 @@ public async Task TrackRunningProjectAsync( // It is important to first create the named pipe connection (delta applier is the server) // and then start the process (named pipe client). Otherwise, the connection would fail. deltaApplier.CreateConnection(namedPipeName, processCommunicationCancellationSource.Token); - var runningProcess = ProcessRunner.RunAsync(processSpec, processReporter, isUserApplication: true, processExitedSource, processTerminationSource.Token); + + processSpec.OnExit += (_, _) => + { + processExitedSource.Cancel(); + return ValueTask.CompletedTask; + }; + + var launchResult = new ProcessLaunchResult(); + var runningProcess = ProcessRunner.RunAsync(processSpec, processReporter, isUserApplication: true, launchResult, processTerminationSource.Token); + if (launchResult.ProcessId == null) + { + // error already reported + return null; + } var capabilityProvider = deltaApplier.GetApplyUpdateCapabilitiesAsync(processCommunicationCancellationSource.Token); var runningProject = new RunningProject( @@ -156,8 +155,10 @@ public async Task TrackRunningProjectAsync( processReporter, browserRefreshServer, runningProcess, + launchResult.ProcessId.Value, processExitedSource: processExitedSource, processTerminationSource: processTerminationSource, + restartOperation: restartOperation, disposables: [processCommunicationCancellationSource], capabilityProvider); @@ -281,6 +282,8 @@ private static void PrepareCompilations(Solution solution, string projectPath, C var runningProjects = _runningProjects; var updates = await _hotReloadService.GetUpdatesAsync(currentSolution, isRunningProject: p => runningProjects.ContainsKey(p.FilePath!), cancellationToken); + var anyProcessNeedsRestart = updates.ProjectsToRestart.Count > 0; + await DisplayResultsAsync(updates, cancellationToken); if (updates.Status is ModuleUpdateStatus.None or ModuleUpdateStatus.Blocked) @@ -292,7 +295,10 @@ private static void PrepareCompilations(Solution solution, string projectPath, C if (updates.Status == ModuleUpdateStatus.RestartRequired) { - Debug.Assert(updates.ProjectsToRestart.Count > 0); + if (!anyProcessNeedsRestart) + { + return (ImmutableHashSet.Empty, []); + } await restartPrompt.Invoke(updates.ProjectsToRestart, cancellationToken); @@ -345,6 +351,8 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates updates, CancellationToken cancellationToken) { + var anyProcessNeedsRestart = updates.ProjectsToRestart.Count > 0; + switch (updates.Status) { case ModuleUpdateStatus.None: @@ -355,7 +363,15 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates update break; case ModuleUpdateStatus.RestartRequired: - _reporter.Output("Unable to apply hot reload, restart is needed to apply the changes."); + if (anyProcessNeedsRestart) + { + _reporter.Output("Unable to apply hot reload, restart is needed to apply the changes."); + } + else + { + _reporter.Verbose("Rude edits detected but do not affect any running process"); + } + break; case ModuleUpdateStatus.Blocked: @@ -418,6 +434,12 @@ void Display(MessageSeverity severity) continue; } + // Do not report rude edits as errors/warnings if no running process is affected. + if (!anyProcessNeedsRestart && diagnostic.Id is ['E', 'N', 'C', >= '0' and <= '9', ..]) + { + descriptor = descriptor with { Severity = MessageSeverity.Verbose }; + } + var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); _reporter.Report(descriptor, display); @@ -436,36 +458,94 @@ await ForEachProjectAsync( } /// - /// Terminates all processes launched for projects with . + /// Terminates all processes launched for projects with , + /// or all running non-root project processes if is null. + /// /// Removes corresponding entries from . /// - /// May terminate the root project process as well. + /// Does not terminate the root project. /// - internal async ValueTask> TerminateNonRootProcessesAsync(IEnumerable projectPaths, CancellationToken cancellationToken) + internal async ValueTask> TerminateNonRootProcessesAsync( + IEnumerable? projectPaths, CancellationToken cancellationToken) { - IEnumerable projectsToRestart; - lock (_runningProjectsAndUpdatesGuard) - { - // capture snapshot of running processes that can be enumerated outside of the lock: - var runningProjects = _runningProjects; - projectsToRestart = projectPaths.SelectMany(path => runningProjects[path]); + ImmutableArray projectsToRestart = []; - _runningProjects = runningProjects.RemoveRange(projectPaths); + UpdateRunningProjects(runningProjectsByPath => + { + if (projectPaths == null) + { + projectsToRestart = _runningProjects.SelectMany(entry => entry.Value).Where(p => !p.Options.IsRootProject).ToImmutableArray(); + return _runningProjects.Clear(); + } - // reset capabilities: - _currentAggregateCapabilities = default; - } + projectsToRestart = projectPaths.SelectMany(path => _runningProjects.TryGetValue(path, out var array) ? array : []).ToImmutableArray(); + return runningProjectsByPath.RemoveRange(projectPaths); + }); // 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 TerminateAndDisposeRunningProjects(projectsToTerminate); + _ = await TerminateRunningProjects(projectsToTerminate, 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) + { + Debug.Assert(!project.Options.IsRootProject); + + var projectPath = project.ProjectNode.ProjectInstance.FullPath; + + UpdateRunningProjects(runningProjectsByPath => + { + if (!runningProjectsByPath.TryGetValue(projectPath, out var runningProjects) || + runningProjects.Remove(project) is var updatedRunningProjects && runningProjects == updatedRunningProjects) + { + _reporter.Verbose($"Ignoring an attempt to terminate process {project.ProcessId} of project '{projectPath}' that has no associated running processes."); + return runningProjectsByPath; + } + + 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) + { + lock (_runningProjectsAndUpdatesGuard) + { + _runningProjects = updater(_runningProjects); + + // reset capabilities: + _currentAggregateCapabilities = default; + } + } + + private static async ValueTask> TerminateRunningProjects(IEnumerable projects, CancellationToken cancellationToken) + { + // cancel first, this will cause the process tasks to complete: + foreach (var project in projects) + { + project.ProcessTerminationSource.Cancel(); + } + + // wait for all tasks to complete: + return await Task.WhenAll(projects.Select(p => p.RunningProcess)).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/DefaultDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs index ca0fe1297410..0bdceee705ca 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/DefaultDeltaApplier.cs @@ -175,7 +175,7 @@ private async Task ReceiveApplyUpdateResult(CancellationToken cancellation private void DisposePipe() { - Reporter.Verbose("Disposing pipe"); + Reporter.Verbose("Disposing agent communication pipe"); _pipe?.Dispose(); _pipe = null; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/IRuntimeProcessLauncher.cs b/src/BuiltInTools/dotnet-watch/HotReload/IRuntimeProcessLauncher.cs index eac5929ec703..375e2a9b3248 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/IRuntimeProcessLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/IRuntimeProcessLauncher.cs @@ -10,4 +10,9 @@ namespace Microsoft.DotNet.Watcher; internal interface IRuntimeProcessLauncher : IAsyncDisposable { IEnumerable<(string name, string value)> GetEnvironmentVariables(); + + /// + /// Initiates shutdown. Terminates all created processes. + /// + ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs index 5be1b7c586c1..9185f32044c2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ProjectLauncher.cs @@ -3,11 +3,14 @@ using System.Globalization; using Microsoft.Build.Graph; +using Microsoft.DotNet.Watcher.Internal; using Microsoft.DotNet.Watcher.Tools; using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher; +internal delegate ValueTask ProcessExitAction(int processId, int? exitCode); + internal sealed class ProjectLauncher( DotNetWatchContext context, ProjectNodeMap projectMap, @@ -23,7 +26,13 @@ public IReporter Reporter public EnvironmentOptions EnvironmentOptions => context.EnvironmentOptions; - public async ValueTask TryLaunchProcessAsync(ProjectOptions projectOptions, CancellationTokenSource processTerminationSource, bool build, CancellationToken cancellationToken) + public async ValueTask TryLaunchProcessAsync( + ProjectOptions projectOptions, + CancellationTokenSource processTerminationSource, + Action? onOutput, + RestartOperation restartOperation, + bool build, + CancellationToken cancellationToken) { var projectNode = projectMap.TryGetProjectNode(projectOptions.ProjectPath, projectOptions.TargetFramework); if (projectNode == null) @@ -34,27 +43,15 @@ public EnvironmentOptions EnvironmentOptions if (!projectNode.IsNetCoreApp(Versions.Version6_0)) { - Reporter.Error($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Update the project's launchSettings.json to disable this feature."); + Reporter.Error($"Hot Reload based watching is only supported in .NET 6.0 or newer apps. Use --no-hot-reload switch or update the project's launchSettings.json to disable this feature."); return null; } - try - { - return await LaunchProcessAsync(projectOptions, projectNode, processTerminationSource, build, cancellationToken); - } - catch (ObjectDisposedException e) when (e.ObjectName == typeof(HotReloadDotNetWatcher).FullName) - { - Reporter.Verbose("Unable to launch project, watcher has been disposed"); - return null; - } - } - - public async Task LaunchProcessAsync(ProjectOptions projectOptions, ProjectGraphNode projectNode, CancellationTokenSource processTerminationSource, bool build, CancellationToken cancellationToken) - { var processSpec = new ProcessSpec { Executable = EnvironmentOptions.MuxerPath, WorkingDirectory = projectOptions.WorkingDirectory, + OnOutput = onOutput, Arguments = build || projectOptions.Command is not ("run" or "test") ? [projectOptions.Command, .. projectOptions.CommandArguments] : [projectOptions.Command, "--no-build", .. projectOptions.CommandArguments] @@ -112,11 +109,12 @@ public async Task LaunchProcessAsync(ProjectOptions projectOptio namedPipeName, browserRefreshServer, processSpec, + restartOperation, processReporter, processTerminationSource, cancellationToken); } - public ValueTask> TerminateProcessesAsync(IReadOnlyList projectPaths, CancellationToken cancellationToken) - => compilationHandler.TerminateNonRootProcessesAsync(projectPaths, cancellationToken); + public ValueTask TerminateProcessAsync(RunningProject project, CancellationToken cancellationToken) + => compilationHandler.TerminateNonRootProcessAsync(project, cancellationToken); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs b/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs index 6b4cad493400..99b04b828a63 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/RunningProject.cs @@ -8,15 +8,19 @@ namespace Microsoft.DotNet.Watcher.Tools { + internal delegate ValueTask RestartOperation(bool build, CancellationToken cancellationToken); + internal sealed class RunningProject( ProjectGraphNode projectNode, ProjectOptions options, DeltaApplier deltaApplier, IReporter reporter, BrowserRefreshServer? browserRefreshServer, - Task runningProcess, + Task runningProcess, + int processId, CancellationTokenSource processExitedSource, CancellationTokenSource processTerminationSource, + RestartOperation restartOperation, IReadOnlyList disposables, Task> capabilityProvider) : IDisposable { @@ -26,7 +30,9 @@ internal sealed class RunningProject( public readonly DeltaApplier DeltaApplier = deltaApplier; public readonly Task> CapabilityProvider = capabilityProvider; public readonly IReporter Reporter = reporter; - public readonly Task RunningProcess = runningProcess; + public readonly Task RunningProcess = runningProcess; + public readonly int ProcessId = processId; + public readonly RestartOperation RestartOperation = restartOperation; /// /// Cancellation source triggered when the process exits. diff --git a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs index dfa80c91e08c..3ca446c43b30 100644 --- a/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs @@ -73,6 +73,8 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke EvaluationResult? evaluationResult = null; RunningProject? rootRunningProject = null; Task? fileSetWatcherTask = null; + IRuntimeProcessLauncher? runtimeProcessLauncher = null; + CompilationHandler? compilationHandler = null; try { @@ -97,14 +99,14 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke await using var browserConnector = new BrowserConnector(Context); var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter); - await using var compilationHandler = new CompilationHandler(Context.Reporter); + compilationHandler = new CompilationHandler(Context.Reporter); var staticFileHandler = new StaticFileHandler(Context.Reporter, projectMap, browserConnector); var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector); var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration); var rootProjectNode = evaluationResult.ProjectGraph.GraphRoots.Single(); - await using var runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProjectNode, projectLauncher, rootProjectOptions.BuildProperties); + runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProjectNode, projectLauncher, rootProjectOptions.BuildProperties); if (runtimeProcessLauncher != null) { var launcherEnvironment = runtimeProcessLauncher.GetEnvironmentVariables(); @@ -114,10 +116,18 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke }; } - rootRunningProject = await projectLauncher.TryLaunchProcessAsync(rootProjectOptions, rootProcessTerminationSource, build: true, iterationCancellationToken); + rootRunningProject = await projectLauncher.TryLaunchProcessAsync( + rootProjectOptions, + rootProcessTerminationSource, + onOutput: null, + restartOperation: new RestartOperation((_, _) => throw new InvalidOperationException("Root project shouldn't be restarted")), + build: true, + iterationCancellationToken); + if (rootRunningProject == null) { // error has been reported: + waitForFileChangeBeforeRestarting = false; return; } @@ -269,6 +279,11 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke else { Context.Reporter.Verbose("Restarting without prompt since dotnet-watch is running in non-interactive mode."); + + foreach (var project in projects) + { + Context.Reporter.Verbose($" Project to restart: '{project.Name}'"); + } } }, iterationCancellationToken); HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.CompilationHandler); @@ -292,7 +307,8 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke await Task.WhenAll( projectsToRestart.Select(async runningProject => { - var newRunningProject = await projectLauncher.LaunchProcessAsync(runningProject.Options, runningProject.ProjectNode, new CancellationTokenSource(), build: true, shutdownCancellationToken); + var newRunningProject = await runningProject.RestartOperation(build: true, shutdownCancellationToken); + runningProject.Dispose(); await newRunningProject.WaitForProcessRunningAsync(shutdownCancellationToken); })) .WaitAsync(shutdownCancellationToken); @@ -316,10 +332,23 @@ await Task.WhenAll( rootProcessTerminationSource.Cancel(); } + if (runtimeProcessLauncher != null) + { + // Request cleanup of all processes created by the launcher before we terminate the root process. + // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. + await runtimeProcessLauncher.TerminateLaunchedProcessesAsync(CancellationToken.None); + } + + if (compilationHandler != null) + { + // Non-cancellable - can only be aborted by forced Ctrl+C, which immediately kills the dotnet-watch process. + await compilationHandler.TerminateNonRootProcessesAndDispose(CancellationToken.None); + } + try { - // Wait for the root process to exit. Child processes will be terminated upon CompilationHandler disposal. - await Task.WhenAll(new[] { rootRunningProject?.RunningProcess, fileSetWatcherTask }.Where(t => t != null)!); + // Wait for the root process to exit. + await Task.WhenAll(new[] { (Task?)rootRunningProject?.RunningProcess, fileSetWatcherTask }.Where(t => t != null)!); } catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested) { @@ -328,6 +357,12 @@ await Task.WhenAll( finally { fileSetWatcherTask = null; + + if (runtimeProcessLauncher != null) + { + await runtimeProcessLauncher.DisposeAsync(); + } + rootRunningProject?.Dispose(); if (evaluationResult != null && diff --git a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs index 3522bd5832aa..85405a76f4a7 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs @@ -56,7 +56,7 @@ internal class MSBuildFileSetFactory( reporter.Verbose($"Running MSBuild target '{TargetName}' on '{rootProjectFile}'"); - var exitCode = await ProcessRunner.RunAsync(processSpec, reporter, isUserApplication: false, processExitedSource: null, cancellationToken); + var exitCode = await ProcessRunner.RunAsync(processSpec, reporter, isUserApplication: false, launchResult: null, cancellationToken); if (exitCode != 0 || !File.Exists(watchList)) { diff --git a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs index a818a780a889..06d9fd194327 100644 --- a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs +++ b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs @@ -9,14 +9,25 @@ namespace Microsoft.DotNet.Watcher.Internal { internal sealed class ProcessRunner { + private const int SIGKILL = 9; + private const int SIGTERM = 15; + + private sealed class ProcessState + { + public int ProcessId; + public bool HasExited; + public bool ForceExit; + } + /// /// Launches a process. /// /// True if the process is a user application, false if it is a helper process (e.g. msbuild). - public static async Task RunAsync(ProcessSpec processSpec, IReporter reporter, bool isUserApplication, CancellationTokenSource? processExitedSource, CancellationToken processTerminationToken) + public static async Task RunAsync(ProcessSpec processSpec, IReporter reporter, bool isUserApplication, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken) { Ensure.NotNull(processSpec, nameof(processSpec)); + var state = new ProcessState(); var stopwatch = new Stopwatch(); var onOutput = processSpec.OnOutput; @@ -27,123 +38,137 @@ public static async Task RunAsync(ProcessSpec processSpec, IReporter report onOutput += line => reporter.ReportProcessOutput(line); } - using var process = CreateProcess(processSpec, redirectOutput: onOutput != null); + using var process = CreateProcess(processSpec, onOutput, state, reporter); - if (onOutput != null) - { - process.OutputDataReceived += (_, args) => - { - if (args.Data != null) - { - onOutput(new OutputLine(args.Data, IsError: false)); - } - }; - - process.ErrorDataReceived += (_, args) => - { - if (args.Data != null) - { - onOutput(new OutputLine(args.Data, IsError: true)); - } - }; - } - - using var processState = new ProcessState(process, reporter); - processTerminationToken.Register(() => processState.TryKill()); + processTerminationToken.Register(() => TerminateProcess(process, state, reporter)); stopwatch.Start(); - int? processId = null; + Exception? launchException = null; try { - if (process.Start()) + if (!process.Start()) { - processId = process.Id; + throw new InvalidOperationException("Process can't be started."); } - } - finally - { - var argsDisplay = processSpec.GetArgumentsDisplay(); - if (processId.HasValue) - { - reporter.Report(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, processId.Value); - } - else + state.ProcessId = process.Id; + + if (onOutput != null) { - reporter.Error($"Failed to launch '{processSpec.Executable}' with arguments '{argsDisplay}'"); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); } } + catch (Exception e) + { + launchException = e; + } - if (processId == null) + var argsDisplay = processSpec.GetArgumentsDisplay(); + if (launchException == null) + { + reporter.Report(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId); + } + else { - // failed to launch + reporter.Error($"Failed to launch '{processSpec.Executable}' with arguments '{argsDisplay}': {launchException.Message}"); return int.MinValue; } - if (onOutput != null) + if (launchResult != null) { - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + launchResult.ProcessId = process.Id; } int? exitCode = null; - var failed = false; try { - await processState.Task; + try + { + await process.WaitForExitAsync(processTerminationToken); + } + catch (OperationCanceledException) + { + // Process termination requested via cancellation token. + // Wait for the actual process exit. + while (true) + { + try + { + // non-cancellable to not leave orphaned processes around blocking resources: + await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + break; + } + catch (TimeoutException) + { + // nop + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || state.ForceExit) + { + reporter.Output($"Waiting for process {state.ProcessId} to exit ..."); + } + else + { + reporter.Output($"Forcing process {state.ProcessId} to exit ..."); + } + + state.ForceExit = true; + } + } } - catch (Exception e) when (e is not OperationCanceledException) + catch (Exception e) { - failed = true; - if (isUserApplication) { - reporter.Error($"Application failed to launch: {e.Message}"); + reporter.Error($"Application failed: {e.Message}"); } } finally { stopwatch.Stop(); - if (!failed && !processTerminationToken.IsCancellationRequested) + state.HasExited = true; + + try { - try + exitCode = process.ExitCode; + } + catch + { + exitCode = null; + } + + reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms and exited with exit code {exitCode}."); + + if (isUserApplication) + { + if (exitCode == 0) { - exitCode = process.ExitCode; + reporter.Output("Exited"); } - catch + else if (exitCode == null) { - exitCode = null; + reporter.Error("Exited with unknown error code"); } - - reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms."); - - if (isUserApplication) + else { - if (exitCode == 0) - { - reporter.Output("Exited"); - } - else if (exitCode == null) - { - reporter.Error("Exited with unknown error code"); - } - else - { - reporter.Error($"Exited with error code {exitCode}"); - } + reporter.Error($"Exited with error code {exitCode}"); } } - processExitedSource?.Cancel(); + if (processSpec.OnExit != null) + { + await processSpec.OnExit(state.ProcessId, exitCode); + } } return exitCode ?? int.MinValue; } - private static Process CreateProcess(ProcessSpec processSpec, bool redirectOutput) + private static Process CreateProcess(ProcessSpec processSpec, Action? onOutput, ProcessState state, IReporter reporter) { var process = new Process { @@ -153,8 +178,8 @@ private static Process CreateProcess(ProcessSpec processSpec, bool redirectOutpu FileName = processSpec.Executable, UseShellExecute = false, WorkingDirectory = processSpec.WorkingDirectory, - RedirectStandardOutput = redirectOutput, - RedirectStandardError = redirectOutput, + RedirectStandardOutput = onOutput != null, + RedirectStandardError = onOutput != null, } }; @@ -175,83 +200,77 @@ private static Process CreateProcess(ProcessSpec processSpec, bool redirectOutpu process.StartInfo.Environment.Add(env.Key, env.Value); } - return process; - } - - private sealed class ProcessState : IDisposable - { - private readonly IReporter _reporter; - private readonly Process _process; - private readonly TaskCompletionSource _processExitedCompletionSource = new(); - private volatile bool _disposed; - - public readonly Task Task; - - public ProcessState(Process process, IReporter reporter) + if (onOutput != null) { - _reporter = reporter; - _process = process; - _process.Exited += OnExited; - Task = _processExitedCompletionSource.Task.ContinueWith(_ => + process.OutputDataReceived += (_, args) => { try { - // We need to use two WaitForExit calls to ensure that all of the output/events are processed. Previously - // this code used Process.Exited, which could result in us missing some output due to the ordering of - // events. - // - // See the remarks here: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.process.waitforexit#System_Diagnostics_Process_WaitForExit_System_Int32_ - if (!_process.WaitForExit(int.MaxValue)) + if (args.Data != null) { - throw new TimeoutException(); + onOutput(new OutputLine(args.Data, IsError: false)); } + } + catch (Exception e) + { + reporter.Verbose($"Error reading stdout of process {state.ProcessId}: {e}"); + } + }; - _process.WaitForExit(); + process.ErrorDataReceived += (_, args) => + { + try + { + if (args.Data != null) + { + onOutput(new OutputLine(args.Data, IsError: true)); + } } - catch (InvalidOperationException) + catch (Exception e) { - // suppress if this throws if no process is associated with this object anymore. + reporter.Verbose($"Error reading stderr of process {state.ProcessId}: {e}"); } - }); + }; } - public void TryKill() + return process; + } + + private static void TerminateProcess(Process process, ProcessState state, IReporter reporter) + { + try { - if (_disposed) + if (!state.HasExited && !process.HasExited) { - return; - } + reporter.Report(MessageDescriptor.KillingProcess, state.ProcessId.ToString()); - try - { - if (!_process.HasExited) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - _reporter.Report(MessageDescriptor.KillingProcess, _process.Id); - _process.Kill(entireProcessTree: true); + process.Kill(); } - } - catch (Exception ex) - { - _reporter.Verbose($"Error while killing process '{_process.StartInfo.FileName} {_process.StartInfo.Arguments}': {ex.Message}"); -#if DEBUG - _reporter.Verbose(ex.ToString()); -#endif - } - } + else + { + [DllImport("libc", SetLastError = true, EntryPoint = "kill")] + static extern int sys_kill(int pid, int sig); - private void OnExited(object? sender, EventArgs args) - => _processExitedCompletionSource.TrySetResult(); + var result = sys_kill(state.ProcessId, state.ForceExit ? SIGKILL : SIGTERM); + if (result != 0) + { + var error = Marshal.GetLastPInvokeError(); + reporter.Verbose($"Error while sending SIGTERM to process {state.ProcessId}: {Marshal.GetPInvokeErrorMessage(error)} (code {error})."); + } + } - public void Dispose() - { - if (!_disposed) - { - TryKill(); - _disposed = true; - _process.Exited -= OnExited; - _process.Dispose(); + reporter.Verbose($"Process {state.ProcessId} killed."); } } + catch (Exception ex) + { + reporter.Verbose($"Error while killing process {state.ProcessId}: {ex.Message}"); +#if DEBUG + reporter.Verbose(ex.ToString()); +#endif + } } } } diff --git a/src/BuiltInTools/dotnet-watch/ProcessLaunchResult.cs b/src/BuiltInTools/dotnet-watch/ProcessLaunchResult.cs new file mode 100644 index 000000000000..3c58c69946a9 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/ProcessLaunchResult.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.DotNet.Watcher +{ + internal sealed class ProcessLaunchResult + { + public int? ProcessId { get; set; } + } +} diff --git a/src/BuiltInTools/dotnet-watch/ProcessSpec.cs b/src/BuiltInTools/dotnet-watch/ProcessSpec.cs index 0c85685acdfc..c6b651c91b55 100644 --- a/src/BuiltInTools/dotnet-watch/ProcessSpec.cs +++ b/src/BuiltInTools/dotnet-watch/ProcessSpec.cs @@ -13,7 +13,7 @@ internal sealed class ProcessSpec public IReadOnlyList? Arguments { get; set; } public string? EscapedArguments { get; set; } public Action? OnOutput { get; set; } - public Func? OnExit { get; set; } + public ProcessExitAction? OnExit { get; set; } public CancellationToken CancelOutputCapture { get; set; } public string? ShortDisplayName() diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 74a112d0fe73..75106f6e67f8 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -97,27 +97,27 @@ public static async Task Main(string[] args) // internal for testing internal async Task RunAsync() { - var cancellationSource = new CancellationTokenSource(); - var cancellationToken = cancellationSource.Token; + var shutdownCancellationSource = new CancellationTokenSource(); + var shutdownCancellationToken = shutdownCancellationSource.Token; console.CancelKeyPress += OnCancelKeyPress; try { - if (cancellationToken.IsCancellationRequested) + if (shutdownCancellationToken.IsCancellationRequested) { return 1; } if (options.List) { - return await ListFilesAsync(cancellationToken); + return await ListFilesAsync(shutdownCancellationToken); } var watcher = CreateWatcher(runtimeProcessLauncherFactory: null); - await watcher.WatchAsync(cancellationToken); + await watcher.WatchAsync(shutdownCancellationToken); return 0; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (shutdownCancellationToken.IsCancellationRequested) { // Ctrl+C forced an exit return 0; @@ -131,20 +131,24 @@ internal async Task RunAsync() finally { console.CancelKeyPress -= OnCancelKeyPress; - cancellationSource.Dispose(); + shutdownCancellationSource.Dispose(); } void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs args) { - // suppress CTRL+C on the first press - args.Cancel = !cancellationSource.IsCancellationRequested; + // if we already canceled, we force immediate shutdown: + var forceShutdown = shutdownCancellationSource.IsCancellationRequested; - if (args.Cancel) + if (!forceShutdown) { reporter.Report(MessageDescriptor.ShutdownRequested); + shutdownCancellationSource.Cancel(); + args.Cancel = true; + } + else + { + Environment.Exit(0); } - - cancellationSource.Cancel(); } } diff --git a/src/BuiltInTools/dotnet-watch/Watcher.cs b/src/BuiltInTools/dotnet-watch/Watcher.cs index 743a25b627f4..b24a93f700e5 100644 --- a/src/BuiltInTools/dotnet-watch/Watcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watcher.cs @@ -10,6 +10,6 @@ internal abstract class Watcher(DotNetWatchContext context, MSBuildFileSetFactor public DotNetWatchContext Context => context; public MSBuildFileSetFactory RootFileSetFactory => rootFileSetFactory; - public abstract Task WatchAsync(CancellationToken cancellationToken); + public abstract Task WatchAsync(CancellationToken shutdownCancellationToken); } } diff --git a/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs index 876f86f66472..30ddc62054a3 100644 --- a/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs +++ b/test/Microsoft.WebTools.AspireService.Tests/AspireServerServiceTests.cs @@ -120,7 +120,8 @@ public async Task LaunchProject_Success_ThenStopProcessRequest() mocks.GetOrCreate() .ImplementStartProjectAsync(DcpId, "2") - .ImplementStopSessionAsync(DcpId, "2"); + .ImplementStopSessionAsync(DcpId, "2", exists: true) + .ImplementStopSessionAsync(DcpId, "3", exists: false); var server = await GetAspireServer(mocks); var tokens = server.GetServerVariables(); diff --git a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs index 0ba8f588b176..3c805871fdf4 100644 --- a/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs +++ b/test/Microsoft.WebTools.AspireService.Tests/Mocks/IAspireServerEventsMock.cs @@ -28,7 +28,7 @@ public IAspireServerEventsMock ImplementStartProjectAsync(string dcpId, string s return this; } - public IAspireServerEventsMock ImplementStopSessionAsync(string dcpId, string sessionId, Exception? ex = null) + public IAspireServerEventsMock ImplementStopSessionAsync(string dcpId, string sessionId, bool exists, Exception? ex = null) { MockObject.Setup(x => x.StopSessionAsync(dcpId, sessionId, It.IsAny())) .Returns(() => @@ -38,7 +38,7 @@ public IAspireServerEventsMock ImplementStopSessionAsync(string dcpId, string se throw ex; } - return new ValueTask(); + return new ValueTask(exists); }) .Verifiable(); return this; diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 2ec05735773f..fa8f184c48af 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -394,6 +394,9 @@ public async Task Aspire() // 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"); + var newSource = File.ReadAllText(serviceSourcePath, Encoding.UTF8); newSource = newSource.Replace("Enumerable.Range(1, 5)", "Enumerable.Range(1, 10)"); UpdateSourceFile(serviceSourcePath, newSource); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index e405cb356bfe..d6fbbbfd6079 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -3,6 +3,7 @@ #nullable enable +using Microsoft.DotNet.Watcher.Tools; using Microsoft.Extensions.Tools.Internal; namespace Microsoft.DotNet.Watcher.Tests; @@ -16,6 +17,43 @@ public enum TriggerEvent WaitingForChanges, } + private static async Task Launch(string projectPath, TestRuntimeProcessLauncher service, string workingDirectory, CancellationToken cancellationToken) + { + var projectOptions = new ProjectOptions() + { + IsRootProject = false, + ProjectPath = projectPath, + WorkingDirectory = workingDirectory, + BuildProperties = [], + Command = "run", + CommandArguments = ["--project", projectPath], + LaunchEnvironmentVariables = [], + LaunchProfileName = null, + NoLaunchProfile = true, + TargetFramework = null, + }; + + RestartOperation? startOp = null; + startOp = new RestartOperation(async (build, cancellationToken) => + { + var result = await service.ProjectLauncher.TryLaunchProcessAsync( + projectOptions, + new CancellationTokenSource(), + onOutput: null, + restartOperation: startOp!, + build, + cancellationToken); + + Assert.NotNull(result); + + await result.WaitForProcessRunningAsync(cancellationToken); + + return result; + }); + + return await startOp(build: false, cancellationToken); + } + [Theory] [CombinatorialData] public async Task UpdateAndRudeEdit(TriggerEvent trigger) @@ -77,8 +115,14 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) return; } - Launch(serviceProjectA, launchCompletionA).Wait(); - Launch(serviceProjectB, launchCompletionB).Wait(); + // service should have been created before Hot Reload session started: + Assert.NotNull(service); + + Launch(serviceProjectA, service, workingDirectory, watchCancellationSource.Token).Wait(); + launchCompletionA.TrySetResult(); + + Launch(serviceProjectB, service, workingDirectory, watchCancellationSource.Token).Wait(); + launchCompletionB.TrySetResult(); }); var waitingForChanges = reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); @@ -118,34 +162,6 @@ public async Task UpdateAndRudeEdit(TriggerEvent trigger) Assert.Equal(4, launchedProcessCount); - async Task Launch(string projectPath, TaskCompletionSource completion) - { - // service should have been created before Hot Reload session started: - Assert.NotNull(service); - - var processTerminationSource = new CancellationTokenSource(); - var projectOptions = new ProjectOptions() - { - IsRootProject = false, - ProjectPath = projectPath, - WorkingDirectory = workingDirectory, - BuildProperties = [], - Command = "run", - CommandArguments = ["--project", projectPath], - LaunchEnvironmentVariables = [], - LaunchProfileName = null, - NoLaunchProfile = true, - TargetFramework = null, - }; - - var runningProject = await service.ProjectLauncher.TryLaunchProcessAsync(projectOptions, processTerminationSource, build: false, watchCancellationSource.Token); - Assert.NotNull(runningProject); - - await runningProject.WaitForProcessRunningAsync(CancellationToken.None); - - completion.SetResult(); - } - // Hot Reload shared dependency - should update both service projects async Task MakeValidDependencyChange() { @@ -302,7 +318,10 @@ public async Task UpdateAppliedToNewProcesses(bool sharedOutput) // let the host process start: await waitingForChanges.WaitAsync(); - await Launch(serviceProjectA); + // service should have been created before Hot Reload session started: + Assert.NotNull(service); + + await Launch(serviceProjectA, service, workingDirectory, watchCancellationSource.Token); UpdateSourceFile(libSource, """ @@ -323,7 +342,7 @@ public static void Common() await updatesApplied.WaitAsync(); await updatesApplied.WaitAsync(); - await Launch(serviceProjectB); + await Launch(serviceProjectB, service, workingDirectory, watchCancellationSource.Token); // ServiceB received updates: await updatesApplied.WaitAsync(); @@ -338,32 +357,6 @@ public static void Common() catch (OperationCanceledException) { } - - async Task Launch(string projectPath) - { - // service should have been created before Hot Reload session started: - Assert.NotNull(service); - - var processTerminationSource = new CancellationTokenSource(); - var projectOptions = new ProjectOptions() - { - IsRootProject = false, - ProjectPath = projectPath, - WorkingDirectory = workingDirectory, - BuildProperties = [], - Command = "run", - CommandArguments = ["--project", projectPath], - LaunchEnvironmentVariables = [], - LaunchProfileName = null, - NoLaunchProfile = true, - TargetFramework = null, - }; - - var runningProject = await service.ProjectLauncher.TryLaunchProcessAsync(projectOptions, processTerminationSource, build: false, watchCancellationSource.Token); - Assert.NotNull(runningProject); - - await runningProject.WaitForProcessRunningAsync(CancellationToken.None); - } } public enum UpdateLocation @@ -489,4 +482,79 @@ public static void Print() { } } + + [Fact] + public async Task RudeEditInProjectWithoutRunningProcess() + { + var testAsset = TestAssets.CopyTestAsset("WatchAppMultiProc") + .WithSource(); + + var workingDirectory = testAsset.Path; + var hostDir = Path.Combine(testAsset.Path, "Host"); + var hostProject = Path.Combine(hostDir, "Host.csproj"); + var serviceDirA = Path.Combine(testAsset.Path, "ServiceA"); + var serviceSourceA2 = Path.Combine(serviceDirA, "A2.cs"); + var serviceProjectA = Path.Combine(serviceDirA, "A.csproj"); + + var console = new TestConsole(Logger); + var reporter = new TestReporter(Logger); + + var program = Program.TryCreate( + TestOptions.GetCommandLineOptions(["--verbose", "--non-interactive", "--project", hostProject]), + console, + TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath), + reporter, + out var errorCode); + + Assert.Equal(0, errorCode); + Assert.NotNull(program); + + TestRuntimeProcessLauncher? service = null; + var factory = new TestRuntimeProcessLauncher.Factory(s => + { + service = s; + }); + + var watcher = Assert.IsType(program.CreateWatcher(factory)); + + var watchCancellationSource = new CancellationTokenSource(); + var watchTask = watcher.WatchAsync(watchCancellationSource.Token); + + var waitingForChanges = reporter.RegisterSemaphore(MessageDescriptor.WaitingForChanges); + + var changeHandled = reporter.RegisterSemaphore(MessageDescriptor.HotReloadChangeHandled); + var sessionStarted = reporter.RegisterSemaphore(MessageDescriptor.HotReloadSessionStarted); + + // let the host process start: + await waitingForChanges.WaitAsync(); + + // service should have been created before Hot Reload session started: + Assert.NotNull(service); + + var runningProject = await Launch(serviceProjectA, service, workingDirectory, watchCancellationSource.Token); + await sessionStarted.WaitAsync(); + + // Terminate the process: + await service.ProjectLauncher.TerminateProcessAsync(runningProject, CancellationToken.None); + + // rude edit in A (changing assembly level attribute): + UpdateSourceFile(serviceSourceA2, """ + [assembly: System.Reflection.AssemblyMetadata("TestAssemblyMetadata", "2")] + """); + + await changeHandled.WaitAsync(); + + reporter.ProcessOutput.Contains("verbose ⌚ Rude edits detected but do not affect any running process"); + reporter.ProcessOutput.Contains($"verbose ❌ {serviceSourceA2}(1,12): error ENC0003: Updating 'attribute' requires restarting the application."); + + // clean up: + watchCancellationSource.Cancel(); + try + { + await watchTask; + } + catch (OperationCanceledException) + { + } + } } diff --git a/test/dotnet-watch.Tests/Utilities/TestReporter.cs b/test/dotnet-watch.Tests/Utilities/TestReporter.cs index 556cfb930e6c..e7054f2ed195 100644 --- a/test/dotnet-watch.Tests/Utilities/TestReporter.cs +++ b/test/dotnet-watch.Tests/Utilities/TestReporter.cs @@ -13,6 +13,7 @@ namespace Microsoft.Extensions.Tools.Internal internal class TestReporter(ITestOutputHelper output) : IReporter { private readonly Dictionary _actions = []; + public readonly List ProcessOutput = []; public bool EnableProcessOutputReporting => true; @@ -23,12 +24,18 @@ public bool EnableProcessOutputReporting public void ReportProcessOutput(OutputLine line) { output.WriteLine(line.Content); + ProcessOutput.Add(line.Content); + OnProcessOutput?.Invoke(line); } public void ReportProcessOutput(ProjectGraphNode project, OutputLine line) { - output.WriteLine($"[{project.GetDisplayName()}]: {line.Content}"); + var content = $"[{project.GetDisplayName()}]: {line.Content}"; + + output.WriteLine(content); + ProcessOutput.Add(content); + OnProjectProcessOutput?.Invoke(project.ProjectInstance.FullPath, line); } diff --git a/test/dotnet-watch.Tests/Utilities/TestRuntimeProcessLauncher.cs b/test/dotnet-watch.Tests/Utilities/TestRuntimeProcessLauncher.cs index 21eb13a5dc5c..35e8fc249c56 100644 --- a/test/dotnet-watch.Tests/Utilities/TestRuntimeProcessLauncher.cs +++ b/test/dotnet-watch.Tests/Utilities/TestRuntimeProcessLauncher.cs @@ -20,6 +20,7 @@ public IRuntimeProcessLauncher TryCreate(ProjectGraphNode projectNode, ProjectLa } public Func>? GetEnvironmentVariablesImpl; + public Action? TerminateLaunchedProcessesImpl; public ProjectLauncher ProjectLauncher { get; } = projectLauncher; @@ -28,4 +29,10 @@ public ValueTask DisposeAsync() public IEnumerable<(string name, string value)> GetEnvironmentVariables() => GetEnvironmentVariablesImpl?.Invoke() ?? []; + + public ValueTask TerminateLaunchedProcessesAsync(CancellationToken cancellationToken) + { + TerminateLaunchedProcessesImpl?.Invoke(); + return ValueTask.CompletedTask; + } }