From 09b13e46d036baee73204e732d68c4105c5e49ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 03:41:04 +0000 Subject: [PATCH 1/4] Add separate WS and WSS ports for browser refresh server - Added DOTNET_WATCH_AUTO_RELOAD_WSS_PORT environment variable - Added AutoReloadWebSocketSecurePort property to EnvironmentOptions - Updated BrowserRefreshServer to use separate ports for HTTP and HTTPS - This allows users to avoid port conflicts when TLS is supported When TLS is available, users can now set: - DOTNET_WATCH_AUTO_RELOAD_WS_PORT for HTTP (e.g., 8081) - DOTNET_WATCH_AUTO_RELOAD_WSS_PORT for HTTPS (e.g., 8082) Co-authored-by: dbreshears <3432571+dbreshears@users.noreply.github.com> --- src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs | 4 +++- src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs | 1 + src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs | 2 ++ src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs index 8d139846350a..8fbd5c05ee88 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs @@ -28,6 +28,7 @@ internal sealed class BrowserRefreshServer( string dotnetPath, string? autoReloadWebSocketHostName, int? autoReloadWebSocketPort, + int? autoReloadWebSocketSecurePort, bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { @@ -38,10 +39,11 @@ protected override async ValueTask CreateAndStartHostAsync(Cancel { var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; var port = autoReloadWebSocketPort ?? 0; + var securePort = autoReloadWebSocketSecurePort ?? 0; var supportsTls = await KestrelWebSocketServer.IsTlsSupportedAsync(dotnetPath, suppressTimeouts, cancellationToken); var server = new KestrelWebSocketServer(Logger, WebSocketRequestAsync); - await server.StartServerAsync(hostName, port, supportsTls ? 0 : null, cancellationToken); + await server.StartServerAsync(hostName, port, supportsTls ? securePort : null, cancellationToken); // URLs are only available after the server has started. return new WebServerHost(server, GetServerUrls(server.ServerUrls), virtualDirectory: "/"); diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs index 2460d27a79e7..734e6a3fbbae 100644 --- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs @@ -60,6 +60,7 @@ private static string GetMiddlewareAssemblyPath() dotnetPath: context.EnvironmentOptions.MuxerPath, autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, autoReloadWebSocketPort: context.EnvironmentOptions.AutoReloadWebSocketPort, + autoReloadWebSocketSecurePort: context.EnvironmentOptions.AutoReloadWebSocketSecurePort, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs index 016c774130bf..f11358818724 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs @@ -39,6 +39,7 @@ internal sealed record EnvironmentOptions( LogLevel? CliLogLevel = null, string? AutoReloadWebSocketHostName = null, int? AutoReloadWebSocketPort = null, + int? AutoReloadWebSocketSecurePort = null, string? BrowserPath = null, int AgentWebSocketPort = 0, int? AgentWebSocketSecurePort = null, @@ -60,6 +61,7 @@ internal sealed record EnvironmentOptions( CliLogLevel: EnvironmentVariables.CliLogLevel, AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, AutoReloadWebSocketPort: EnvironmentVariables.AutoReloadWSPort, + AutoReloadWebSocketSecurePort: EnvironmentVariables.AutoReloadWSSPort, BrowserPath: EnvironmentVariables.BrowserPath, AgentWebSocketPort: EnvironmentVariables.AgentWebSocketPort, AgentWebSocketSecurePort: EnvironmentVariables.AgentWebSocketSecurePort, diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs index d9dde6c02640..0b0d2548ee56 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs @@ -61,6 +61,7 @@ public static LogLevel? CliLogLevel public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); public static int? AutoReloadWSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT"); + public static int? AutoReloadWSSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WSS_PORT"); public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); /// From bf47bf0e712f79f556176b9f31974277b109dcad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:58:45 +0000 Subject: [PATCH 2/4] Rename AutoReloadWebSocket* properties to BrowserWebSocket* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed properties and variables as requested: - AutoReloadWebSocketHostName → BrowserWebSocketHostName - AutoReloadWebSocketPort → BrowserWebSocketPort - AutoReloadWebSocketSecurePort → BrowserWebSocketSecurePort Updated in: - EnvironmentOptions.cs (property definitions and FromEnvironment) - BrowserRefreshServer.cs (constructor parameters and local variables) - WebApplicationAppModel.cs (property usage) Co-authored-by: tmat <41759+tmat@users.noreply.github.com> --- .../HotReloadClient/Web/BrowserRefreshServer.cs | 14 +++++++------- .../Watch/AppModels/WebApplicationAppModel.cs | 6 +++--- .../Watch/Context/EnvironmentOptions.cs | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs index 8fbd5c05ee88..e1234848917b 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs @@ -26,9 +26,9 @@ internal sealed class BrowserRefreshServer( ILoggerFactory loggerFactory, string middlewareAssemblyPath, string dotnetPath, - string? autoReloadWebSocketHostName, - int? autoReloadWebSocketPort, - int? autoReloadWebSocketSecurePort, + string? webSocketHostName, + int? webSocketPort, + int? webSocketSecurePort, bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { @@ -37,9 +37,9 @@ protected override bool SuppressTimeouts protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; - var port = autoReloadWebSocketPort ?? 0; - var securePort = autoReloadWebSocketSecurePort ?? 0; + var hostName = webSocketHostName ?? "127.0.0.1"; + var port = webSocketPort ?? 0; + var securePort = webSocketSecurePort ?? 0; var supportsTls = await KestrelWebSocketServer.IsTlsSupportedAsync(dotnetPath, suppressTimeouts, cancellationToken); var server = new KestrelWebSocketServer(Logger, WebSocketRequestAsync); @@ -52,7 +52,7 @@ protected override async ValueTask CreateAndStartHostAsync(Cancel private ImmutableArray GetServerUrls(ImmutableArray serverUrls) { Debug.Assert(serverUrls.Length > 0); - return [.. serverUrls.Select(s => KestrelWebSocketServer.GetWebSocketUrl(s, autoReloadWebSocketHostName))]; + return [.. serverUrls.Select(s => KestrelWebSocketServer.GetWebSocketUrl(s, webSocketHostName))]; } private async Task WebSocketRequestAsync(HttpContext context) diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs index 734e6a3fbbae..42d0ce8295cc 100644 --- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs @@ -58,9 +58,9 @@ private static string GetMiddlewareAssemblyPath() context.LoggerFactory, middlewareAssemblyPath: GetMiddlewareAssemblyPath(), dotnetPath: context.EnvironmentOptions.MuxerPath, - autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, - autoReloadWebSocketPort: context.EnvironmentOptions.AutoReloadWebSocketPort, - autoReloadWebSocketSecurePort: context.EnvironmentOptions.AutoReloadWebSocketSecurePort, + browserWebSocketHostName: context.EnvironmentOptions.BrowserWebSocketHostName, + browserWebSocketPort: context.EnvironmentOptions.BrowserWebSocketPort, + browserWebSocketSecurePort: context.EnvironmentOptions.BrowserWebSocketSecurePort, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs index f11358818724..df4707b17d0d 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs @@ -37,9 +37,9 @@ internal sealed record EnvironmentOptions( bool SuppressEmojis = false, bool RestartOnRudeEdit = false, LogLevel? CliLogLevel = null, - string? AutoReloadWebSocketHostName = null, - int? AutoReloadWebSocketPort = null, - int? AutoReloadWebSocketSecurePort = null, + string? BrowserWebSocketHostName = null, + int? BrowserWebSocketPort = null, + int? BrowserWebSocketSecurePort = null, string? BrowserPath = null, int AgentWebSocketPort = 0, int? AgentWebSocketSecurePort = null, @@ -59,9 +59,9 @@ internal sealed record EnvironmentOptions( SuppressEmojis: EnvironmentVariables.SuppressEmojis, RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, CliLogLevel: EnvironmentVariables.CliLogLevel, - AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, - AutoReloadWebSocketPort: EnvironmentVariables.AutoReloadWSPort, - AutoReloadWebSocketSecurePort: EnvironmentVariables.AutoReloadWSSPort, + BrowserWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, + BrowserWebSocketPort: EnvironmentVariables.AutoReloadWSPort, + BrowserWebSocketSecurePort: EnvironmentVariables.AutoReloadWSSPort, BrowserPath: EnvironmentVariables.BrowserPath, AgentWebSocketPort: EnvironmentVariables.AgentWebSocketPort, AgentWebSocketSecurePort: EnvironmentVariables.AgentWebSocketSecurePort, From 81e5378b5b851b70bf4635071c7d978b8a2acf18 Mon Sep 17 00:00:00 2001 From: tmat Date: Fri, 20 Feb 2026 17:03:59 -0800 Subject: [PATCH 3/4] Streamline web socket config, KestrelWebSocketServer impl --- .../Web/BrowserRefreshServer.cs | 22 +- .../Web/KestrelWebSocketServer.cs | 125 ++++----- .../HotReloadClient/Web/WebSocketConfig.cs | 39 +++ .../WebSocketClientTransport.cs | 246 +++++++++--------- .../Watch/AppModels/MobileAppModel.cs | 3 +- .../Watch/AppModels/WebApplicationAppModel.cs | 4 +- .../Watch/Context/EnvironmentOptions.cs | 15 +- .../Watch/Context/EnvironmentVariables.cs | 16 +- .../Web/KestrelWebSocketServerTests.cs | 21 ++ 9 files changed, 258 insertions(+), 233 deletions(-) create mode 100644 src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs create mode 100644 test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs diff --git a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs index e1234848917b..bc5cbc67d007 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/BrowserRefreshServer.cs @@ -26,9 +26,7 @@ internal sealed class BrowserRefreshServer( ILoggerFactory loggerFactory, string middlewareAssemblyPath, string dotnetPath, - string? webSocketHostName, - int? webSocketPort, - int? webSocketSecurePort, + WebSocketConfig webSocketConfig, bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { @@ -37,22 +35,16 @@ protected override bool SuppressTimeouts protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - var hostName = webSocketHostName ?? "127.0.0.1"; - var port = webSocketPort ?? 0; - var securePort = webSocketSecurePort ?? 0; var supportsTls = await KestrelWebSocketServer.IsTlsSupportedAsync(dotnetPath, suppressTimeouts, cancellationToken); + if (!supportsTls) + { + webSocketConfig = webSocketConfig.WithSecurePort(null); + } - var server = new KestrelWebSocketServer(Logger, WebSocketRequestAsync); - await server.StartServerAsync(hostName, port, supportsTls ? securePort : null, cancellationToken); + var server = await KestrelWebSocketServer.StartServerAsync(webSocketConfig, WebSocketRequestAsync, cancellationToken); // URLs are only available after the server has started. - return new WebServerHost(server, GetServerUrls(server.ServerUrls), virtualDirectory: "/"); - } - - private ImmutableArray GetServerUrls(ImmutableArray serverUrls) - { - Debug.Assert(serverUrls.Length > 0); - return [.. serverUrls.Select(s => KestrelWebSocketServer.GetWebSocketUrl(s, webSocketHostName))]; + return new WebServerHost(server, server.ServerUrls, virtualDirectory: "/"); } private async Task WebSocketRequestAsync(HttpContext context) diff --git a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs index 41d0522f5715..601787cbbaff 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs @@ -26,55 +26,15 @@ namespace Microsoft.DotNet.HotReload; /// Sealed WebSocket server using Kestrel. /// Uses a request handler delegate for all WebSocket handling. /// -internal sealed class KestrelWebSocketServer : IDisposable +internal sealed class KestrelWebSocketServer(IHost host, ImmutableArray serverUrls) : IDisposable { - private readonly RequestDelegate _requestHandler; - private readonly ILogger _logger; - - private IHost? _host; - public ImmutableArray ServerUrls { get; private set; } = []; - - public KestrelWebSocketServer(ILogger logger, RequestDelegate requestHandler) - { - _logger = logger; - _requestHandler = requestHandler; - } - - public void Dispose() - { - _host?.Dispose(); - } - private static bool? s_lazyTlsSupported; - /// - /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet. - /// - public static async ValueTask IsTlsSupportedAsync(string dotnetPath, bool suppressTimeouts, CancellationToken cancellationToken) - { - var result = s_lazyTlsSupported; - if (result.HasValue) - { - return result.Value; - } - - try - { - using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); - await process - .WaitForExitAsync(cancellationToken) - .WaitAsync(suppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); - - result = process.ExitCode == 0; - } - catch - { - result = false; - } + public void Dispose() + => host.Dispose(); - s_lazyTlsSupported = result; - return result.Value; - } + public ImmutableArray ServerUrls + => serverUrls; /// /// Starts the Kestrel WebSocket server. @@ -83,68 +43,71 @@ await process /// 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 async ValueTask StartServerAsync(string hostName, int port, int? securePort, CancellationToken cancellationToken) + public static async ValueTask StartServerAsync(WebSocketConfig config, RequestDelegate requestHandler, CancellationToken cancellationToken) { - if (_host != null) - { - throw new InvalidOperationException("Server already started"); - } - - _host = new HostBuilder() + var host = new HostBuilder() .ConfigureWebHost(builder => { builder.UseKestrel(); - - if (securePort.HasValue) - { - builder.UseUrls($"http://{hostName}:{port}", $"https://{hostName}:{securePort.Value}"); - } - else - { - builder.UseUrls($"http://{hostName}:{port}"); - } + builder.UseUrls([.. config.GetHttpUrls()]); builder.Configure(app => { app.UseWebSockets(); - app.Run(_requestHandler); + app.Run(requestHandler); }); }) .Build(); - await _host.StartAsync(cancellationToken); + await host.StartAsync(cancellationToken); // URLs are only available after the server has started. - var addresses = _host.Services + var addresses = host.Services .GetRequiredService() .Features .Get()? - .Addresses; + .Addresses ?? []; - if (addresses != null) - { - ServerUrls = [.. addresses]; - } - - _logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", ServerUrls.Select(url => GetWebSocketUrl(url)))); + return new KestrelWebSocketServer(host, serverUrls: [.. addresses.Select(GetWebSocketUrl)]); } /// - /// Converts an HTTP(S) URL to a WebSocket URL. - /// When is not specified, also replaces 127.0.0.1 with localhost. + /// Converts an HTTP(S) URL to a WebSocket URL and replaces 127.0.0.1 with localhost. /// - internal static string GetWebSocketUrl(string httpUrl, string? hostName = null) + internal static string GetWebSocketUrl(string httpUrl) + => httpUrl + .Replace("http://127.0.0.1:", "ws://localhost:", StringComparison.Ordinal) + .Replace("https://127.0.0.1:", "wss://localhost:", StringComparison.Ordinal) + .Replace("https://", "wss://", StringComparison.Ordinal) + .Replace("http://", "ws://", StringComparison.Ordinal); + + /// + /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet. + /// + public static async ValueTask IsTlsSupportedAsync(string dotnetPath, bool suppressTimeouts, CancellationToken cancellationToken) { - if (hostName is null) + var result = s_lazyTlsSupported; + if (result.HasValue) { - return httpUrl - .Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) - .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal); + return result.Value; } - return httpUrl - .Replace("https://", "wss://", StringComparison.Ordinal) - .Replace("http://", "ws://", StringComparison.Ordinal); + try + { + using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); + await process + .WaitForExitAsync(cancellationToken) + .WaitAsync(suppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); + + result = process.ExitCode == 0; + } + catch + { + result = false; + } + + s_lazyTlsSupported = result; + return result.Value; } } diff --git a/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs b/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs new file mode 100644 index 000000000000..66887d28b348 --- /dev/null +++ b/src/Dotnet.Watch/HotReloadClient/Web/WebSocketConfig.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct WebSocketConfig(int port, int? securePort, string? hostName) +{ + /// + /// 0 to auto-assign. + /// + public int Port => port; + + /// + /// 0 to auto-assign, null to disable HTTPS/WSS. + /// + public int? SecurePort => securePort; + + // Use 127.0.0.1 instead of "localhost" because Kestrel doesn't support dynamic port binding with "localhost". + // System.InvalidOperationException: Dynamic port binding is not supported when binding to localhost. + // You must either bind to 127.0.0.1:0 or [::1]:0, or both. + public string HostName => hostName ?? "127.0.0.1"; + + public IEnumerable GetHttpUrls() + { + yield return $"http://{HostName}:{Port}"; + + if (SecurePort.HasValue) + { + yield return $"https://{HostName}:{SecurePort.Value}"; + } + } + + public WebSocketConfig WithSecurePort(int? value) + => new(port, value, hostName); +} diff --git a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs index b10f5aa63674..91bff33e95e0 100644 --- a/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs +++ b/src/Dotnet.Watch/HotReloadClient/WebSocketClientTransport.cs @@ -28,178 +28,186 @@ namespace Microsoft.DotNet.HotReload; /// internal sealed class WebSocketClientTransport : ClientTransport { - private readonly ILogger _logger; private readonly KestrelWebSocketServer _server; - private readonly SharedSecretProvider _sharedSecretProvider = new(); - private readonly TaskCompletionSource _clientConnectedSource = new(); + private readonly RequestHandler _handler; - private WebSocket? _clientSocket; - - // Reused across WriteAsync calls to avoid allocations. - // WriteAsync is invoked under a semaphore in DefaultHotReloadClient. - private MemoryStream? _sendBuffer; + private WebSocketClientTransport(KestrelWebSocketServer server, RequestHandler handler) + { + _server = server; + _handler = handler; + } - private WebSocketClientTransport(ILogger logger) + public override void Dispose() { - _logger = logger; - _server = new KestrelWebSocketServer(logger, HandleRequestAsync); + _server.Dispose(); + _handler.Dispose(); } /// /// Creates and starts a new instance. /// - public static async Task CreateAsync(int port, int? securePort, ILogger logger, CancellationToken cancellationToken) + public static async Task CreateAsync(WebSocketConfig config, ILogger logger, CancellationToken cancellationToken) { - var transport = new WebSocketClientTransport(logger); - - // Start Kestrel server with WebSocket support. - // Use 127.0.0.1 instead of "localhost" because Kestrel doesn't support dynamic port binding with "localhost". - // System.InvalidOperationException: Dynamic port binding is not supported when binding to localhost. - // You must either bind to 127.0.0.1:0 or [::1]:0, or both. - await transport._server.StartServerAsync("127.0.0.1", port, securePort: securePort, cancellationToken); + var handler = new RequestHandler(logger); + var server = await KestrelWebSocketServer.StartServerAsync(config, handler.HandleRequestAsync, cancellationToken); + var transport = new WebSocketClientTransport(server, handler); + logger.LogDebug("WebSocket server started at: {Urls}", string.Join(", ", server.ServerUrls)); return transport; } - /// - /// The bound port number, for testing. Only valid after server has started. - /// - internal int Port => new Uri(_server.ServerUrls.Select(url => KestrelWebSocketServer.GetWebSocketUrl(url)).First()).Port; - public override void ConfigureEnvironment(IDictionary env) { // Set the WebSocket endpoint for the app to connect to. // Use the actual bound URL from the server (important when port 0 was requested). - env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint] = _server.ServerUrls.Select(url => KestrelWebSocketServer.GetWebSocketUrl(url)).First(); + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketEndpoint] = _server.ServerUrls.First(); // Set the RSA public key for the client to encrypt its shared secret. // This is the same authentication mechanism used by BrowserRefreshServer. - env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey] = _sharedSecretProvider.GetPublicKey(); + env[AgentEnvironmentVariables.DotNetWatchHotReloadWebSocketKey] = _handler.SharedSecretProvider.GetPublicKey(); } - private async Task HandleRequestAsync(HttpContext context) + public override Task WaitForConnectionAsync(CancellationToken cancellationToken) + => _handler.ClientConnectedSource.Task.WaitAsync(cancellationToken); + + public override ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + => _handler.WriteAsync(type, writePayload, cancellationToken); + + public override ValueTask ReadAsync(CancellationToken cancellationToken) + => _handler.ReadAsync(cancellationToken); + + private sealed class RequestHandler(ILogger logger) : IDisposable { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } + public SharedSecretProvider SharedSecretProvider { get; } = new(); + public TaskCompletionSource ClientConnectedSource { get; } = new(); - // Validate the shared secret from the subprotocol - string? subProtocol = context.WebSockets.WebSocketRequestedProtocols is [var sp] ? sp : null; + private WebSocket? _clientSocket; - if (subProtocol == null) - { - _logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)"); - context.Response.StatusCode = 401; - return; - } + // Reused across WriteAsync calls to avoid allocations. + // WriteAsync is invoked under a semaphore in DefaultHotReloadClient. + private MemoryStream? _sendBuffer; - // Decrypt and validate the secret - try + public void Dispose() { - _sharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)); + logger.LogDebug("Disposing agent websocket transport"); + + _sendBuffer?.Dispose(); + _clientSocket?.Dispose(); + SharedSecretProvider.Dispose(); } - catch (Exception ex) + + public async Task HandleRequestAsync(HttpContext context) { - _logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message); - context.Response.StatusCode = 401; - return; - } + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } - var webSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + // Validate the shared secret from the subprotocol + string? subProtocol = context.WebSockets.WebSocketRequestedProtocols is [var sp] ? sp : null; - _logger.LogDebug("WebSocket client connected"); + if (subProtocol == null) + { + logger.LogWarning("WebSocket connection rejected: missing subprotocol (shared secret)"); + context.Response.StatusCode = 401; + return; + } - _clientSocket = webSocket; - _clientConnectedSource.TrySetResult(webSocket); + // Decrypt and validate the secret + try + { + SharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)); + } + catch (Exception ex) + { + logger.LogWarning("WebSocket connection rejected: invalid shared secret - {Message}", ex.Message); + context.Response.StatusCode = 401; + return; + } - // Keep the request alive until the connection is closed or aborted - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); - } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) - { - // Expected when the client disconnects or the request is aborted - } + var webSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); - _logger.LogDebug("WebSocket client disconnected"); - } + logger.LogDebug("WebSocket client connected"); - public override Task WaitForConnectionAsync(CancellationToken cancellationToken) => - _clientConnectedSource.Task.WaitAsync(cancellationToken); + _clientSocket = webSocket; + ClientConnectedSource.TrySetResult(webSocket); - public override async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) - { - if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) - { - throw new InvalidOperationException("No active WebSocket connection from the client."); + // Keep the request alive until the connection is closed or aborted + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + // Expected when the client disconnects or the request is aborted + } + + logger.LogDebug("WebSocket client disconnected"); } - // Serialize the complete message to a reusable buffer, then send as a single WebSocket message - _sendBuffer ??= new MemoryStream(); - _sendBuffer.SetLength(0); + public async ValueTask WriteAsync(byte type, Func? writePayload, CancellationToken cancellationToken) + { + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + throw new InvalidOperationException("No active WebSocket connection from the client."); + } - await _sendBuffer.WriteAsync(type, cancellationToken); + // Serialize the complete message to a reusable buffer, then send as a single WebSocket message + _sendBuffer ??= new MemoryStream(); + _sendBuffer.SetLength(0); - if (writePayload != null) - { - await writePayload(_sendBuffer, cancellationToken); - } + await _sendBuffer.WriteAsync(type, cancellationToken); - await _clientSocket.SendAsync( - new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), - WebSocketMessageType.Binary, - endOfMessage: true, - cancellationToken); - } + if (writePayload != null) + { + await writePayload(_sendBuffer, cancellationToken); + } - public override async ValueTask ReadAsync(CancellationToken cancellationToken) - { - if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) - { - return null; + await _clientSocket.SendAsync( + new ArraySegment(_sendBuffer.GetBuffer(), 0, (int)_sendBuffer.Length), + WebSocketMessageType.Binary, + endOfMessage: true, + cancellationToken); } - // Receive a complete WebSocket message - var buffer = ArrayPool.Shared.Rent(4096); - try + public async ValueTask ReadAsync(CancellationToken cancellationToken) { - var stream = new MemoryStream(); - WebSocketReceiveResult result; - do + if (_clientSocket == null || _clientSocket.State != WebSocketState.Open) + { + return null; + } + + // Receive a complete WebSocket message + var buffer = ArrayPool.Shared.Rent(4096); + try { - result = await _clientSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); - if (result.MessageType == WebSocketMessageType.Close) + var stream = new MemoryStream(); + WebSocketReceiveResult result; + do { - stream.Dispose(); - return null; + result = await _clientSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + stream.Dispose(); + return null; + } + stream.Write(buffer, 0, result.Count); } - stream.Write(buffer, 0, result.Count); - } - while (!result.EndOfMessage); + while (!result.EndOfMessage); - stream.Position = 0; + stream.Position = 0; - // Read the response type byte from the message - var type = (ResponseType)await stream.ReadByteAsync(cancellationToken); - return new ClientTransportResponse(type, stream, disposeStream: true); - } - finally - { - ArrayPool.Shared.Return(buffer); + // Read the response type byte from the message + var type = (ResponseType)await stream.ReadByteAsync(cancellationToken); + return new ClientTransportResponse(type, stream, disposeStream: true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } - - public override void Dispose() - { - _logger.LogDebug("Disposing agent websocket transport"); - _sendBuffer?.Dispose(); - _clientSocket?.Dispose(); - _sharedSecretProvider.Dispose(); - _server.Dispose(); - } } #endif diff --git a/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs index fa3d16509151..198f06f6f20a 100644 --- a/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/MobileAppModel.cs @@ -20,8 +20,7 @@ public override async ValueTask CreateClientsAsync(ILogger cli if (IsManagedAgentSupported(project, clientLogger)) { var transport = await WebSocketClientTransport.CreateAsync( - context.EnvironmentOptions.AgentWebSocketPort, - context.EnvironmentOptions.AgentWebSocketSecurePort, + context.EnvironmentOptions.AgentWebSocketConfig, clientLogger, cancellationToken); diff --git a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs index 42d0ce8295cc..6258919dbdd0 100644 --- a/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs +++ b/src/Dotnet.Watch/Watch/AppModels/WebApplicationAppModel.cs @@ -58,9 +58,7 @@ private static string GetMiddlewareAssemblyPath() context.LoggerFactory, middlewareAssemblyPath: GetMiddlewareAssemblyPath(), dotnetPath: context.EnvironmentOptions.MuxerPath, - browserWebSocketHostName: context.EnvironmentOptions.BrowserWebSocketHostName, - browserWebSocketPort: context.EnvironmentOptions.BrowserWebSocketPort, - browserWebSocketSecurePort: context.EnvironmentOptions.BrowserWebSocketSecurePort, + webSocketConfig: context.EnvironmentOptions.BrowserWebSocketConfig, suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs index df4707b17d0d..7a69972fac19 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch @@ -37,12 +38,9 @@ internal sealed record EnvironmentOptions( bool SuppressEmojis = false, bool RestartOnRudeEdit = false, LogLevel? CliLogLevel = null, - string? BrowserWebSocketHostName = null, - int? BrowserWebSocketPort = null, - int? BrowserWebSocketSecurePort = null, string? BrowserPath = null, - int AgentWebSocketPort = 0, - int? AgentWebSocketSecurePort = null, + WebSocketConfig BrowserWebSocketConfig = default, + WebSocketConfig AgentWebSocketConfig = default, TestFlags TestFlags = TestFlags.None, string TestOutput = "") { @@ -59,12 +57,9 @@ internal sealed record EnvironmentOptions( SuppressEmojis: EnvironmentVariables.SuppressEmojis, RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, CliLogLevel: EnvironmentVariables.CliLogLevel, - BrowserWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, - BrowserWebSocketPort: EnvironmentVariables.AutoReloadWSPort, - BrowserWebSocketSecurePort: EnvironmentVariables.AutoReloadWSSPort, BrowserPath: EnvironmentVariables.BrowserPath, - AgentWebSocketPort: EnvironmentVariables.AgentWebSocketPort, - AgentWebSocketSecurePort: EnvironmentVariables.AgentWebSocketSecurePort, + BrowserWebSocketConfig: new(EnvironmentVariables.BrowserWebSocketPort, EnvironmentVariables.BrowserWebSocketSecurePort, EnvironmentVariables.BrowserWebSocketHostName), + AgentWebSocketConfig: new(EnvironmentVariables.AgentWebSocketPort, EnvironmentVariables.AgentWebSocketSecurePort, hostName: null), TestFlags: EnvironmentVariables.TestFlags, TestOutput: EnvironmentVariables.TestOutputDir ); diff --git a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs index 0b0d2548ee56..ac4dfa5db843 100644 --- a/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs +++ b/src/Dotnet.Watch/Watch/Context/EnvironmentVariables.cs @@ -59,9 +59,19 @@ public static LogLevel? CliLogLevel public static TestFlags TestFlags => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_FLAGS") is { } value ? Enum.Parse(value) : TestFlags.None; public static string TestOutputDir => Environment.GetEnvironmentVariable("__DOTNET_WATCH_TEST_OUTPUT_DIR") ?? ""; - public static string? AutoReloadWSHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); - public static int? AutoReloadWSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT"); - public static int? AutoReloadWSSPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WSS_PORT"); + public static string? BrowserWebSocketHostName => Environment.GetEnvironmentVariable("DOTNET_WATCH_AUTO_RELOAD_WS_HOSTNAME"); + + /// + /// Port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// + public static int BrowserWebSocketPort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WS_PORT") ?? 0; + + /// + /// Secure (HTTPS/WSS) port used for browser WebSocket communication. Defaults to 0 (auto-assign) if not specified. + /// Only used if TLS is supported and enabled. + /// + public static int BrowserWebSocketSecurePort => ReadInt("DOTNET_WATCH_AUTO_RELOAD_WSS_PORT") ?? 0; + public static string? BrowserPath => Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); /// diff --git a/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs b/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs new file mode 100644 index 000000000000..dd6993ac75c7 --- /dev/null +++ b/test/dotnet-watch.Tests/Web/KestrelWebSocketServerTests.cs @@ -0,0 +1,21 @@ +// 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.HotReload; + +namespace Microsoft.DotNet.Watch.UnitTests; + +public class KestrelWebSocketServerTests +{ + [Theory] + [InlineData("http://contoso.com:10", "ws://contoso.com:10")] + [InlineData("https://contoso.com:10", "wss://contoso.com:10")] + [InlineData("http://127.0.0.10:10", "ws://127.0.0.10:10")] + [InlineData("https://127.0.0.10:10", "wss://127.0.0.10:10")] + [InlineData("http://127.0.0.1:10", "ws://localhost:10")] + [InlineData("https://127.0.0.1:10", "wss://localhost:10")] + public void Urls(string httpUrl, string wsUrl) + { + Assert.Equal(wsUrl, KestrelWebSocketServer.GetWebSocketUrl(httpUrl)); + } +} From 1c4b17c0e1adc7b70b157daefc01c45764491bbc Mon Sep 17 00:00:00 2001 From: tmat Date: Tue, 24 Feb 2026 08:08:40 -0800 Subject: [PATCH 4/4] Use UriBuilder --- .../Web/KestrelWebSocketServer.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs index 601787cbbaff..ec6033f648c6 100644 --- a/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs +++ b/src/Dotnet.Watch/HotReloadClient/Web/KestrelWebSocketServer.cs @@ -75,11 +75,20 @@ public static async ValueTask StartServerAsync(WebSocket /// Converts an HTTP(S) URL to a WebSocket URL and replaces 127.0.0.1 with localhost. /// internal static string GetWebSocketUrl(string httpUrl) - => httpUrl - .Replace("http://127.0.0.1:", "ws://localhost:", StringComparison.Ordinal) - .Replace("https://127.0.0.1:", "wss://localhost:", StringComparison.Ordinal) - .Replace("https://", "wss://", StringComparison.Ordinal) - .Replace("http://", "ws://", StringComparison.Ordinal); + { + var uri = new Uri(httpUrl, UriKind.Absolute); + var builder = new UriBuilder(uri) + { + Scheme = uri.Scheme == "https" ? "wss" : "ws" + }; + + if (builder.Host == "127.0.0.1") + { + builder.Host = "localhost"; + } + + return builder.Uri.ToString().TrimEnd('/'); + } /// /// Checks whether TLS is supported by running dotnet dev-certs https --check --quiet.