diff --git a/src/Dotnet.Watch/.editorconfig b/src/Dotnet.Watch/.editorconfig index 54d2f34da8e4..09b2d5adee8b 100644 --- a/src/Dotnet.Watch/.editorconfig +++ b/src/Dotnet.Watch/.editorconfig @@ -14,13 +14,15 @@ dotnet_diagnostic.CA2008.severity = none # Do not create tasks without passing a # CS - C# compiler warnings/errors dotnet_diagnostic.CS1591.severity = none # Missing XML comment for publicly visible type or member -dotnet_diagnostic.CS1573.severity = none # Parameter 'sourceFile' has no matching param tag in the XML comment +dotnet_diagnostic.CS1573.severity = none # Parameter has no matching param tag in the XML comment +dotnet_diagnostic.CS1572.severity = warning # XML comment has a param tag for '...', but there is no parameter by that name # IDE - IDE/Style warnings dotnet_diagnostic.IDE0005.severity = none # Using directive is unnecessary dotnet_diagnostic.IDE0011.severity = none # Add braces dotnet_diagnostic.IDE0036.severity = none # Order modifiers +dotnet_diagnostic.IDE0044.severity = none # Make field readonly dotnet_diagnostic.IDE0060.severity = none # Remove unused parameter dotnet_diagnostic.IDE0073.severity = none # File header does not match required text dotnet_diagnostic.IDE0161.severity = none # Convert to file-scoped namespace -dotnet_diagnostic.IDE1006.severity = none # Naming rule violation +dotnet_diagnostic.IDE1006.severity = none # Naming rule violation \ No newline at end of file diff --git a/src/Dotnet.Watch/AspireService/AspireServerService.cs b/src/Dotnet.Watch/AspireService/AspireServerService.cs index 064de0ee7a50..5183e05a8ffc 100644 --- a/src/Dotnet.Watch/AspireService/AspireServerService.cs +++ b/src/Dotnet.Watch/AspireService/AspireServerService.cs @@ -42,7 +42,12 @@ internal partial class AspireServerService : IAsyncDisposable private readonly string _currentSecret; private readonly string _displayName; - private readonly CancellationTokenSource _shutdownCancellationTokenSource = new(); + /// + /// Triggered when the shutdown process has been initiated. + /// During this time all requests respond with . + /// + private readonly CancellationTokenSource _shutdownCancellationSource = new(); + private readonly int _port; private readonly X509Certificate2 _certificate; private readonly string _certificateEncodedBytes; @@ -91,12 +96,20 @@ public AspireServerService(IAspireServerEvents aspireServerEvents, string displa _requestListener = StartListeningAsync(); } + public void InitiateShutdown() + { + Log("Server shutdown initiated."); + _shutdownCancellationSource.Cancel(); + + // TODO: send message to DCP + // https://github.com/dotnet/aspire/issues/14987 + } + public async ValueTask DisposeAsync() { - // Shutdown the service: - _shutdownCancellationTokenSource.Cancel(); + Log("Disposing server ..."); - Log("Waiting for server to shutdown ..."); + _shutdownCancellationSource.Cancel(); try { @@ -111,7 +124,7 @@ public async ValueTask DisposeAsync() _socketConnectionManager.Dispose(); _certificate.Dispose(); - _shutdownCancellationTokenSource.Dispose(); + _shutdownCancellationSource.Dispose(); } /// @@ -214,14 +227,14 @@ private Task StartListeningAsync() var app = builder.Build(); app.MapGet("/", () => _displayName); - app.MapGet(InfoResponse.Url, GetInfoAsync); + app.MapGet(InfoResponse.Url, HandleInfoRequestAsync); // Set up the run session endpoints var runSessionApi = app.MapGroup(RunSessionRequest.Url); - runSessionApi.MapPut("/", RunSessionPutAsync); - runSessionApi.MapDelete("/{sessionId}", RunSessionDeleteAsync); - runSessionApi.Map(SessionNotification.Url, RunSessionNotifyAsync); + runSessionApi.MapPut("/", HandleStartSessionRequestAsync); + runSessionApi.MapDelete("/{sessionId}", HandleStopSessionRequestAsync); + runSessionApi.Map(SessionNotification.Url, HandleSessionNotifyRequestAsync); app.UseWebSockets(new WebSocketOptions { @@ -229,62 +242,48 @@ private Task StartListeningAsync() }); // Run the application async. It will shutdown when the cancel token is signaled - return app.RunAsync(_shutdownCancellationTokenSource.Token); + return app.RunAsync(_shutdownCancellationSource.Token); } - private async Task RunSessionPutAsync(HttpContext context) + private bool TryAcceptRequest(HttpContext context) { - // Check the authentication header - if (!IsValidAuthentication(context)) - { - Log("Authorization failure"); - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - } - else + if (_shutdownCancellationSource.IsCancellationRequested) { - await HandleStartSessionRequestAsync(context); + Log("Service unavailable -- shutdown in progress."); + context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; + return false; } - } - private async Task RunSessionDeleteAsync(HttpContext context, string sessionId) - { // Check the authentication header if (!IsValidAuthentication(context)) { Log("Authorization failure"); context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return false; } - else - { - await HandleStopSessionRequestAsync(context, sessionId); - } + + return true; } - private async Task GetInfoAsync(HttpContext context) + private async Task HandleInfoRequestAsync(HttpContext context) { - // Check the authentication header - if (!IsValidAuthentication(context)) + if (!TryAcceptRequest(context)) { - Log("Authorization failure"); - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - } - else - { - context.Response.StatusCode = (int)HttpStatusCode.OK; - await context.Response.WriteAsJsonAsync(InfoResponse.Instance, JsonSerializerOptions, _shutdownCancellationTokenSource.Token); + return; } + + context.Response.StatusCode = (int)HttpStatusCode.OK; + await context.Response.WriteAsJsonAsync(InfoResponse.Instance, JsonSerializerOptions, _shutdownCancellationSource.Token); } - private async Task RunSessionNotifyAsync(HttpContext context) + private async Task HandleSessionNotifyRequestAsync(HttpContext context) { - // Check the authentication header - if (!IsValidAuthentication(context)) + if (!TryAcceptRequest(context)) { - Log("Authorization failure"); - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; return; } - else if (!context.WebSockets.IsWebSocketRequest) + + if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = StatusCodes.Status400BadRequest; return; @@ -323,6 +322,11 @@ private bool IsValidAuthentication(HttpContext context) private async Task HandleStartSessionRequestAsync(HttpContext context) { + if (!TryAcceptRequest(context)) + { + return; + } + string? projectPath = null; try @@ -333,7 +337,7 @@ private async Task HandleStartSessionRequestAsync(HttpContext context) } // Get the project launch request data - var projectLaunchRequest = await context.GetProjectLaunchInformationAsync(_shutdownCancellationTokenSource.Token); + var projectLaunchRequest = await context.GetProjectLaunchInformationAsync(_shutdownCancellationSource.Token); if (projectLaunchRequest == null) { // Unknown or unsupported version @@ -343,7 +347,7 @@ private async Task HandleStartSessionRequestAsync(HttpContext context) projectPath = projectLaunchRequest.ProjectPath; - var sessionId = await _aspireServerEvents.StartProjectAsync(context.GetDcpId(), projectLaunchRequest, _shutdownCancellationTokenSource.Token); + var sessionId = await _aspireServerEvents.StartProjectAsync(context.GetDcpId(), projectLaunchRequest, _shutdownCancellationSource.Token); context.Response.StatusCode = (int)HttpStatusCode.Created; context.Response.Headers.Location = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}/{sessionId}"; @@ -370,14 +374,14 @@ private async Task WriteResponseTextAsync(HttpResponse response, Exception ex, b Error = new ErrorDetail { ErrorCode = errorCode, Message = ex.GetMessageFromException() } }; - await response.WriteAsJsonAsync(error, JsonSerializerOptions, _shutdownCancellationTokenSource.Token); + await response.WriteAsJsonAsync(error, JsonSerializerOptions, _shutdownCancellationSource.Token); } else { errorResponse = Encoding.UTF8.GetBytes(ex.GetMessageFromException()); response.ContentType = "text/plain"; response.ContentLength = errorResponse.Length; - await response.WriteAsync(ex.GetMessageFromException(), _shutdownCancellationTokenSource.Token); + await response.WriteAsync(ex.GetMessageFromException(), _shutdownCancellationSource.Token); } } @@ -394,7 +398,7 @@ private async ValueTask SendMessageAsync(string dcpId, byte[] messageBytes try { using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, _shutdownCancellationTokenSource.Token, connection.HttpRequestAborted); + cancellationToken, _shutdownCancellationSource.Token, connection.HttpRequestAborted); await _webSocketAccess.WaitAsync(cancelTokenSource.Token); await connection.Socket.SendAsync(new ArraySegment(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token); @@ -415,8 +419,13 @@ private async ValueTask SendMessageAsync(string dcpId, byte[] messageBytes return success; } - private async ValueTask HandleStopSessionRequestAsync(HttpContext context, string sessionId) + private async Task HandleStopSessionRequestAsync(HttpContext context, string sessionId) { + if (!TryAcceptRequest(context)) + { + return; + } + try { if (_isDisposed) @@ -424,7 +433,7 @@ private async ValueTask HandleStopSessionRequestAsync(HttpContext context, strin throw new ObjectDisposedException(nameof(AspireServerService), "Received 'DELETE /run_session' request after the service has been disposed."); } - var sessionExists = await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationTokenSource.Token); + var sessionExists = await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationSource.Token); context.Response.StatusCode = (int)(sessionExists ? HttpStatusCode.OK : HttpStatusCode.NoContent); } catch (Exception e) when (e is not OperationCanceledException) diff --git a/src/Dotnet.Watch/HotReloadClient/ClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/ClientTransport.cs index 0422348dc203..660c6c4e5510 100644 --- a/src/Dotnet.Watch/HotReloadClient/ClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/ClientTransport.cs @@ -44,5 +44,10 @@ internal abstract class ClientTransport : IDisposable /// public abstract ValueTask ReadAsync(CancellationToken cancellationToken); + /// + /// True if and indicate an expected connection termination signal. + /// + public abstract bool IsExpectedConnectionTermination(Exception exception, CancellationToken cancellationToken); + public abstract void Dispose(); } diff --git a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs index c07dc05444fe..7597541de4ec 100644 --- a/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/Dotnet.Watch/HotReloadClient/DefaultHotReloadClient.cs @@ -82,7 +82,7 @@ async Task> ConnectAsync() { // 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) + if (!transport.IsExpectedConnectionTermination(e, cancellationToken)) { Logger.LogError("Failed to read capabilities: {Message}", e.Message); } @@ -127,7 +127,7 @@ private async Task ListenForResponsesAsync(CancellationToken cancellationToken) } catch (Exception e) when (e is not OperationCanceledException) { - if (!cancellationToken.IsCancellationRequested) + if (!transport.IsExpectedConnectionTermination(e, cancellationToken)) { Logger.LogError("Failed to read response: {Exception}", e.ToString()); } diff --git a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs index 0328c5aa4aca..488d4aaa3bde 100644 --- a/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/NamedPipeClientTransport.cs @@ -50,30 +50,14 @@ public override void ConfigureEnvironment(IDictionary env) public override async Task WaitForConnectionAsync(CancellationToken cancellationToken) { _logger.LogDebug("Waiting for application to connect to pipe '{NamedPipeName}'.", _namedPipeName); - - try - { - await _pipe.WaitForConnectionAsync(cancellationToken); - } - catch (Exception e) when (e is not OperationCanceledException) - { - // The process may die while we're waiting for the connection and the pipe may be disposed. - // Log and let subsequent ReadAsync return null gracefully. - if (IsExpectedPipeException(e, cancellationToken)) - { - _logger.LogDebug("Pipe connection ended: {Message}", e.Message); - return; - } - - throw; - } + await _pipe.WaitForConnectionAsync(cancellationToken); } /// /// Returns true if the exception is expected when the pipe is disposed or the process has terminated. /// On Unix named pipes can also throw SocketException with ErrorCode 125 (Operation canceled) when disposed. /// - private static bool IsExpectedPipeException(Exception e, CancellationToken cancellationToken) + public override bool IsExpectedConnectionTermination(Exception e, CancellationToken cancellationToken) { return e is ObjectDisposedException or EndOfStreamException or SocketException { ErrorCode: 125 } || cancellationToken.IsCancellationRequested; @@ -93,16 +77,8 @@ public override async ValueTask WriteAsync(byte type, Func ReadAsync(CancellationToken cancellationToken) { - try - { - var type = (ResponseType)await _pipe.ReadByteAsync(cancellationToken); - return new ClientTransportResponse(type, _pipe, disposeStream: false); - } - catch (Exception e) when (e is not OperationCanceledException && IsExpectedPipeException(e, cancellationToken)) - { - // Pipe has been disposed or the process has terminated. - return null; - } + var type = (ResponseType)await _pipe.ReadByteAsync(cancellationToken); + return new ClientTransportResponse(type, _pipe, disposeStream: false); } public override void Dispose() diff --git a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs index ec6033f648c6..bfcceeb50a9b 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs @@ -39,10 +39,6 @@ public ImmutableArray ServerUrls /// /// Starts the Kestrel WebSocket server. /// - /// Host name to bind to - /// HTTP port to bind to (0 for auto-assign) - /// HTTPS port to bind to in addition to HTTP port. Null to skip HTTPS. - /// Cancellation token public static async ValueTask StartServerAsync(WebSocketConfig config, RequestDelegate requestHandler, CancellationToken cancellationToken) { var host = new HostBuilder() diff --git a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs index 91bff33e95e0..6bcc49d6e7fe 100644 --- a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs @@ -43,6 +43,9 @@ public override void Dispose() _handler.Dispose(); } + public override bool IsExpectedConnectionTermination(Exception exception, CancellationToken cancellationToken) + => cancellationToken.IsCancellationRequested; + /// /// Creates and starts a new instance. /// diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs index 00d40cfecd85..ed077b3b03e5 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireServerLauncher.cs @@ -3,12 +3,6 @@ using System.Collections.Immutable; using System.CommandLine; -using System.Threading.Channels; -using Microsoft.CodeAnalysis.Elfie.Diagnostics; -using Microsoft.DotNet.HotReload; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace Microsoft.DotNet.Watch; diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs index 0ccc26894288..389bb872cc07 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/AspireWatcherLauncher.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using Microsoft.CodeAnalysis.Elfie.Diagnostics; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs index 8bb1e7548f30..145f90e43d35 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/ProcessLauncherFactory.cs @@ -7,7 +7,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading.Channels; -using Aspire.Tools.Service; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs index e676a2ff0b7f..0e58d1485213 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/StatusReportingLoggerFactory.cs @@ -7,6 +7,7 @@ namespace Microsoft.DotNet.Watch; /// /// Intercepts select log messages reported by watch and forwards them to to be sent to an external listener. +/// Does not own (dispose) and . /// internal sealed class StatusReportingLoggerFactory(WatchStatusWriter writer, LoggerFactory underlyingFactory) : ILoggerFactory { diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs index 858ec4f8a9b5..d48ba798b76c 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchControlReader.cs @@ -46,6 +46,8 @@ public async ValueTask DisposeAsync() { // Pipe may already be broken if the server disconnected } + + _disposalCancellationSource.Dispose(); } private async Task ListenAsync(CancellationToken cancellationToken) diff --git a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs index a208be7836d5..4b273f54da96 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Server/WatchStatusWriter.cs @@ -1,12 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.IO.Pipes; -using System.Reflection.Metadata; using System.Text.Json; using System.Threading.Channels; -using Microsoft.CodeAnalysis.Elfie.Diagnostics; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; diff --git a/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs index ffdd4f424a4b..161c736fa30b 100644 --- a/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs +++ b/src/Dotnet.Watch/Watch.Aspire/Utilities/AspireEnvironmentVariables.cs @@ -1,10 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Text; - namespace Microsoft.DotNet.Watch; internal static class AspireEnvironmentVariables diff --git a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs index 10d46c189096..bd90577d05aa 100644 --- a/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs +++ b/src/Dotnet.Watch/Watch/Aspire/AspireServiceFactory.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Globalization; using System.Threading.Channels; @@ -46,7 +47,7 @@ private readonly struct Session(string dcpId, string sessionId, RunningProject r private volatile bool _isDisposed; // The number of sessions whose initialization is in progress. - private int _pendingSessionInitializationCount; + private volatile int _pendingSessionInitializationCount; // Blocks disposal until no session initialization is in progress. private readonly SemaphoreSlim _postDisposalSessionInitializationCompleted = new(initialCount: 0, maxCount: 1); @@ -69,14 +70,16 @@ public async ValueTask DisposeAsync() _logger.LogDebug("Disposing service factory ..."); - // stop accepting requests - triggers cancellation token for in-flight operations: - await _service.DisposeAsync(); - - // should not receive any more requests at this point: _isDisposed = true; + // should not receive any more requests at this point, triggers cancellation of any in-flight operations: + _service.InitiateShutdown(); + // wait for all in-flight process initialization to complete: - await _postDisposalSessionInitializationCompleted.WaitAsync(CancellationToken.None); + if (_pendingSessionInitializationCount > 0) + { + await _postDisposalSessionInitializationCompleted.WaitAsync(CancellationToken.None); + } // terminate all active sessions: ImmutableArray sessions; @@ -91,6 +94,9 @@ public async ValueTask DisposeAsync() _postDisposalSessionInitializationCompleted.Dispose(); + // terminate the web server: + await _service.DisposeAsync(); + _logger.LogDebug("Service factory disposed"); } @@ -102,8 +108,6 @@ public async ValueTask DisposeAsync() /// async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, ProjectLaunchRequest projectLaunchInfo, CancellationToken cancellationToken) { - ObjectDisposedException.ThrowIf(_isDisposed, this); - var projectOptions = GetProjectOptions(projectLaunchInfo); var sessionId = Interlocked.Increment(ref _sessionIdDispenser).ToString(CultureInfo.InvariantCulture); await StartProjectAsync(dcpId, sessionId, projectOptions, isRestart: false, cancellationToken); @@ -112,18 +116,18 @@ async ValueTask IAspireServerEvents.StartProjectAsync(string dcpId, Proj public async ValueTask StartProjectAsync(string dcpId, string sessionId, ProjectOptions projectOptions, bool isRestart, CancellationToken cancellationToken) { - // Neither request from DCP nor restart should happen once the disposal has started. - ObjectDisposedException.ThrowIf(_isDisposed, this); - - _logger.LogDebug("[#{SessionId}] Starting: '{Path}'", sessionId, projectOptions.Representation.ProjectOrEntryPointFilePath); - - RunningProject? runningProject = null; - var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); - Interlocked.Increment(ref _pendingSessionInitializationCount); - try { + // Abort before launching if cancellation has been requested. + // This is important after shutdown has been initiated to avoid creating orphaned processes. + cancellationToken.ThrowIfCancellationRequested(); + + _logger.LogDebug("[#{SessionId}] Starting: '{Path}'", sessionId, projectOptions.Representation.ProjectOrEntryPointFilePath); + + RunningProject? runningProject = null; + var outputChannel = Channel.CreateUnbounded(s_outputChannelOptions); + runningProject = await _projectLauncher.TryLaunchProcessAsync( projectOptions, onOutput: line => @@ -169,34 +173,34 @@ public async ValueTask StartProjectAsync(string dcpId, string sessionId, Project _sessions[sessionId] = new Session(dcpId, sessionId, runningProject, outputReader); } - } - finally - { - if (Interlocked.Decrement(ref _pendingSessionInitializationCount) == 0 && _isDisposed) - { - _postDisposalSessionInitializationCompleted.Release(); - } - } - _logger.LogDebug("[#{SessionId}] Session started", sessionId); + _logger.LogDebug("[#{SessionId}] Session started", sessionId); - async Task StartChannelReader(CancellationToken cancellationToken) - { - try + async Task StartChannelReader(CancellationToken cancellationToken) { - await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + try { - await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken); + await foreach (var line in outputChannel.Reader.ReadAllAsync(cancellationToken)) + { + await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken); + } } - } - catch (Exception e) - { - if (!cancellationToken.IsCancellationRequested) + catch (Exception e) { - _logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e); + if (!cancellationToken.IsCancellationRequested) + { + _logger.LogError("Unexpected error reading output of session '{SessionId}': {Exception}", sessionId, e); + } } } } + finally + { + if (Interlocked.Decrement(ref _pendingSessionInitializationCount) == 0 && _isDisposed) + { + _postDisposalSessionInitializationCompleted.Release(); + } + } } /// diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs index 9f38f3af4545..756897145d2b 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/AwaitableProcess.cs @@ -140,11 +140,6 @@ public async Task GetRequiredOutputLineAsync(Predicate selector) disposalCompletionSource.Token, timeoutCancellation.Token); - if (!Debugger.IsAttached) - { - outputReadCancellation.CancelAfter(s_timeout); - } - try { while (!outputReadCancellation.IsCancellationRequested) diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs index 52ac55d6cbdf..0718b7205d0b 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireHostLauncherTests.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; -using System.CommandLine; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch.UnitTests; diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs index 101319f315b4..2d64735848d0 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/AspireLauncherTests.cs @@ -1,12 +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.Diagnostics; using System.IO.Pipes; -using System.Reflection.Metadata; using System.Text.Json; -using Elfie.Serialization; -using Xunit.Runners; namespace Microsoft.DotNet.Watch.UnitTests; diff --git a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs index 3bc100bc1255..a671cc5a2da1 100644 --- a/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs +++ b/test/Microsoft.DotNet.HotReload.Watch.Aspire.Tests/Utilities/PipeUtilities.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.ComponentModel.DataAnnotations; using System.IO.Pipes; using System.Text.Json; diff --git a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs index 477657ecc0c7..a9db6da9c8cd 100644 --- a/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs +++ b/test/dotnet-watch.Tests/HotReload/AspireHotReloadTests.cs @@ -107,7 +107,7 @@ public async Task Aspire_BuildError_ManualRestart() await App.WaitUntilOutputContains($"[WatchAspire.Web ({tfm})] Exited"); await App.WaitUntilOutputContains($"[WatchAspire.AppHost ({tfm})] Exited"); - await App.WaitUntilOutputContains("dotnet watch ⭐ Waiting for server to shutdown ..."); + await App.WaitUntilOutputContains("dotnet watch ⭐ Disposing server ..."); // TODO: these are not reliably reported: https://github.com/dotnet/sdk/issues/53308 //await App.WaitUntilOutputContains("dotnet watch ⭐ [#1] Stop session");