From 739dbe9c75b34db6feda76d649bc64bb63110aaa Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 09:17:21 -0700 Subject: [PATCH 01/32] Unindent --- .../dotnet-watch/Browser/BrowserConnector.cs | 417 +++++++------ .../Browser/BrowserRefreshServer.cs | 571 +++++++++--------- 2 files changed, 493 insertions(+), 495 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs index d21938b6b43c..a8c8f4a7dc20 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs @@ -8,285 +8,284 @@ using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch -{ - internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable - { - private readonly record struct ProjectKey(string projectPath, string targetFramework); +namespace Microsoft.DotNet.Watch; - // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. - private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; +internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable +{ + private readonly record struct ProjectKey(string projectPath, string targetFramework); - private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); - private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); + // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. + private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; - [GeneratedRegex(@"Now listening on: (?.*)\s*$", RegexOptions.Compiled)] - private static partial Regex GetNowListeningOnRegex(); + private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); + private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); - [GeneratedRegex(@"Login to the dashboard at (?.*)\s*$", RegexOptions.Compiled)] - private static partial Regex GetAspireDashboardUrlRegex(); + [GeneratedRegex(@"Now listening on: (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetNowListeningOnRegex(); - private readonly Lock _serversGuard = new(); - private readonly Dictionary _servers = []; + [GeneratedRegex(@"Login to the dashboard at (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetAspireDashboardUrlRegex(); - // interlocked - private ImmutableHashSet _browserLaunchAttempted = []; + private readonly Lock _serversGuard = new(); + private readonly Dictionary _servers = []; - public async ValueTask DisposeAsync() - { - BrowserRefreshServer?[] serversToDispose; + // interlocked + private ImmutableHashSet _browserLaunchAttempted = []; - lock (_serversGuard) - { - serversToDispose = _servers.Values.ToArray(); - _servers.Clear(); - } + public async ValueTask DisposeAsync() + { + BrowserRefreshServer?[] serversToDispose; - await Task.WhenAll(serversToDispose.Select(async server => - { - if (server != null) - { - await server.DisposeAsync(); - } - })); + lock (_serversGuard) + { + serversToDispose = _servers.Values.ToArray(); + _servers.Clear(); } - private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) - => new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework()); - - /// - /// A single browser refresh server is created for each project that supports browser launching. - /// When the project is rebuilt we reuse the same refresh server and browser instance. - /// Reload message is sent to the browser in that case. - /// - public async ValueTask GetOrCreateBrowserRefreshServerAsync( - ProjectGraphNode projectNode, - ProcessSpec processSpec, - EnvironmentVariablesBuilder environmentBuilder, - ProjectOptions projectOptions, - HotReloadAppModel appModel, - CancellationToken cancellationToken) + await Task.WhenAll(serversToDispose.Select(async server => { - BrowserRefreshServer? server; - bool hasExistingServer; - - var key = GetProjectKey(projectNode); - - lock (_serversGuard) + if (server != null) { - hasExistingServer = _servers.TryGetValue(key, out server); - if (!hasExistingServer) - { - server = IsServerSupported(projectNode, appModel) ? new BrowserRefreshServer(context.EnvironmentOptions, context.LoggerFactory) : null; - _servers.Add(key, server); - } + await server.DisposeAsync(); } + })); + } - // Attach trigger to the process that detects when the web server reports to the output that it's listening. - // Launches browser on the URL found in the process output for root projects. - processSpec.OnOutput += GetBrowserLaunchTrigger(projectNode, projectOptions, server, cancellationToken); + private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) + => new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework()); + + /// + /// A single browser refresh server is created for each project that supports browser launching. + /// When the project is rebuilt we reuse the same refresh server and browser instance. + /// Reload message is sent to the browser in that case. + /// + public async ValueTask GetOrCreateBrowserRefreshServerAsync( + ProjectGraphNode projectNode, + ProcessSpec processSpec, + EnvironmentVariablesBuilder environmentBuilder, + ProjectOptions projectOptions, + HotReloadAppModel appModel, + CancellationToken cancellationToken) + { + BrowserRefreshServer? server; + bool hasExistingServer; - if (server == null) - { - // browser refresh server isn't supported - return null; - } + var key = GetProjectKey(projectNode); + lock (_serversGuard) + { + hasExistingServer = _servers.TryGetValue(key, out server); if (!hasExistingServer) { - // Start the server we just created: - await server.StartAsync(cancellationToken); + server = IsServerSupported(projectNode, appModel) ? new BrowserRefreshServer(context.EnvironmentOptions, context.LoggerFactory) : null; + _servers.Add(key, server); } - - server.SetEnvironmentVariables(environmentBuilder); - - return server; } - public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) - { - var key = GetProjectKey(projectNode); + // Attach trigger to the process that detects when the web server reports to the output that it's listening. + // Launches browser on the URL found in the process output for root projects. + processSpec.OnOutput += GetBrowserLaunchTrigger(projectNode, projectOptions, server, cancellationToken); - lock (_serversGuard) - { - return _servers.TryGetValue(key, out server) && server != null; - } + if (server == null) + { + // browser refresh server isn't supported + return null; } - /// - /// Get process output handler that will be subscribed to the process output event every time the process is launched. - /// - public Action? GetBrowserLaunchTrigger(ProjectGraphNode projectNode, ProjectOptions projectOptions, BrowserRefreshServer? server, CancellationToken cancellationToken) + if (!hasExistingServer) { - if (!CanLaunchBrowser(context, projectNode, projectOptions, out var launchProfile)) - { - if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) - { - context.Logger.LogError("Test requires browser to launch"); - } + // Start the server we just created: + await server.StartAsync(cancellationToken); + } - return null; - } + server.SetEnvironmentVariables(environmentBuilder); - bool matchFound = false; + return server; + } - // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. - // TODO: Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. - var isAspireHost = projectNode.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); + public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) + { + var key = GetProjectKey(projectNode); - return handler; + lock (_serversGuard) + { + return _servers.TryGetValue(key, out server) && server != null; + } + } - void handler(OutputLine line) + /// + /// Get process output handler that will be subscribed to the process output event every time the process is launched. + /// + public Action? GetBrowserLaunchTrigger(ProjectGraphNode projectNode, ProjectOptions projectOptions, BrowserRefreshServer? server, CancellationToken cancellationToken) + { + if (!CanLaunchBrowser(context, projectNode, projectOptions, out var launchProfile)) + { + if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { - if (matchFound) - { - return; - } - - var match = (isAspireHost ? s_aspireDashboardUrlRegex : s_nowListeningRegex).Match(line.Content); - if (!match.Success) - { - return; - } - - matchFound = true; - - if (projectOptions.IsRootProject && - ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), GetProjectKey(projectNode))) - { - // first build iteration of a root project: - var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, match.Groups["url"].Value); - LaunchBrowser(launchUrl, server); - } - else if (server != null) - { - // Subsequent iterations (project has been rebuilt and relaunched). - // Use refresh server to reload the browser, if available. - context.Logger.LogDebug("Reloading browser."); - _ = server.SendReloadMessageAsync(cancellationToken); - } + context.Logger.LogError("Test requires browser to launch"); } + + return null; } - public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl) - => string.IsNullOrWhiteSpace(profileLaunchUrl) ? outputLaunchUrl : - Uri.TryCreate(profileLaunchUrl, UriKind.Absolute, out _) ? profileLaunchUrl : - Uri.TryCreate(outputLaunchUrl, UriKind.Absolute, out var launchUri) ? new Uri(launchUri, profileLaunchUrl).ToString() : - outputLaunchUrl; + bool matchFound = false; - private void LaunchBrowser(string launchUrl, BrowserRefreshServer? server) - { - var fileName = launchUrl; + // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. + // TODO: Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. + var isAspireHost = projectNode.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); + + return handler; - var args = string.Empty; - if (EnvironmentVariables.BrowserPath is { } browserPath) + void handler(OutputLine line) + { + if (matchFound) { - args = fileName; - fileName = browserPath; + return; } - context.Logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args); - - if (context.EnvironmentOptions.TestFlags != TestFlags.None) + var match = (isAspireHost ? s_aspireDashboardUrlRegex : s_nowListeningRegex).Match(line.Content); + if (!match.Success) { - if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) - { - Debug.Assert(server != null); - server.EmulateClientConnected(); - } - return; } - var info = new ProcessStartInfo - { - FileName = fileName, - Arguments = args, - UseShellExecute = true, - }; + matchFound = true; - try + if (projectOptions.IsRootProject && + ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), GetProjectKey(projectNode))) { - using var browserProcess = Process.Start(info); - if (browserProcess is null or { HasExited: true }) - { - // dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well - // where URLs are associated with the default browser. On Linux, this is a bit murky. - // From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value - // or for the process to have immediately exited. - // We can use this to provide a helpful message. - context.Logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl); - } + // first build iteration of a root project: + var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, match.Groups["url"].Value); + LaunchBrowser(launchUrl, server); } - catch (Exception e) + else if (server != null) { - context.Logger.LogDebug("Failed to launch a browser: {Message}", e.Message); + // Subsequent iterations (project has been rebuilt and relaunched). + // Use refresh server to reload the browser, if available. + context.Logger.LogDebug("Reloading browser."); + _ = server.SendReloadMessageAsync(cancellationToken); } } + } + + public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl) + => string.IsNullOrWhiteSpace(profileLaunchUrl) ? outputLaunchUrl : + Uri.TryCreate(profileLaunchUrl, UriKind.Absolute, out _) ? profileLaunchUrl : + Uri.TryCreate(outputLaunchUrl, UriKind.Absolute, out var launchUri) ? new Uri(launchUri, profileLaunchUrl).ToString() : + outputLaunchUrl; - private bool CanLaunchBrowser(DotNetWatchContext context, ProjectGraphNode projectNode, ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) + private void LaunchBrowser(string launchUrl, BrowserRefreshServer? server) + { + var fileName = launchUrl; + + var args = string.Empty; + if (EnvironmentVariables.BrowserPath is { } browserPath) { - var logger = context.Logger; - launchProfile = null; + args = fileName; + fileName = browserPath; + } - if (context.EnvironmentOptions.SuppressLaunchBrowser) - { - return false; - } + context.Logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args); - if (!projectNode.IsNetCoreApp(minVersion: Versions.Version3_1)) + if (context.EnvironmentOptions.TestFlags != TestFlags.None) + { + if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { - // Browser refresh middleware supports 3.1 or newer - logger.LogDebug("Browser refresh is only supported in .NET Core 3.1 or newer projects."); - return false; + Debug.Assert(server != null); + server.EmulateClientConnected(); } - if (!CommandLineOptions.IsCodeExecutionCommand(projectOptions.Command)) - { - logger.LogDebug("Command '{Command}' does not support browser refresh.", projectOptions.Command); - return false; - } + return; + } + + var info = new ProcessStartInfo + { + FileName = fileName, + Arguments = args, + UseShellExecute = true, + }; - launchProfile = GetLaunchProfile(projectOptions); - if (launchProfile is not { LaunchBrowser: true }) + try + { + using var browserProcess = Process.Start(info); + if (browserProcess is null or { HasExited: true }) { - logger.LogDebug("launchSettings does not allow launching browsers."); - return false; + // dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well + // where URLs are associated with the default browser. On Linux, this is a bit murky. + // From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value + // or for the process to have immediately exited. + // We can use this to provide a helpful message. + context.Logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl); } + } + catch (Exception e) + { + context.Logger.LogDebug("Failed to launch a browser: {Message}", e.Message); + } + } - logger.Log(MessageDescriptor.ConfiguredToLaunchBrowser); - return true; + private bool CanLaunchBrowser(DotNetWatchContext context, ProjectGraphNode projectNode, ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) + { + var logger = context.Logger; + launchProfile = null; + + if (context.EnvironmentOptions.SuppressLaunchBrowser) + { + return false; } - public bool IsServerSupported(ProjectGraphNode projectNode, HotReloadAppModel appModel) + if (!projectNode.IsNetCoreApp(minVersion: Versions.Version3_1)) { - if (context.EnvironmentOptions.SuppressBrowserRefresh) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); - return false; - } + // Browser refresh middleware supports 3.1 or newer + logger.LogDebug("Browser refresh is only supported in .NET Core 3.1 or newer projects."); + return false; + } - if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); - return false; - } + if (!CommandLineOptions.IsCodeExecutionCommand(projectOptions.Command)) + { + logger.LogDebug("Command '{Command}' does not support browser refresh.", projectOptions.Command); + return false; + } - // We only want to enable browser refresh if this is a WebApp (ASP.NET Core / Blazor app). - if (!projectNode.IsWebApp()) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_NotWebApp.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); - return false; - } + launchProfile = GetLaunchProfile(projectOptions); + if (launchProfile is not { LaunchBrowser: true }) + { + logger.LogDebug("launchSettings does not allow launching browsers."); + return false; + } + + logger.Log(MessageDescriptor.ConfiguredToLaunchBrowser); + return true; + } + + public bool IsServerSupported(ProjectGraphNode projectNode, HotReloadAppModel appModel) + { + if (context.EnvironmentOptions.SuppressBrowserRefresh) + { + context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + return false; + } - context.Logger.Log(MessageDescriptor.ConfiguredToUseBrowserRefresh); - return true; + if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) + { + context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); + return false; } - private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) + // We only want to enable browser refresh if this is a WebApp (ASP.NET Core / Blazor app). + if (!projectNode.IsWebApp()) { - return (projectOptions.NoLaunchProfile == true - ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, context.Logger)) ?? new(); + context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_NotWebApp.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); + return false; } + + context.Logger.Log(MessageDescriptor.ConfiguredToUseBrowserRefresh); + return true; + } + + private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) + { + return (projectOptions.NoLaunchProfile == true + ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, context.Logger)) ?? new(); } } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index a56aa4ffe115..b2c7bc181192 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -17,389 +17,388 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch +namespace Microsoft.DotNet.Watch; + +/// +/// Communicates with aspnetcore-browser-refresh.js loaded in the browser. +/// Associated with a project instance. +/// +internal sealed class BrowserRefreshServer : IAsyncDisposable { - /// - /// Communicates with aspnetcore-browser-refresh.js loaded in the browser. - /// Associated with a project instance. - /// - internal sealed class BrowserRefreshServer : IAsyncDisposable - { - public const string ServerLogComponentName = nameof(BrowserRefreshServer); - - private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); - private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); + public const string ServerLogComponentName = nameof(BrowserRefreshServer); + + private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); + private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); + + private static bool? s_lazyTlsSupported; - private static bool? s_lazyTlsSupported; + private readonly List _activeConnections = []; + private readonly RSA _rsa; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + private readonly TaskCompletionSource _terminateWebSocket; + private readonly TaskCompletionSource _browserConnected; + private readonly string? _environmentHostName; - private readonly List _activeConnections = []; - private readonly RSA _rsa; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly TaskCompletionSource _terminateWebSocket; - private readonly TaskCompletionSource _browserConnected; - private readonly string? _environmentHostName; + // initialized by StartAsync + private IHost? _refreshServer; + private string? _serverUrls; - // initialized by StartAsync - private IHost? _refreshServer; - private string? _serverUrls; + public readonly EnvironmentOptions Options; - public readonly EnvironmentOptions Options; + public BrowserRefreshServer(EnvironmentOptions options, ILoggerFactory loggerFactory) + { + _rsa = RSA.Create(2048); + Options = options; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(ServerLogComponentName); + _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _environmentHostName = EnvironmentVariables.AutoReloadWSHostName; + } + + public async ValueTask DisposeAsync() + { + _rsa.Dispose(); - public BrowserRefreshServer(EnvironmentOptions options, ILoggerFactory loggerFactory) + BrowserConnection[] connectionsToDispose; + lock (_activeConnections) { - _rsa = RSA.Create(2048); - Options = options; - _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(ServerLogComponentName); - _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _environmentHostName = EnvironmentVariables.AutoReloadWSHostName; + connectionsToDispose = [.. _activeConnections]; + _activeConnections.Clear(); } - public async ValueTask DisposeAsync() + foreach (var connection in connectionsToDispose) { - _rsa.Dispose(); - - BrowserConnection[] connectionsToDispose; - lock (_activeConnections) - { - connectionsToDispose = [.. _activeConnections]; - _activeConnections.Clear(); - } - - foreach (var connection in connectionsToDispose) - { - connection.ServerLogger.LogDebug("Disconnecting from browser."); - await connection.DisposeAsync(); - } + connection.ServerLogger.LogDebug("Disconnecting from browser."); + await connection.DisposeAsync(); + } - _refreshServer?.Dispose(); + _refreshServer?.Dispose(); - _terminateWebSocket.TrySetResult(); - } + _terminateWebSocket.TrySetResult(); + } - public void SetEnvironmentVariables(EnvironmentVariablesBuilder environmentBuilder) - { - Debug.Assert(_refreshServer != null); - Debug.Assert(_serverUrls != null); + public void SetEnvironmentVariables(EnvironmentVariablesBuilder environmentBuilder) + { + Debug.Assert(_refreshServer != null); + Debug.Assert(_serverUrls != null); - environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _serverUrls); - environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey, GetServerKey()); + environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _serverUrls); + environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey, GetServerKey()); - environmentBuilder.DotNetStartupHooks.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll")); - environmentBuilder.AspNetCoreHostingStartupAssemblies.Add("Microsoft.AspNetCore.Watch.BrowserRefresh"); + environmentBuilder.DotNetStartupHooks.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll")); + environmentBuilder.AspNetCoreHostingStartupAssemblies.Add("Microsoft.AspNetCore.Watch.BrowserRefresh"); - if (_logger.IsEnabled(LogLevel.Debug)) - { - // enable debug logging from middleware: - environmentBuilder.SetVariable("Logging__LogLevel__Microsoft.AspNetCore.Watch", "Debug"); - } + if (_logger.IsEnabled(LogLevel.Debug)) + { + // enable debug logging from middleware: + environmentBuilder.SetVariable("Logging__LogLevel__Microsoft.AspNetCore.Watch", "Debug"); } + } - public string GetServerKey() - => Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); + public string GetServerKey() + => Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); - public async ValueTask StartAsync(CancellationToken cancellationToken) - { - Debug.Assert(_refreshServer == null); + public async ValueTask StartAsync(CancellationToken cancellationToken) + { + Debug.Assert(_refreshServer == null); - var hostName = _environmentHostName ?? "127.0.0.1"; + var hostName = _environmentHostName ?? "127.0.0.1"; - var supportsTLS = await SupportsTlsAsync(); + var supportsTLS = await SupportsTlsAsync(); - _refreshServer = new HostBuilder() - .ConfigureWebHost(builder => + _refreshServer = new HostBuilder() + .ConfigureWebHost(builder => + { + builder.UseKestrel(); + if (supportsTLS) { - builder.UseKestrel(); - if (supportsTLS) - { - builder.UseUrls($"https://{hostName}:0", $"http://{hostName}:0"); - } - else - { - builder.UseUrls($"http://{hostName}:0"); - } - - builder.Configure(app => - { - app.UseWebSockets(); - app.Run(WebSocketRequestAsync); - }); - }) - .Build(); - - await _refreshServer.StartAsync(cancellationToken); - - var serverUrls = string.Join(',', GetServerUrls(_refreshServer)); - _logger.LogDebug("Refresh server running at {0}.", serverUrls); - _serverUrls = serverUrls; - } - - private IEnumerable GetServerUrls(IHost server) - { - var serverUrls = server.Services - .GetRequiredService() - .Features - .Get()? - .Addresses; + builder.UseUrls($"https://{hostName}:0", $"http://{hostName}:0"); + } + else + { + builder.UseUrls($"http://{hostName}:0"); + } - Debug.Assert(serverUrls != null); + builder.Configure(app => + { + app.UseWebSockets(); + app.Run(WebSocketRequestAsync); + }); + }) + .Build(); - if (_environmentHostName is null) - { - return serverUrls.Select(s => - s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) - .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal)); - } + await _refreshServer.StartAsync(cancellationToken); - return - [ - serverUrls - .First() - .Replace("https://", "wss://", StringComparison.Ordinal) - .Replace("http://", "ws://", StringComparison.Ordinal) - ]; - } + var serverUrls = string.Join(',', GetServerUrls(_refreshServer)); + _logger.LogDebug("Refresh server running at {0}.", serverUrls); + _serverUrls = serverUrls; + } - private async Task WebSocketRequestAsync(HttpContext context) - { - if (!context.WebSockets.IsWebSocketRequest) - { - context.Response.StatusCode = 400; - return; - } + private IEnumerable GetServerUrls(IHost server) + { + var serverUrls = server.Services + .GetRequiredService() + .Features + .Get()? + .Addresses; - string? subProtocol = null; - string? sharedSecret = null; - if (context.WebSockets.WebSocketRequestedProtocols.Count == 1) - { - subProtocol = context.WebSockets.WebSocketRequestedProtocols[0]; - var subProtocolBytes = Convert.FromBase64String(WebUtility.UrlDecode(subProtocol)); - sharedSecret = Convert.ToBase64String(_rsa.Decrypt(subProtocolBytes, RSAEncryptionPadding.OaepSHA256)); - } + Debug.Assert(serverUrls != null); - var clientSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); - var connection = new BrowserConnection(clientSocket, sharedSecret, _loggerFactory); + if (_environmentHostName is null) + { + return serverUrls.Select(s => + s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) + .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal)); + } - lock (_activeConnections) - { - _activeConnections.Add(connection); - } + return + [ + serverUrls + .First() + .Replace("https://", "wss://", StringComparison.Ordinal) + .Replace("http://", "ws://", StringComparison.Ordinal) + ]; + } - _browserConnected.TrySetResult(); - await _terminateWebSocket.Task; + private async Task WebSocketRequestAsync(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; } - /// - /// For testing. - /// - internal void EmulateClientConnected() + string? subProtocol = null; + string? sharedSecret = null; + if (context.WebSockets.WebSocketRequestedProtocols.Count == 1) { - _browserConnected.TrySetResult(); + subProtocol = context.WebSockets.WebSocketRequestedProtocols[0]; + var subProtocolBytes = Convert.FromBase64String(WebUtility.UrlDecode(subProtocol)); + sharedSecret = Convert.ToBase64String(_rsa.Decrypt(subProtocolBytes, RSAEncryptionPadding.OaepSHA256)); } - public async Task WaitForClientConnectionAsync(CancellationToken cancellationToken) + var clientSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); + var connection = new BrowserConnection(clientSocket, sharedSecret, _loggerFactory); + + lock (_activeConnections) { - using var progressCancellationSource = new CancellationTokenSource(); + _activeConnections.Add(connection); + } - // It make take a while to connect since the app might need to build first. - // Indicate progress in the output. Start with 60s and then report progress every 10s. - var firstReportSeconds = TimeSpan.FromSeconds(60); - var nextReportSeconds = TimeSpan.FromSeconds(10); + _browserConnected.TrySetResult(); + await _terminateWebSocket.Task; + } - var reportDelayInSeconds = firstReportSeconds; - var connectionAttemptReported = false; + /// + /// For testing. + /// + internal void EmulateClientConnected() + { + _browserConnected.TrySetResult(); + } - var progressReportingTask = Task.Run(async () => - { - while (!progressCancellationSource.Token.IsCancellationRequested) - { - await Task.Delay(Options.TestFlags != TestFlags.None ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + public async Task WaitForClientConnectionAsync(CancellationToken cancellationToken) + { + using var progressCancellationSource = new CancellationTokenSource(); - connectionAttemptReported = true; - reportDelayInSeconds = nextReportSeconds; - _logger.LogInformation("Connecting to the browser ..."); - } - }, progressCancellationSource.Token); + // It make take a while to connect since the app might need to build first. + // Indicate progress in the output. Start with 60s and then report progress every 10s. + var firstReportSeconds = TimeSpan.FromSeconds(60); + var nextReportSeconds = TimeSpan.FromSeconds(10); - try - { - await _browserConnected.Task.WaitAsync(cancellationToken); - } - finally - { - progressCancellationSource.Cancel(); - } + var reportDelayInSeconds = firstReportSeconds; + var connectionAttemptReported = false; - if (connectionAttemptReported) + var progressReportingTask = Task.Run(async () => + { + while (!progressCancellationSource.Token.IsCancellationRequested) { - _logger.LogInformation("Browser connection established."); + await Task.Delay(Options.TestFlags != TestFlags.None ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + + connectionAttemptReported = true; + reportDelayInSeconds = nextReportSeconds; + _logger.LogInformation("Connecting to the browser ..."); } + }, progressCancellationSource.Token); + + try + { + await _browserConnected.Task.WaitAsync(cancellationToken); + } + finally + { + progressCancellationSource.Cancel(); } - private IReadOnlyCollection GetOpenBrowserConnections() + if (connectionAttemptReported) { - lock (_activeConnections) - { - return [.. _activeConnections.Where(b => b.ClientSocket.State == WebSocketState.Open)]; - } + _logger.LogInformation("Browser connection established."); } + } - private async ValueTask DisposeClosedBrowserConnectionsAsync() + private IReadOnlyCollection GetOpenBrowserConnections() + { + lock (_activeConnections) { - List? lazyConnectionsToDispose = null; + return [.. _activeConnections.Where(b => b.ClientSocket.State == WebSocketState.Open)]; + } + } + + private async ValueTask DisposeClosedBrowserConnectionsAsync() + { + List? lazyConnectionsToDispose = null; - lock (_activeConnections) + lock (_activeConnections) + { + var j = 0; + for (var i = 0; i < _activeConnections.Count; i++) { - var j = 0; - for (var i = 0; i < _activeConnections.Count; i++) + var connection = _activeConnections[i]; + if (connection.ClientSocket.State == WebSocketState.Open) { - var connection = _activeConnections[i]; - if (connection.ClientSocket.State == WebSocketState.Open) - { - _activeConnections[j++] = connection; - } - else - { - lazyConnectionsToDispose ??= []; - lazyConnectionsToDispose.Add(connection); - } + _activeConnections[j++] = connection; + } + else + { + lazyConnectionsToDispose ??= []; + lazyConnectionsToDispose.Add(connection); } - - _activeConnections.RemoveRange(j, _activeConnections.Count - j); } - if (lazyConnectionsToDispose != null) + _activeConnections.RemoveRange(j, _activeConnections.Count - j); + } + + if (lazyConnectionsToDispose != null) + { + foreach (var connection in lazyConnectionsToDispose) { - foreach (var connection in lazyConnectionsToDispose) - { - await connection.DisposeAsync(); - } + await connection.DisposeAsync(); } } + } - public static ReadOnlyMemory SerializeJson(TValue value) - => JsonSerializer.SerializeToUtf8Bytes(value, s_jsonSerializerOptions); + public static ReadOnlyMemory SerializeJson(TValue value) + => JsonSerializer.SerializeToUtf8Bytes(value, s_jsonSerializerOptions); - public static TValue DeserializeJson(ReadOnlySpan value) - => JsonSerializer.Deserialize(value, s_jsonSerializerOptions) ?? throw new InvalidDataException("Unexpected null object"); + public static TValue DeserializeJson(ReadOnlySpan value) + => JsonSerializer.Deserialize(value, s_jsonSerializerOptions) ?? throw new InvalidDataException("Unexpected null object"); - public ValueTask SendJsonMessageAsync(TValue value, CancellationToken cancellationToken) - => SendAsync(SerializeJson(value), cancellationToken); + public ValueTask SendJsonMessageAsync(TValue value, CancellationToken cancellationToken) + => SendAsync(SerializeJson(value), cancellationToken); - public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) - => SendAsync(s_reloadMessage, cancellationToken); + public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) + => SendAsync(s_reloadMessage, cancellationToken); - public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) - => SendAsync(s_waitMessage, cancellationToken); + public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) + => SendAsync(s_waitMessage, cancellationToken); - private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) - => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); + private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) + => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); - public async ValueTask SendAndReceiveAsync( - Func? request, - Action, ILogger>? response, - CancellationToken cancellationToken) - { - var responded = false; + public async ValueTask SendAndReceiveAsync( + Func? request, + Action, ILogger>? response, + CancellationToken cancellationToken) + { + var responded = false; - foreach (var connection in GetOpenBrowserConnections()) + foreach (var connection in GetOpenBrowserConnections()) + { + if (request != null) { - if (request != null) - { - var requestValue = request(connection.SharedSecret); - var requestBytes = requestValue is ReadOnlyMemory bytes ? bytes : SerializeJson(requestValue); - - if (!await connection.TrySendMessageAsync(requestBytes, cancellationToken)) - { - continue; - } - } + var requestValue = request(connection.SharedSecret); + var requestBytes = requestValue is ReadOnlyMemory bytes ? bytes : SerializeJson(requestValue); - if (response != null && !await connection.TryReceiveMessageAsync(response, cancellationToken)) + if (!await connection.TrySendMessageAsync(requestBytes, cancellationToken)) { continue; } - - responded = true; } - if (!responded) + if (response != null && !await connection.TryReceiveMessageAsync(response, cancellationToken)) { - _logger.Log(MessageDescriptor.FailedToReceiveResponseFromConnectedBrowser); + continue; } - await DisposeClosedBrowserConnectionsAsync(); + responded = true; } - private async Task SupportsTlsAsync() + if (!responded) { - var result = s_lazyTlsSupported; - if (result.HasValue) - { - return result.Value; - } + _logger.Log(MessageDescriptor.FailedToReceiveResponseFromConnectedBrowser); + } - try - { - using var process = Process.Start(Options.MuxerPath, "dev-certs https --check --quiet"); - await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); - result = process.ExitCode == 0; - } - catch - { - result = false; - } + await DisposeClosedBrowserConnectionsAsync(); + } - s_lazyTlsSupported = result; + private async Task SupportsTlsAsync() + { + var result = s_lazyTlsSupported; + if (result.HasValue) + { return result.Value; } - public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) - => SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); - - public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + try { - _logger.Log(MessageDescriptor.UpdatingDiagnosticsInConnectedBrowsers); - if (compilationErrors.IsEmpty) - { - return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); - } - else - { - return SendJsonMessageAsync(new HotReloadDiagnostics { Diagnostics = compilationErrors }, cancellationToken); - } + using var process = Process.Start(Options.MuxerPath, "dev-certs https --check --quiet"); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + result = process.ExitCode == 0; } - - public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) + catch { - // Serialize all requests sent to a single server: - foreach (var relativeUrl in relativeUrls) - { - _logger.Log(MessageDescriptor.SendingStaticAssetUpdateRequest, relativeUrl); - var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions); - await SendAsync(message, cancellationToken); - } + result = false; } - private readonly struct AspNetCoreHotReloadApplied + s_lazyTlsSupported = result; + return result.Value; + } + + public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) + => SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + + public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + { + _logger.Log(MessageDescriptor.UpdatingDiagnosticsInConnectedBrowsers); + if (compilationErrors.IsEmpty) { - public string Type => "AspNetCoreHotReloadApplied"; + return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); } - - private readonly struct HotReloadDiagnostics + else { - public string Type => "HotReloadDiagnosticsv1"; - - public IEnumerable Diagnostics { get; init; } + return SendJsonMessageAsync(new HotReloadDiagnostics { Diagnostics = compilationErrors }, cancellationToken); } + } - private readonly struct UpdateStaticFileMessage + public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) + { + // Serialize all requests sent to a single server: + foreach (var relativeUrl in relativeUrls) { - public string Type => "UpdateStaticFile"; - public string Path { get; init; } + _logger.Log(MessageDescriptor.SendingStaticAssetUpdateRequest, relativeUrl); + var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions); + await SendAsync(message, cancellationToken); } } + + private readonly struct AspNetCoreHotReloadApplied + { + public string Type => "AspNetCoreHotReloadApplied"; + } + + private readonly struct HotReloadDiagnostics + { + public string Type => "HotReloadDiagnosticsv1"; + + public IEnumerable Diagnostics { get; init; } + } + + private readonly struct UpdateStaticFileMessage + { + public string Type => "UpdateStaticFile"; + public string Path { get; init; } + } } From 24689acecf2511e96d10ab493dbe0ac42ce3ffff Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 12:13:38 -0700 Subject: [PATCH 02/32] Minor improvements to browser refresh logging --- .../dotnet-watch/Browser/BrowserConnector.cs | 15 +++++++++++++-- .../Browser/BrowserRefreshServer.cs | 18 +++++++++++++----- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 3 +++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs index a8c8f4a7dc20..0bbf544f47e6 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs @@ -77,7 +77,7 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) hasExistingServer = _servers.TryGetValue(key, out server); if (!hasExistingServer) { - server = IsServerSupported(projectNode, appModel) ? new BrowserRefreshServer(context.EnvironmentOptions, context.LoggerFactory) : null; + server = TryCreateRefreshServer(projectNode, appModel); _servers.Add(key, server); } } @@ -103,6 +103,18 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) return server; } + private BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode, HotReloadAppModel appModel) + { + var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); + + if (!IsServerSupported(projectNode, appModel)) + { + return null; + } + + return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); + } + public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) { var key = GetProjectKey(projectNode); @@ -162,7 +174,6 @@ void handler(OutputLine line) { // Subsequent iterations (project has been rebuilt and relaunched). // Use refresh server to reload the browser, if available. - context.Logger.LogDebug("Reloading browser."); _ = server.SendReloadMessageAsync(cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index b2c7bc181192..ee060084bb16 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -47,12 +47,12 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable public readonly EnvironmentOptions Options; - public BrowserRefreshServer(EnvironmentOptions options, ILoggerFactory loggerFactory) + public BrowserRefreshServer(EnvironmentOptions options, ILogger logger, ILoggerFactory loggerFactory) { _rsa = RSA.Create(2048); Options = options; _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(ServerLogComponentName); + _logger = logger; _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _environmentHostName = EnvironmentVariables.AutoReloadWSHostName; @@ -290,7 +290,10 @@ public ValueTask SendJsonMessageAsync(TValue value, CancellationToken ca => SendAsync(SerializeJson(value), cancellationToken); public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) - => SendAsync(s_reloadMessage, cancellationToken); + { + _logger.Log(MessageDescriptor.ReloadingBrowser); + return SendAsync(s_reloadMessage, cancellationToken); + } public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendAsync(s_waitMessage, cancellationToken); @@ -304,8 +307,9 @@ public async ValueTask SendAndReceiveAsync( CancellationToken cancellationToken) { var responded = false; + var openConnections = GetOpenBrowserConnections(); - foreach (var connection in GetOpenBrowserConnections()) + foreach (var connection in openConnections) { if (request != null) { @@ -326,7 +330,11 @@ public async ValueTask SendAndReceiveAsync( responded = true; } - if (!responded) + if (openConnections.Count == 0) + { + _logger.Log(MessageDescriptor.NoBrowserConnected); + } + else if (response != null && !responded) { _logger.Log(MessageDescriptor.FailedToReceiveResponseFromConnectedBrowser); } diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index 37f0b1c2f2ca..88b32de5f190 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -201,6 +201,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Configuring the app to use browser-refresh middleware", Emoji.Watch, MessageSeverity.Verbose); + public static readonly MessageDescriptor ReloadingBrowser = Create("Reloading browser.", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, MessageSeverity.Verbose); @@ -230,6 +231,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create("Sending static asset update request to connected browsers: '{0}'.", Emoji.Refresh, MessageSeverity.Verbose); public static readonly MessageDescriptor UpdatingDiagnosticsInConnectedBrowsers = Create("Updating diagnostics in connected browsers.", Emoji.Refresh, MessageSeverity.Verbose); public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create("Failed to receive response from a connected browser.", Emoji.Refresh, MessageSeverity.Verbose); + public static readonly MessageDescriptor NoBrowserConnected = Create("No browser is connected.", Emoji.Refresh, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, MessageSeverity.Output); @@ -239,6 +241,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor HotReloadCanceledProcessExited = Create("Hot reload canceled because the process exited.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadProfile_BlazorHosted = Create("HotReloadProfile: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadProfile_BlazorWebAssembly = Create("HotReloadProfile: BlazorWebAssembly.", Emoji.HotReload, MessageSeverity.Verbose); + public static readonly MessageDescriptor HotReloadProfile_WebApplication = Create("HotReloadProfile: WebApplication.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadProfile_Default = Create("HotReloadProfile: Default.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, MessageSeverity.Verbose); From ba0fd12f40e559f1e93738d9d498987cdfb61ccc Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 14:20:37 -0700 Subject: [PATCH 03/32] Simplify app model inference, add WebApplicationAppModel. --- .../Aspire/AspireServiceFactory.cs | 2 +- .../dotnet-watch/Browser/BrowserConnector.cs | 34 ++---------- .../Browser/BrowserRefreshServer.cs | 1 + .../dotnet-watch/Build/BuildNames.cs | 7 +++ .../Build/ProjectGraphUtilities.cs | 22 ++++++-- .../AppModels/BlazorWebAssemblyAppModel.cs | 2 +- .../BlazorWebAssemblyHostedAppModel.cs | 2 +- .../HotReload/AppModels/HotReloadAppModel.cs | 54 +++++-------------- .../AppModels/WebApplicationAppModel.cs | 32 +++++++++++ .../HotReload/AppModels/WebServerAppModel.cs | 18 +++++++ .../HotReload/CompilationHandler.cs | 2 +- .../HotReload/ScopedCssFileHandler.cs | 2 +- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 1 - 13 files changed, 97 insertions(+), 82 deletions(-) create mode 100644 src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs create mode 100644 src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs diff --git a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs index 07836873d821..1e84fb05a408 100644 --- a/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs @@ -267,7 +267,7 @@ internal static IReadOnlyList GetRunCommandArguments(ProjectLaunchReques public static readonly AspireServiceFactory Instance = new(); public const string AspireLogComponentName = "Aspire"; - public const string AppHostProjectCapability = "Aspire"; + public const string AppHostProjectCapability = ProjectCapability.Aspire; public IRuntimeProcessLauncher? TryCreate(ProjectGraphNode projectNode, ProjectLauncher projectLauncher, ProjectOptions hostProjectOptions) => projectNode.GetCapabilities().Contains(AppHostProjectCapability) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs index 0bbf544f47e6..95908b8d75fd 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs @@ -14,9 +14,6 @@ internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAs { private readonly record struct ProjectKey(string projectPath, string targetFramework); - // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. - private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; - private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); @@ -107,12 +104,12 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) { var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); - if (!IsServerSupported(projectNode, appModel)) + if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, context.EnvironmentOptions, logger)) { - return null; + return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); } - return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); + return null; } public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) @@ -269,31 +266,6 @@ private bool CanLaunchBrowser(DotNetWatchContext context, ProjectGraphNode proje return true; } - public bool IsServerSupported(ProjectGraphNode projectNode, HotReloadAppModel appModel) - { - if (context.EnvironmentOptions.SuppressBrowserRefresh) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); - return false; - } - - if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); - return false; - } - - // We only want to enable browser refresh if this is a WebApp (ASP.NET Core / Blazor app). - if (!projectNode.IsWebApp()) - { - context.Logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_NotWebApp.WithSeverityWhen(MessageSeverity.Error, appModel.RequiresBrowserRefresh)); - return false; - } - - context.Logger.Log(MessageDescriptor.ConfiguredToUseBrowserRefresh); - return true; - } - private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) { return (projectOptions.NoLaunchProfile == true diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index ee060084bb16..2e1a68a5a7ea 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; +using Microsoft.Build.Graph; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs index 05f41d26e645..d92d5af86c14 100644 --- a/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs +++ b/src/BuiltInTools/dotnet-watch/Build/BuildNames.cs @@ -47,3 +47,10 @@ internal static class TargetNames public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup); } + +internal static class ProjectCapability +{ + public const string Aspire = nameof(Aspire); + public const string AspNetCore = nameof(AspNetCore); + public const string WebAssembly = nameof(WebAssembly); +} diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs index 5f1fdb5ffabd..495fdc6e2643 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -101,7 +101,7 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVe => projectNode.IsNetCoreApp() && projectNode.IsTargetFrameworkVersionOrNewer(minVersion); public static bool IsWebApp(this ProjectGraphNode projectNode) - => projectNode.GetCapabilities().Any(static value => value is "AspNetCore" or "WebAssembly"); + => projectNode.GetCapabilities().Any(static value => value is ProjectCapability.AspNetCore or ProjectCapability.WebAssembly); public static string? GetOutputDirectory(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetPath) is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null; @@ -139,7 +139,19 @@ public static bool GetBooleanPropertyValue(this ProjectInstance project, string public static bool GetBooleanMetadataValue(this ProjectItemInstance item, string metadataName, bool defaultValue = false) => item.GetMetadataValue(metadataName) is { Length: > 0 } value ? bool.TryParse(value, out var result) && result : defaultValue; - public static IEnumerable GetTransitivelyReferencingProjects(this IEnumerable projects) + public static IEnumerable GetAncestorsAndSelf(this ProjectGraphNode project) + => GetAncestorsAndSelf([project]); + + public static IEnumerable GetAncestorsAndSelf(this IEnumerable projects) + => GetTransitiveProjects(projects, static project => project.ReferencingProjects); + + public static IEnumerable GetDescendantsAndSelf(this ProjectGraphNode project) + => GetDescendantsAndSelf([project]); + + public static IEnumerable GetDescendantsAndSelf(this IEnumerable projects) + => GetTransitiveProjects(projects, static project => project.ProjectReferences); + + private static IEnumerable GetTransitiveProjects(IEnumerable projects, Func> getEdges) { var visited = new HashSet(); var queue = new Queue(); @@ -153,13 +165,13 @@ public static IEnumerable GetTransitivelyReferencingProjects(t var project = queue.Dequeue(); if (visited.Add(project)) { - foreach (var referencingProject in project.ReferencingProjects) + yield return project; + + foreach (var referencingProject in getEdges(project)) { queue.Enqueue(referencingProject); } } } - - return visited; } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs index 2d85eca957dc..d1ff6fce9d48 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs @@ -13,7 +13,7 @@ namespace Microsoft.DotNet.Watch; /// internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject) // Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server. - : HotReloadAppModel(agentInjectionProject: null) + : WebApplicationAppModel(agentInjectionProject: null) { public override bool RequiresBrowserRefresh => true; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs index 01d2b4c36ac6..72aa04cd14bb 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -14,7 +14,7 @@ namespace Microsoft.DotNet.Watch; /// Agent is injected into the server process. The client process is updated via WebSocketScriptInjection.js injected into the browser. /// internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject, ProjectGraphNode serverProject) - : HotReloadAppModel(agentInjectionProject: serverProject) + : WebApplicationAppModel(agentInjectionProject: serverProject) { public override bool RequiresBrowserRefresh => true; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs index 71a0a9586805..c98aaafc0084 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs @@ -38,50 +38,24 @@ public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, ILogger logger) { - if (projectNode.IsWebApp()) - { - var queue = new Queue(); - queue.Enqueue(projectNode); - - ProjectGraphNode? aspnetCoreProject = null; + var capabilities = projectNode.GetCapabilities(); - var visited = new HashSet(); + if (capabilities.Contains(ProjectCapability.WebAssembly)) + { + logger.Log(MessageDescriptor.HotReloadProfile_BlazorWebAssembly); + return new BlazorWebAssemblyAppModel(clientProject: projectNode); + } - while (queue.Count > 0) + if (capabilities.Contains(ProjectCapability.AspNetCore)) + { + if (projectNode.GetDescendantsAndSelf().FirstOrDefault(static p => p.GetCapabilities().Contains(ProjectCapability.WebAssembly)) is { } clientProject) { - var currentNode = queue.Dequeue(); - var projectCapability = currentNode.ProjectInstance.GetItems("ProjectCapability"); - - foreach (var item in projectCapability) - { - if (item.EvaluatedInclude == "AspNetCore") - { - aspnetCoreProject = currentNode; - break; - } - - if (item.EvaluatedInclude == "WebAssembly") - { - // We saw a previous project that was AspNetCore. This must be a blazor hosted app. - if (aspnetCoreProject is not null && aspnetCoreProject.ProjectInstance != currentNode.ProjectInstance) - { - logger.Log(MessageDescriptor.HotReloadProfile_BlazorHosted, aspnetCoreProject.ProjectInstance.FullPath, currentNode.ProjectInstance.FullPath); - return new BlazorWebAssemblyHostedAppModel(clientProject: currentNode, serverProject: aspnetCoreProject); - } - - logger.Log(MessageDescriptor.HotReloadProfile_BlazorWebAssembly); - return new BlazorWebAssemblyAppModel(clientProject: currentNode); - } - } - - foreach (var project in currentNode.ProjectReferences) - { - if (visited.Add(project)) - { - queue.Enqueue(project); - } - } + logger.Log(MessageDescriptor.HotReloadProfile_BlazorHosted, projectNode.ProjectInstance.FullPath, clientProject.ProjectInstance.FullPath); + return new BlazorWebAssemblyHostedAppModel(clientProject: clientProject, serverProject: projectNode); } + + logger.Log(MessageDescriptor.HotReloadProfile_WebApplication); + return new WebServerAppModel(serverProject: projectNode); } logger.Log(MessageDescriptor.HotReloadProfile_Default); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs new file mode 100644 index 000000000000..aca42594ab88 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal abstract class WebApplicationAppModel(ProjectGraphNode? agentInjectionProject) + : HotReloadAppModel(agentInjectionProject) +{ + // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. + private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; + + public bool IsServerSupported(ProjectGraphNode projectNode, EnvironmentOptions options, ILogger logger) + { + if (options.SuppressBrowserRefresh) + { + logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithSeverityWhen(MessageSeverity.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); + return false; + } + + if (!projectNode.IsNetCoreApp(minVersion: s_minimumSupportedVersion)) + { + logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported.WithSeverityWhen(MessageSeverity.Error, RequiresBrowserRefresh)); + return false; + } + + logger.Log(MessageDescriptor.ConfiguredToUseBrowserRefresh); + return true; + } +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs new file mode 100644 index 000000000000..76f8118cbfe0 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class WebServerAppModel(ProjectGraphNode serverProject) + : WebApplicationAppModel(agentInjectionProject: serverProject) +{ + public override bool RequiresBrowserRefresh + => false; + + public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger) + => new(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true)); +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 7de664545446..00bc170b4a9c 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -526,7 +526,7 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList files, var buildResults = await Task.WhenAll(buildTasks).WaitAsync(cancellationToken); - var browserRefreshTasks = buildResults.Where(p => p != null)!.GetTransitivelyReferencingProjects().Select(async projectNode => + var browserRefreshTasks = buildResults.Where(p => p != null)!.GetAncestorsAndSelf().Select(async projectNode => { if (browserConnector.TryGetRefreshServer(projectNode, out var browserRefreshServer)) { diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index 88b32de5f190..fd6ed6931ef9 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -214,7 +214,6 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, MessageSeverity.Warning); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_NotWebApp = Create("Skipping configuring browser-refresh middleware since this is not a webapp.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor FailedToLaunchProcess = Create("Failed to launch '{0}' with arguments '{1}': {2}", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor ApplicationFailed = Create("Application failed: {0}", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor ProcessRunAndExited = Create("Process id {0} ran for {1}ms and exited with exit code {2}.", Emoji.Watch, MessageSeverity.Verbose); From 3060d4dda22402d472892f776225455ab4a41506 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 15:37:36 -0700 Subject: [PATCH 04/32] Better logging --- .../Browser/BrowserRefreshServer.cs | 8 ++++++-- .../HotReload/CompilationHandler.cs | 1 - src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 18 ++++++++++-------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 2e1a68a5a7ea..131ad2941b0e 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.Build.Graph; +using Microsoft.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -367,11 +368,14 @@ private async Task SupportsTlsAsync() } public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) - => SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + { + _logger.Log(MessageDescriptor.RefreshingBrowser); + return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + } public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) { - _logger.Log(MessageDescriptor.UpdatingDiagnosticsInConnectedBrowsers); + _logger.Log(MessageDescriptor.UpdatingDiagnostics); if (compilationErrors.IsEmpty) { return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 00bc170b4a9c..c4d98cea6be2 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -335,7 +335,6 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT runningProject.Logger.Log(MessageDescriptor.HotReloadSucceeded); if (runningProject.BrowserRefreshServer is { } server) { - runningProject.Logger.LogDebug("Refreshing browser."); await server.RefreshBrowserAsync(cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index fd6ed6931ef9..8df9e87f5cc4 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -200,7 +200,13 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor ApplyUpdate_ChangingEntryPoint = Create("{0} Press \"Ctrl + R\" to restart.", Emoji.Warning, MessageSeverity.Warning); public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = Create("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor ConfiguredToLaunchBrowser = Create("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", Emoji.Watch, MessageSeverity.Verbose); - public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Configuring the app to use browser-refresh middleware", Emoji.Watch, MessageSeverity.Verbose); + public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, MessageSeverity.Verbose); + public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, MessageSeverity.Warning); + public static readonly MessageDescriptor UpdatingDiagnostics = Create("Updating diagnostics.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create("Failed to receive response from a connected browser.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor NoBrowserConnected = Create("No browser is connected.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor RefreshingBrowser = Create("Refreshing browser.", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor ReloadingBrowser = Create("Reloading browser.", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, MessageSeverity.Verbose); @@ -212,8 +218,6 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, MessageSeverity.Output); public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor ExitedWithErrorCode = Create("Exited with error code {0}", Emoji.Error, MessageSeverity.Error); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, MessageSeverity.Verbose); - public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, MessageSeverity.Warning); public static readonly MessageDescriptor FailedToLaunchProcess = Create("Failed to launch '{0}' with arguments '{1}': {2}", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor ApplicationFailed = Create("Application failed: {0}", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor ProcessRunAndExited = Create("Process id {0} ran for {1}ms and exited with exit code {2}.", Emoji.Watch, MessageSeverity.Verbose); @@ -228,9 +232,6 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor HotReloadOfScopedCssFailed = Create("Hot reload of scoped css failed.", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor HotReloadOfStaticAssetsSucceeded = Create("Hot reload of static assets succeeded.", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create("Sending static asset update request to connected browsers: '{0}'.", Emoji.Refresh, MessageSeverity.Verbose); - public static readonly MessageDescriptor UpdatingDiagnosticsInConnectedBrowsers = Create("Updating diagnostics in connected browsers.", Emoji.Refresh, MessageSeverity.Verbose); - public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create("Failed to receive response from a connected browser.", Emoji.Refresh, MessageSeverity.Verbose); - public static readonly MessageDescriptor NoBrowserConnected = Create("No browser is connected.", Emoji.Refresh, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, MessageSeverity.Output); @@ -245,8 +246,9 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, MessageSeverity.Output); - public static readonly MessageDescriptor BuildSucceeded = Create(" Build succeeded: {0}", Emoji.Default, MessageSeverity.Output); - public static readonly MessageDescriptor BuildFailed = Create(" Build failed: {0}", Emoji.Default, MessageSeverity.Output); + public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, MessageSeverity.Output); + public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, MessageSeverity.Output); + } internal interface IProcessOutputReporter From 508917d12220100507c6736c1ee601a7c6635197 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 17:37:16 -0700 Subject: [PATCH 05/32] Separate BrowserLauncher and BrowserConnector --- .../dotnet-watch/Browser/BrowserConnector.cs | 126 ++++++------------ .../Build/ProjectGraphUtilities.cs | 3 + .../dotnet-watch/Build/ProjectInstanceId.cs | 6 + .../HotReload/HotReloadDotNetWatcher.cs | 5 +- .../dotnet-watch/Process/ProjectLauncher.cs | 8 +- .../Process/WebServerProcessStateObserver.cs | 49 +++++++ .../dotnet-watch/Watch/DotNetWatcher.cs | 11 +- 7 files changed, 116 insertions(+), 92 deletions(-) create mode 100644 src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs create mode 100644 src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs index 95908b8d75fd..edd0e399f298 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs @@ -4,30 +4,15 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Text.RegularExpressions; using Microsoft.Build.Graph; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable +internal sealed class BrowserConnector(ILoggerFactory loggerFactory, EnvironmentOptions environmentOptions) : IAsyncDisposable { - private readonly record struct ProjectKey(string projectPath, string targetFramework); - - private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); - private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); - - [GeneratedRegex(@"Now listening on: (?.*)\s*$", RegexOptions.Compiled)] - private static partial Regex GetNowListeningOnRegex(); - - [GeneratedRegex(@"Login to the dashboard at (?.*)\s*$", RegexOptions.Compiled)] - private static partial Regex GetAspireDashboardUrlRegex(); - private readonly Lock _serversGuard = new(); - private readonly Dictionary _servers = []; - - // interlocked - private ImmutableHashSet _browserLaunchAttempted = []; + private readonly Dictionary _servers = []; public async ValueTask DisposeAsync() { @@ -48,26 +33,17 @@ await Task.WhenAll(serversToDispose.Select(async server => })); } - private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) - => new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework()); - /// /// A single browser refresh server is created for each project that supports browser launching. /// When the project is rebuilt we reuse the same refresh server and browser instance. /// Reload message is sent to the browser in that case. /// - public async ValueTask GetOrCreateBrowserRefreshServerAsync( - ProjectGraphNode projectNode, - ProcessSpec processSpec, - EnvironmentVariablesBuilder environmentBuilder, - ProjectOptions projectOptions, - HotReloadAppModel appModel, - CancellationToken cancellationToken) + public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, HotReloadAppModel appModel, CancellationToken cancellationToken) { BrowserRefreshServer? server; bool hasExistingServer; - var key = GetProjectKey(projectNode); + var key = projectNode.GetProjectInstanceId(); lock (_serversGuard) { @@ -79,10 +55,6 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) } } - // Attach trigger to the process that detects when the web server reports to the output that it's listening. - // Launches browser on the URL found in the process output for root projects. - processSpec.OnOutput += GetBrowserLaunchTrigger(projectNode, projectOptions, server, cancellationToken); - if (server == null) { // browser refresh server isn't supported @@ -95,18 +67,16 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) await server.StartAsync(cancellationToken); } - server.SetEnvironmentVariables(environmentBuilder); - return server; } private BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode, HotReloadAppModel appModel) { - var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); + var logger = loggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); - if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, context.EnvironmentOptions, logger)) + if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, environmentOptions, logger)) { - return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); + return new BrowserRefreshServer(environmentOptions, logger, loggerFactory); } return null; @@ -114,66 +84,56 @@ private static ProjectKey GetProjectKey(ProjectGraphNode projectNode) public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) { - var key = GetProjectKey(projectNode); + var key = projectNode.GetProjectInstanceId(); lock (_serversGuard) { return _servers.TryGetValue(key, out server) && server != null; } } +} + +internal sealed class BrowserLauncher(ILogger logger, EnvironmentOptions environmentOptions) +{ + // interlocked + private ImmutableHashSet _browserLaunchAttempted = []; /// - /// Get process output handler that will be subscribed to the process output event every time the process is launched. + /// Installs browser launch/reload trigger. /// - public Action? GetBrowserLaunchTrigger(ProjectGraphNode projectNode, ProjectOptions projectOptions, BrowserRefreshServer? server, CancellationToken cancellationToken) + public void InstallBrowserLaunchTrigger( + ProcessSpec processSpec, + ProjectGraphNode projectNode, + ProjectOptions projectOptions, + BrowserRefreshServer? server, + CancellationToken cancellationToken) { - if (!CanLaunchBrowser(context, projectNode, projectOptions, out var launchProfile)) + if (!CanLaunchBrowser(projectOptions, out var launchProfile)) { - if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) + if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { - context.Logger.LogError("Test requires browser to launch"); + logger.LogError("Test requires browser to launch"); } - return null; + return; } - bool matchFound = false; - - // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. - // TODO: Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. - var isAspireHost = projectNode.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); - - return handler; - - void handler(OutputLine line) + WebServerProcessStateObserver.Observe(projectNode, processSpec, url => { - if (matchFound) - { - return; - } - - var match = (isAspireHost ? s_aspireDashboardUrlRegex : s_nowListeningRegex).Match(line.Content); - if (!match.Success) - { - return; - } - - matchFound = true; - if (projectOptions.IsRootProject && - ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), GetProjectKey(projectNode))) + ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.GetProjectInstanceId())) { // first build iteration of a root project: - var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, match.Groups["url"].Value); + var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, url); LaunchBrowser(launchUrl, server); } else if (server != null) { // Subsequent iterations (project has been rebuilt and relaunched). // Use refresh server to reload the browser, if available. - _ = server.SendReloadMessageAsync(cancellationToken); + _ = server.SendReloadMessageAsync(cancellationToken).AsTask(); } - } + }); } public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl) @@ -193,11 +153,11 @@ private void LaunchBrowser(string launchUrl, BrowserRefreshServer? server) fileName = browserPath; } - context.Logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args); + logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args); - if (context.EnvironmentOptions.TestFlags != TestFlags.None) + if (environmentOptions.TestFlags != TestFlags.None) { - if (context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) + if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { Debug.Assert(server != null); server.EmulateClientConnected(); @@ -223,35 +183,27 @@ private void LaunchBrowser(string launchUrl, BrowserRefreshServer? server) // From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value // or for the process to have immediately exited. // We can use this to provide a helpful message. - context.Logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl); + logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl); } } catch (Exception e) { - context.Logger.LogDebug("Failed to launch a browser: {Message}", e.Message); + logger.LogDebug("Failed to launch a browser: {Message}", e.Message); } } - private bool CanLaunchBrowser(DotNetWatchContext context, ProjectGraphNode projectNode, ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) + private bool CanLaunchBrowser(ProjectOptions projectOptions, [NotNullWhen(true)] out LaunchSettingsProfile? launchProfile) { - var logger = context.Logger; launchProfile = null; - if (context.EnvironmentOptions.SuppressLaunchBrowser) - { - return false; - } - - if (!projectNode.IsNetCoreApp(minVersion: Versions.Version3_1)) + if (environmentOptions.SuppressLaunchBrowser) { - // Browser refresh middleware supports 3.1 or newer - logger.LogDebug("Browser refresh is only supported in .NET Core 3.1 or newer projects."); return false; } if (!CommandLineOptions.IsCodeExecutionCommand(projectOptions.Command)) { - logger.LogDebug("Command '{Command}' does not support browser refresh.", projectOptions.Command); + logger.LogDebug("Command '{Command}' does not support launching browsers.", projectOptions.Command); return false; } @@ -269,6 +221,6 @@ private bool CanLaunchBrowser(DotNetWatchContext context, ProjectGraphNode proje private LaunchSettingsProfile GetLaunchProfile(ProjectOptions projectOptions) { return (projectOptions.NoLaunchProfile == true - ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, context.Logger)) ?? new(); + ? null : LaunchSettingsProfile.ReadLaunchProfile(projectOptions.ProjectPath, projectOptions.LaunchProfileName, logger)) ?? new(); } } diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs index 495fdc6e2643..ca8f616ebcce 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs @@ -174,4 +174,7 @@ private static IEnumerable GetTransitiveProjects(IEnumerable

new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework()); } diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs new file mode 100644 index 000000000000..d396caa0a213 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs @@ -0,0 +1,6 @@ +// 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.Watch; + +internal readonly record struct ProjectInstanceId(string projectPath, string targetFramework); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 9431af44d987..ae82725d667f 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -66,7 +66,8 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) _context.Logger.Log(MessageDescriptor.HotReloadEnabled with { Severity = MessageSeverity.Verbose }); } - await using var browserConnector = new BrowserConnector(_context); + await using var browserConnector = new BrowserConnector(_context.LoggerFactory, _context.EnvironmentOptions); + var browserLauncher = new BrowserLauncher(_context.Logger, _context.EnvironmentOptions); using var fileWatcher = new FileWatcher(_context.Logger, _context.EnvironmentOptions); for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++) @@ -118,7 +119,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); compilationHandler = new CompilationHandler(_context.LoggerFactory, _context.Logger, _context.ProcessRunner); var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, browserConnector, _context.Options, _context.EnvironmentOptions); - var projectLauncher = new ProjectLauncher(_context, projectMap, browserConnector, compilationHandler, iteration); + var projectLauncher = new ProjectLauncher(_context, projectMap, browserConnector, browserLauncher, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Logger); runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 963540a9fe90..05d9c9ceb284 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -12,6 +12,7 @@ internal sealed class ProjectLauncher( DotNetWatchContext context, ProjectNodeMap projectMap, BrowserConnector browserConnector, + BrowserLauncher browserLauncher, CompilationHandler compilationHandler, int iteration) { @@ -106,7 +107,8 @@ public EnvironmentOptions EnvironmentOptions } } - var browserRefreshServer = await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectNode, processSpec, environmentBuilder, projectOptions, appModel, cancellationToken); + var browserRefreshServer = await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectNode, appModel, cancellationToken); + browserRefreshServer?.SetEnvironmentVariables(environmentBuilder); var arguments = new List() { @@ -124,6 +126,10 @@ public EnvironmentOptions EnvironmentOptions processSpec.Arguments = arguments; + // Attach trigger to the process that detects when the web server reports to the output that it's listening. + // Launches browser on the URL found in the process output for root projects. + browserLauncher.InstallBrowserLaunchTrigger(processSpec, projectNode, projectOptions, browserRefreshServer, cancellationToken); + return await compilationHandler.TrackRunningProjectAsync( projectNode, projectOptions, diff --git a/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs b/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs new file mode 100644 index 000000000000..5449aef65588 --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Microsoft.Build.Graph; + +namespace Microsoft.DotNet.Watch; + +///

+/// Observes the state of the web server by scanning its standard output for known patterns. +/// Notifies when the server starts listening. +/// +internal static partial class WebServerProcessStateObserver +{ + private static readonly Regex s_nowListeningRegex = GetNowListeningOnRegex(); + private static readonly Regex s_aspireDashboardUrlRegex = GetAspireDashboardUrlRegex(); + + [GeneratedRegex(@"Now listening on: (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetNowListeningOnRegex(); + + [GeneratedRegex(@"Login to the dashboard at (?.*)\s*$", RegexOptions.Compiled)] + private static partial Regex GetAspireDashboardUrlRegex(); + + public static void Observe(ProjectGraphNode serverProject, ProcessSpec serverProcessSpec, Action onServerListening) + { + // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. + // TODO: Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. + bool isAspireHost = serverProject.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); + + var _notified = false; + + serverProcessSpec.OnOutput += line => + { + if (_notified) + { + return; + } + + var match = (isAspireHost ? s_aspireDashboardUrlRegex : s_nowListeningRegex).Match(line.Content); + if (!match.Success) + { + return; + } + + _notified = true; + onServerListening(match.Groups["url"].Value); + }; + } +} diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index 3598d69cffae..7c55cbcfe594 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -25,7 +25,8 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ChangedFile? changedFile = null; var buildEvaluator = new BuildEvaluator(context); - await using var browserConnector = new BrowserConnector(context); + await using var browserConnector = new BrowserConnector(context.LoggerFactory, context.EnvironmentOptions); + var browserLauncher = new BrowserLauncher(context.Logger, context.EnvironmentOptions); for (var iteration = 0;;iteration++) { @@ -64,11 +65,17 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke }; var browserRefreshServer = (projectRootNode != null) - ? await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectRootNode, processSpec, environmentBuilder, context.RootProjectOptions, new DefaultAppModel(projectRootNode), shutdownCancellationToken) + ? await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectRootNode, new DefaultAppModel(projectRootNode), shutdownCancellationToken) : null; + browserRefreshServer?.SetEnvironmentVariables(environmentBuilder); environmentBuilder.SetProcessEnvironmentVariables(processSpec); + if (projectRootNode != null) + { + browserLauncher.InstallBrowserLaunchTrigger(processSpec, projectRootNode, context.RootProjectOptions, browserRefreshServer, shutdownCancellationToken); + } + // Reset for next run buildEvaluator.RequiresRevaluation = false; From 8b9f221416b1eb869c20b63d433747bf4b72aa66 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 17:38:26 -0700 Subject: [PATCH 06/32] Renames --- ...BrowserConnector.cs => BrowserLauncher.cs} | 84 ----------------- .../Browser/BrowserRefreshServerFactory.cs | 92 +++++++++++++++++++ .../HotReload/HotReloadDotNetWatcher.cs | 2 +- .../HotReload/ScopedCssFileHandler.cs | 2 +- .../HotReload/StaticFileHandler.cs | 2 +- .../dotnet-watch/Process/ProjectLauncher.cs | 2 +- .../dotnet-watch/Watch/DotNetWatcher.cs | 2 +- .../Browser/BrowserConnectorTests.cs | 2 +- 8 files changed, 98 insertions(+), 90 deletions(-) rename src/BuiltInTools/dotnet-watch/Browser/{BrowserConnector.cs => BrowserLauncher.cs} (65%) create mode 100644 src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs similarity index 65% rename from src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs rename to src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs index edd0e399f298..70427361d002 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs @@ -9,90 +9,6 @@ namespace Microsoft.DotNet.Watch; -internal sealed class BrowserConnector(ILoggerFactory loggerFactory, EnvironmentOptions environmentOptions) : IAsyncDisposable -{ - private readonly Lock _serversGuard = new(); - private readonly Dictionary _servers = []; - - public async ValueTask DisposeAsync() - { - BrowserRefreshServer?[] serversToDispose; - - lock (_serversGuard) - { - serversToDispose = _servers.Values.ToArray(); - _servers.Clear(); - } - - await Task.WhenAll(serversToDispose.Select(async server => - { - if (server != null) - { - await server.DisposeAsync(); - } - })); - } - - /// - /// A single browser refresh server is created for each project that supports browser launching. - /// When the project is rebuilt we reuse the same refresh server and browser instance. - /// Reload message is sent to the browser in that case. - /// - public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, HotReloadAppModel appModel, CancellationToken cancellationToken) - { - BrowserRefreshServer? server; - bool hasExistingServer; - - var key = projectNode.GetProjectInstanceId(); - - lock (_serversGuard) - { - hasExistingServer = _servers.TryGetValue(key, out server); - if (!hasExistingServer) - { - server = TryCreateRefreshServer(projectNode, appModel); - _servers.Add(key, server); - } - } - - if (server == null) - { - // browser refresh server isn't supported - return null; - } - - if (!hasExistingServer) - { - // Start the server we just created: - await server.StartAsync(cancellationToken); - } - - return server; - } - - private BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode, HotReloadAppModel appModel) - { - var logger = loggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); - - if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, environmentOptions, logger)) - { - return new BrowserRefreshServer(environmentOptions, logger, loggerFactory); - } - - return null; - } - - public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) - { - var key = projectNode.GetProjectInstanceId(); - - lock (_serversGuard) - { - return _servers.TryGetValue(key, out server) && server != null; - } - } -} - internal sealed class BrowserLauncher(ILogger logger, EnvironmentOptions environmentOptions) { // interlocked diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs new file mode 100644 index 000000000000..7dbb29cbc97e --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs @@ -0,0 +1,92 @@ +// 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.CodeAnalysis; +using Microsoft.Build.Graph; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watch; + +internal sealed class BrowserRefreshServerFactory(ILoggerFactory loggerFactory, EnvironmentOptions environmentOptions) : IAsyncDisposable +{ + private readonly Lock _serversGuard = new(); + private readonly Dictionary _servers = []; + + public async ValueTask DisposeAsync() + { + BrowserRefreshServer?[] serversToDispose; + + lock (_serversGuard) + { + serversToDispose = _servers.Values.ToArray(); + _servers.Clear(); + } + + await Task.WhenAll(serversToDispose.Select(async server => + { + if (server != null) + { + await server.DisposeAsync(); + } + })); + } + + /// + /// A single browser refresh server is created for each project that supports browser launching. + /// When the project is rebuilt we reuse the same refresh server and browser instance. + /// Reload message is sent to the browser in that case. + /// + public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, HotReloadAppModel appModel, CancellationToken cancellationToken) + { + BrowserRefreshServer? server; + bool hasExistingServer; + + var key = projectNode.GetProjectInstanceId(); + + lock (_serversGuard) + { + hasExistingServer = _servers.TryGetValue(key, out server); + if (!hasExistingServer) + { + server = TryCreateRefreshServer(projectNode, appModel); + _servers.Add(key, server); + } + } + + if (server == null) + { + // browser refresh server isn't supported + return null; + } + + if (!hasExistingServer) + { + // Start the server we just created: + await server.StartAsync(cancellationToken); + } + + return server; + } + + private BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode, HotReloadAppModel appModel) + { + var logger = loggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); + + if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, environmentOptions, logger)) + { + return new BrowserRefreshServer(environmentOptions, logger, loggerFactory); + } + + return null; + } + + public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) + { + var key = projectNode.GetProjectInstanceId(); + + lock (_serversGuard) + { + return _servers.TryGetValue(key, out server) && server != null; + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index ae82725d667f..c0828a105a71 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -66,7 +66,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) _context.Logger.Log(MessageDescriptor.HotReloadEnabled with { Severity = MessageSeverity.Verbose }); } - await using var browserConnector = new BrowserConnector(_context.LoggerFactory, _context.EnvironmentOptions); + await using var browserConnector = new BrowserRefreshServerFactory(_context.LoggerFactory, _context.EnvironmentOptions); var browserLauncher = new BrowserLauncher(_context.Logger, _context.EnvironmentOptions); using var fileWatcher = new FileWatcher(_context.Logger, _context.EnvironmentOptions); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs index fbac1b2b239d..09e33759e7a4 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/ScopedCssFileHandler.cs @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class ScopedCssFileHandler(ILogger logger, ILogger buildLogger, ProjectNodeMap projectMap, BrowserConnector browserConnector, GlobalOptions options, EnvironmentOptions environmentOptions) + internal sealed class ScopedCssFileHandler(ILogger logger, ILogger buildLogger, ProjectNodeMap projectMap, BrowserRefreshServerFactory browserConnector, GlobalOptions options, EnvironmentOptions environmentOptions) { private const string BuildTargetName = TargetNames.GenerateComputedBuildStaticWebAssets; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs index d7be36d5e870..342b787cc907 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class StaticFileHandler(ILogger logger, ProjectNodeMap projectMap, BrowserConnector browserConnector) + internal sealed class StaticFileHandler(ILogger logger, ProjectNodeMap projectMap, BrowserRefreshServerFactory browserConnector) { public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken) { diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 05d9c9ceb284..1af8a4ebdc1f 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -11,7 +11,7 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectLauncher( DotNetWatchContext context, ProjectNodeMap projectMap, - BrowserConnector browserConnector, + BrowserRefreshServerFactory browserConnector, BrowserLauncher browserLauncher, CompilationHandler compilationHandler, int iteration) diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index 7c55cbcfe594..9ff6ab24c422 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -25,7 +25,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ChangedFile? changedFile = null; var buildEvaluator = new BuildEvaluator(context); - await using var browserConnector = new BrowserConnector(context.LoggerFactory, context.EnvironmentOptions); + await using var browserConnector = new BrowserRefreshServerFactory(context.LoggerFactory, context.EnvironmentOptions); var browserLauncher = new BrowserLauncher(context.Logger, context.EnvironmentOptions); for (var iteration = 0;;iteration++) diff --git a/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs b/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs index 6f1a08391dfc..94a2d458cb3c 100644 --- a/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs +++ b/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs @@ -18,6 +18,6 @@ public class BrowserConnectorTests [InlineData("https://localhost:1000/x/y?z=u", "https://localhost:1234/a?b=c", "https://localhost:1000/x/y?z=u")] public void GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl, string expected) { - Assert.Equal(expected, BrowserConnector.GetLaunchUrl(profileLaunchUrl, outputLaunchUrl)); + Assert.Equal(expected, BrowserRefreshServerFactory.GetLaunchUrl(profileLaunchUrl, outputLaunchUrl)); } } From 170ddb58e8d461b2389ed4140a90711bdc66f950 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 17:53:54 -0700 Subject: [PATCH 07/32] Comments --- .../Browser/BrowserRefreshServerFactory.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs index 7dbb29cbc97e..ace27aedb59b 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs @@ -7,9 +7,20 @@ namespace Microsoft.DotNet.Watch; +/// +/// Creates instances. +/// +/// An instance is created for each project that supports browser launching. +/// When the project is rebuilt and restarted we reuse the same refresh server and browser instance. +/// Reload message is sent to the browser in that case. +/// +/// The instances are also reused if the project file is updated or the project graph is reloaded. +/// internal sealed class BrowserRefreshServerFactory(ILoggerFactory loggerFactory, EnvironmentOptions environmentOptions) : IAsyncDisposable { private readonly Lock _serversGuard = new(); + + // Null value is cached for project instances that are not web projects or do not support browser refresh for other reason. private readonly Dictionary _servers = []; public async ValueTask DisposeAsync() @@ -18,7 +29,7 @@ public async ValueTask DisposeAsync() lock (_serversGuard) { - serversToDispose = _servers.Values.ToArray(); + serversToDispose = [.. _servers.Values]; _servers.Clear(); } @@ -31,11 +42,6 @@ await Task.WhenAll(serversToDispose.Select(async server => })); } - /// - /// A single browser refresh server is created for each project that supports browser launching. - /// When the project is rebuilt we reuse the same refresh server and browser instance. - /// Reload message is sent to the browser in that case. - /// public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, HotReloadAppModel appModel, CancellationToken cancellationToken) { BrowserRefreshServer? server; From b5fb8a9c838f5b1118b7f511612eabab942a0550 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 18:21:38 -0700 Subject: [PATCH 08/32] Cancellation --- .../Browser/BrowserRefreshServer.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 131ad2941b0e..c5afa001470d 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -44,8 +44,8 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable private readonly string? _environmentHostName; // initialized by StartAsync - private IHost? _refreshServer; - private string? _serverUrls; + private IHost? _lazyServer; + private string? _lazyServerUrls; public readonly EnvironmentOptions Options; @@ -77,17 +77,17 @@ public async ValueTask DisposeAsync() await connection.DisposeAsync(); } - _refreshServer?.Dispose(); + _lazyServer?.Dispose(); _terminateWebSocket.TrySetResult(); } public void SetEnvironmentVariables(EnvironmentVariablesBuilder environmentBuilder) { - Debug.Assert(_refreshServer != null); - Debug.Assert(_serverUrls != null); + Debug.Assert(_lazyServer != null); + Debug.Assert(_lazyServerUrls != null); - environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _serverUrls); + environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _lazyServerUrls); environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey, GetServerKey()); environmentBuilder.DotNetStartupHooks.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll")); @@ -105,13 +105,14 @@ public string GetServerKey() public async ValueTask StartAsync(CancellationToken cancellationToken) { - Debug.Assert(_refreshServer == null); + Debug.Assert(_lazyServer == null); + Debug.Assert(_lazyServerUrls == null); var hostName = _environmentHostName ?? "127.0.0.1"; - var supportsTLS = await SupportsTlsAsync(); + var supportsTLS = await SupportsTlsAsync(cancellationToken); - _refreshServer = new HostBuilder() + _lazyServer = new HostBuilder() .ConfigureWebHost(builder => { builder.UseKestrel(); @@ -132,11 +133,10 @@ public async ValueTask StartAsync(CancellationToken cancellationToken) }) .Build(); - await _refreshServer.StartAsync(cancellationToken); + _lazyServerUrls = string.Join(',', GetServerUrls(_lazyServer)); - var serverUrls = string.Join(',', GetServerUrls(_refreshServer)); - _logger.LogDebug("Refresh server running at {0}.", serverUrls); - _serverUrls = serverUrls; + await _lazyServer.StartAsync(cancellationToken); + _logger.LogDebug("Refresh server running at {Urls}.", _lazyServerUrls); } private IEnumerable GetServerUrls(IHost server) @@ -344,7 +344,7 @@ public async ValueTask SendAndReceiveAsync( await DisposeClosedBrowserConnectionsAsync(); } - private async Task SupportsTlsAsync() + private async Task SupportsTlsAsync(CancellationToken cancellationToken) { var result = s_lazyTlsSupported; if (result.HasValue) @@ -355,7 +355,7 @@ private async Task SupportsTlsAsync() try { using var process = Process.Start(Options.MuxerPath, "dev-certs https --check --quiet"); - await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); result = process.ExitCode == 0; } catch From fcca0fb4860c48570976ec285ee81edf60739b52 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 18:30:44 -0700 Subject: [PATCH 09/32] BrowserLauncherTests --- .../{BrowserConnectorTests.cs => BrowserLauncherTests.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/dotnet-watch.Tests/Browser/{BrowserConnectorTests.cs => BrowserLauncherTests.cs} (88%) diff --git a/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs b/test/dotnet-watch.Tests/Browser/BrowserLauncherTests.cs similarity index 88% rename from test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs rename to test/dotnet-watch.Tests/Browser/BrowserLauncherTests.cs index 94a2d458cb3c..0555c9e557e9 100644 --- a/test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs +++ b/test/dotnet-watch.Tests/Browser/BrowserLauncherTests.cs @@ -3,7 +3,7 @@ namespace Microsoft.DotNet.Watch.UnitTests; -public class BrowserConnectorTests +public class BrowserLauncherTests { [Theory] [InlineData(null, "https://localhost:1234", "https://localhost:1234")] @@ -18,6 +18,6 @@ public class BrowserConnectorTests [InlineData("https://localhost:1000/x/y?z=u", "https://localhost:1234/a?b=c", "https://localhost:1000/x/y?z=u")] public void GetLaunchUrl(string? profileLaunchUrl, string outputLaunchUrl, string expected) { - Assert.Equal(expected, BrowserRefreshServerFactory.GetLaunchUrl(profileLaunchUrl, outputLaunchUrl)); + Assert.Equal(expected, BrowserLauncher.GetLaunchUrl(profileLaunchUrl, outputLaunchUrl)); } } From ca49ca9642d58a2f777b8559bc6284bd5370b61f Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 25 Aug 2025 18:31:59 -0700 Subject: [PATCH 10/32] Env vars --- src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs | 2 +- .../dotnet-watch/Browser/BrowserRefreshServer.cs | 7 ++----- .../dotnet-watch/CommandLine/EnvironmentOptions.cs | 6 ++++++ .../dotnet-watch/HotReload/HotReloadDotNetWatcher.cs | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs index 70427361d002..25c8c8161591 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs @@ -63,7 +63,7 @@ private void LaunchBrowser(string launchUrl, BrowserRefreshServer? server) var fileName = launchUrl; var args = string.Empty; - if (EnvironmentVariables.BrowserPath is { } browserPath) + if (environmentOptions.BrowserPath is { } browserPath) { args = fileName; fileName = browserPath; diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index c5afa001470d..e5ebe7b37b14 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -13,7 +13,6 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; -using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -41,7 +40,6 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable private readonly ILogger _logger; private readonly TaskCompletionSource _terminateWebSocket; private readonly TaskCompletionSource _browserConnected; - private readonly string? _environmentHostName; // initialized by StartAsync private IHost? _lazyServer; @@ -57,7 +55,6 @@ public BrowserRefreshServer(EnvironmentOptions options, ILogger logger, ILoggerF _logger = logger; _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _environmentHostName = EnvironmentVariables.AutoReloadWSHostName; } public async ValueTask DisposeAsync() @@ -108,7 +105,7 @@ public async ValueTask StartAsync(CancellationToken cancellationToken) Debug.Assert(_lazyServer == null); Debug.Assert(_lazyServerUrls == null); - var hostName = _environmentHostName ?? "127.0.0.1"; + var hostName = Options.AutoReloadWebSocketHostName ?? "127.0.0.1"; var supportsTLS = await SupportsTlsAsync(cancellationToken); @@ -149,7 +146,7 @@ private IEnumerable GetServerUrls(IHost server) Debug.Assert(serverUrls != null); - if (_environmentHostName is null) + if (Options.AutoReloadWebSocketHostName is null) { return serverUrls.Select(s => s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs index 960a910d60a2..01ea83ed6df5 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentOptions.cs @@ -34,6 +34,9 @@ internal sealed record EnvironmentOptions( bool SuppressLaunchBrowser = false, bool SuppressBrowserRefresh = false, bool SuppressEmojis = false, + bool RestartOnRudeEdit = false, + string? AutoReloadWebSocketHostName = null, + string? BrowserPath = null, TestFlags TestFlags = TestFlags.None, string TestOutput = "") { @@ -48,6 +51,9 @@ internal sealed record EnvironmentOptions( SuppressLaunchBrowser: EnvironmentVariables.SuppressLaunchBrowser, SuppressBrowserRefresh: EnvironmentVariables.SuppressBrowserRefresh, SuppressEmojis: EnvironmentVariables.SuppressEmojis, + RestartOnRudeEdit: EnvironmentVariables.RestartOnRudeEdit, + AutoReloadWebSocketHostName: EnvironmentVariables.AutoReloadWSHostName, + BrowserPath: EnvironmentVariables.BrowserPath, TestFlags: EnvironmentVariables.TestFlags, TestOutput: EnvironmentVariables.TestOutputDir ); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index c0828a105a71..5e5270f04750 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -32,7 +32,7 @@ public HotReloadDotNetWatcher(DotNetWatchContext context, IConsole console, IRun { var consoleInput = new ConsoleInputReader(_console, context.Options.Quiet, context.EnvironmentOptions.SuppressEmojis); - var noPrompt = EnvironmentVariables.RestartOnRudeEdit; + var noPrompt = context.EnvironmentOptions.RestartOnRudeEdit; if (noPrompt) { context.Logger.LogDebug("DOTNET_WATCH_RESTART_ON_RUDE_EDIT = 'true'. Will restart without prompt."); From 5bc60b5a911006c0ce463b77213358e6d8d34770 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Wed, 27 Aug 2025 11:32:49 -0700 Subject: [PATCH 11/32] Fix tests --- test/dotnet-watch.Tests/CommandLine/ProgramTests.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs index 6e87e28b7bc5..caee7fb19636 100644 --- a/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs +++ b/test/dotnet-watch.Tests/CommandLine/ProgramTests.cs @@ -243,7 +243,6 @@ public async Task BuildCommand() Assert.Contains("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); App.AssertOutputContains("dotnet watch ⌚ Command 'build' does not support Hot Reload."); - App.AssertOutputContains("dotnet watch ⌚ Command 'build' does not support browser refresh."); App.AssertOutputContains("warning : The value of property is '123'"); } @@ -261,7 +260,6 @@ public async Task MSBuildCommand() Assert.DoesNotContain("TestProperty", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); App.AssertOutputContains("dotnet watch ⌚ Command 'msbuild' does not support Hot Reload."); - App.AssertOutputContains("dotnet watch ⌚ Command 'msbuild' does not support browser refresh."); App.AssertOutputContains("warning : The value of property is '123'"); } @@ -281,7 +279,6 @@ public async Task PackCommand() Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); App.AssertOutputContains("dotnet watch ⌚ Command 'pack' does not support Hot Reload."); - App.AssertOutputContains("dotnet watch ⌚ Command 'pack' does not support browser refresh."); App.AssertOutputContains($"Successfully created package '{packagePath}'"); } @@ -299,7 +296,6 @@ public async Task PublishCommand() Assert.Contains("-property:Configuration=Release", App.Process.Output.Single(line => line.Contains("/t:GenerateWatchList"))); App.AssertOutputContains("dotnet watch ⌚ Command 'publish' does not support Hot Reload."); - App.AssertOutputContains("dotnet watch ⌚ Command 'publish' does not support browser refresh."); App.AssertOutputContains(Path.Combine("Release", ToolsetInfo.CurrentTargetFramework, "publish")); } @@ -316,7 +312,6 @@ public async Task FormatCommand() await App.WaitForOutputLineContaining(MessageDescriptor.WaitingForFileChangeBeforeRestarting); App.AssertOutputContains("dotnet watch ⌚ Command 'format' does not support Hot Reload."); - App.AssertOutputContains("dotnet watch ⌚ Command 'format' does not support browser refresh."); App.AssertOutputContains("format --verbosity detailed"); App.AssertOutputContains("Format complete in"); From bca735b0e577685bf95de3cad85b2643640cbcab Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Wed, 27 Aug 2025 11:47:15 -0700 Subject: [PATCH 12/32] Move LoggingUtilities --- .../HotReloadClient/{ => Logging}/LogEvents.cs | 2 ++ .../Logging}/LoggingUtilities.cs | 5 ++--- .../Microsoft.DotNet.HotReload.Client.Package.csproj | 8 ++++---- src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs | 2 +- src/BuiltInTools/dotnet-watch/UI/ConsoleReporter.cs | 2 +- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 3 +++ 6 files changed, 13 insertions(+), 9 deletions(-) rename src/BuiltInTools/HotReloadClient/{ => Logging}/LogEvents.cs (98%) rename src/BuiltInTools/{dotnet-watch/Utilities => HotReloadClient/Logging}/LoggingUtilities.cs (87%) diff --git a/src/BuiltInTools/HotReloadClient/LogEvents.cs b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs similarity index 98% rename from src/BuiltInTools/HotReloadClient/LogEvents.cs rename to src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs index 96eef4ee4b89..5cdf529a0b80 100644 --- a/src/BuiltInTools/HotReloadClient/LogEvents.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs @@ -1,6 +1,8 @@ // 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 Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload; diff --git a/src/BuiltInTools/dotnet-watch/Utilities/LoggingUtilities.cs b/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs similarity index 87% rename from src/BuiltInTools/dotnet-watch/Utilities/LoggingUtilities.cs rename to src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs index deddc5371a86..f9a87f0ebb18 100644 --- a/src/BuiltInTools/dotnet-watch/Utilities/LoggingUtilities.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs @@ -1,6 +1,8 @@ // 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 Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; @@ -14,7 +16,4 @@ public static (string comonentName, string? displayName) ParseCategoryName(strin => categoryName.IndexOf('|') is int index && index > 0 ? (categoryName[..index], categoryName[(index + 1)..]) : (categoryName, null); - - public static string GetPrefix(Emoji emoji) - => $"dotnet watch {emoji.ToDisplay()} "; } diff --git a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index f99be64cc65a..bb03be41d290 100644 --- a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -30,12 +30,12 @@ don't depend on higher versions of the packages than are available in those environments. --> - - + + - - + + diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 1af8a4ebdc1f..e7764ab9da18 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -103,7 +103,7 @@ public EnvironmentOptions EnvironmentOptions { environmentBuilder.SetVariable( EnvironmentVariables.Names.HotReloadDeltaClientLogMessages, - LoggingUtilities.GetPrefix(EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent) + $"[{projectDisplayName}]"); + (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"); } } diff --git a/src/BuiltInTools/dotnet-watch/UI/ConsoleReporter.cs b/src/BuiltInTools/dotnet-watch/UI/ConsoleReporter.cs index a9c78187373d..84cc357ad35a 100644 --- a/src/BuiltInTools/dotnet-watch/UI/ConsoleReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/ConsoleReporter.cs @@ -33,7 +33,7 @@ private void WriteLine(TextWriter writer, string message, ConsoleColor? color, E lock (_writeLock) { console.ForegroundColor = ConsoleColor.DarkGray; - writer.Write(LoggingUtilities.GetPrefix(SuppressEmojis ? Emoji.Default : emoji)); + writer.Write((SuppressEmojis ? Emoji.Default : emoji).GetLogMessagePrefix()); console.ResetColor(); if (color.HasValue) diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index 8df9e87f5cc4..b694c8fd59dc 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -60,6 +60,9 @@ public static string ToDisplay(this Emoji emoji) _ => throw new InvalidOperationException() }; + public static string GetLogMessagePrefix(this Emoji emoji) + => $"dotnet watch {emoji.ToDisplay()} "; + public static void Log(this ILogger logger, MessageDescriptor descriptor, params object?[] args) { logger.Log( From f40db3a719a7fcac7f10ed2e8e6d11c5294a01be Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Thu, 28 Aug 2025 13:42:46 -0700 Subject: [PATCH 13/32] EnvOptions --- .../HotReload/AppModels/BlazorWebAssemblyAppModel.cs | 4 ++-- .../HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs | 4 ++-- .../HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs | 4 ++-- .../dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs | 6 +++--- src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs index d1ff6fce9d48..fb56b97a981e 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs @@ -11,7 +11,7 @@ namespace Microsoft.DotNet.Watch; /// /// Blazor client-only WebAssembly app. /// -internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject) +internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject, EnvironmentOptions environmentOptions) // Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server. : WebApplicationAppModel(agentInjectionProject: null) { @@ -25,6 +25,6 @@ public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefr return HotReloadClients.Empty; } - return new(new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, clientProject)); + return new(new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, environmentOptions, clientProject)); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs index 72aa04cd14bb..376eaa459c30 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -13,7 +13,7 @@ namespace Microsoft.DotNet.Watch; /// App has a client and server projects and deltas are applied to both processes. /// Agent is injected into the server process. The client process is updated via WebSocketScriptInjection.js injected into the browser. /// -internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject, ProjectGraphNode serverProject) +internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject, ProjectGraphNode serverProject, EnvironmentOptions environmentOptions) : WebApplicationAppModel(agentInjectionProject: serverProject) { public override bool RequiresBrowserRefresh => true; @@ -28,7 +28,7 @@ public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefr return new( [ - (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), + (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, environmentOptions, clientProject), "client"), (new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: false), "host") ]); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs index d557216dd767..7081ac6cec77 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class BlazorWebAssemblyHotReloadClient(ILogger logger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode project) + internal sealed class BlazorWebAssemblyHotReloadClient(ILogger logger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, EnvironmentOptions environmentOptions, ProjectGraphNode project) : HotReloadClient(logger, agentLogger) { private static readonly ImmutableArray s_defaultCapabilities60 = @@ -81,7 +81,7 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr return ApplyStatus.NoChangesApplied; } - if (browserRefreshServer.Options.TestFlags.HasFlag(TestFlags.MockBrowser)) + if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) { // When testing abstract away the browser and pretend all changes have been applied: return ApplyStatus.AllChangesApplied; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs index c98aaafc0084..88368aa252b6 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs @@ -36,14 +36,14 @@ public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) return true; } - public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, ILogger logger) + public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, ILogger logger, EnvironmentOptions environmentOptions) { var capabilities = projectNode.GetCapabilities(); if (capabilities.Contains(ProjectCapability.WebAssembly)) { logger.Log(MessageDescriptor.HotReloadProfile_BlazorWebAssembly); - return new BlazorWebAssemblyAppModel(clientProject: projectNode); + return new BlazorWebAssemblyAppModel(clientProject: projectNode, environmentOptions); } if (capabilities.Contains(ProjectCapability.AspNetCore)) @@ -51,7 +51,7 @@ public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, I if (projectNode.GetDescendantsAndSelf().FirstOrDefault(static p => p.GetCapabilities().Contains(ProjectCapability.WebAssembly)) is { } clientProject) { logger.Log(MessageDescriptor.HotReloadProfile_BlazorHosted, projectNode.ProjectInstance.FullPath, clientProject.ProjectInstance.FullPath); - return new BlazorWebAssemblyHostedAppModel(clientProject: clientProject, serverProject: projectNode); + return new BlazorWebAssemblyHostedAppModel(clientProject: clientProject, serverProject: projectNode, environmentOptions); } logger.Log(MessageDescriptor.HotReloadProfile_WebApplication); diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index e7764ab9da18..2a2c97c8801f 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -47,7 +47,7 @@ public EnvironmentOptions EnvironmentOptions return null; } - var appModel = HotReloadAppModel.InferFromProject(projectNode, Logger); + var appModel = HotReloadAppModel.InferFromProject(projectNode, Logger, context.EnvironmentOptions); var processSpec = new ProcessSpec { From 7c0493efa85c707037c0b7cae923408bca581c57 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Thu, 28 Aug 2025 15:16:37 -0700 Subject: [PATCH 14/32] Fix BRS cancellation --- .../dotnet-watch/Browser/BrowserRefreshServer.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index e5ebe7b37b14..73868c6ed654 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -130,9 +130,10 @@ public async ValueTask StartAsync(CancellationToken cancellationToken) }) .Build(); - _lazyServerUrls = string.Join(',', GetServerUrls(_lazyServer)); - await _lazyServer.StartAsync(cancellationToken); + + // URLs are only available after the server has started. + _lazyServerUrls = string.Join(',', GetServerUrls(_lazyServer)); _logger.LogDebug("Refresh server running at {Urls}.", _lazyServerUrls); } From d9536d49cf7cfceb5b46a071a1d87ec3484c4436 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 29 Aug 2025 10:09:29 -0700 Subject: [PATCH 15/32] Move BRS from RunningProject to HotReloadClients --- .../HotReloadClient/DefaultHotReloadClient.cs | 2 + .../HotReloadClient/Logging/LogEvents.cs | 1 + .../Browser/BrowserRefreshServer.cs | 43 ++---- .../Browser/BrowserRefreshServerFactory.cs | 18 +-- .../CommandLine/DotNetWatchContext.cs | 10 +- .../AppModels/BlazorWebAssemblyAppModel.cs | 22 +-- .../BlazorWebAssemblyHostedAppModel.cs | 25 +-- .../BlazorWebAssemblyHotReloadClient.cs | 3 +- .../HotReload/AppModels/DefaultAppModel.cs | 10 +- .../HotReload/AppModels/HotReloadAppModel.cs | 29 ++-- .../AppModels/WebApplicationAppModel.cs | 43 +++++- .../HotReload/AppModels/WebServerAppModel.cs | 11 +- .../HotReload/CompilationHandler.cs | 73 ++------- .../HotReload/HotReloadClients.cs | 145 ++++++++++++------ .../HotReload/HotReloadDotNetWatcher.cs | 6 +- .../dotnet-watch/Process/ProjectLauncher.cs | 57 ++++--- .../dotnet-watch/Process/RunningProject.cs | 5 - src/BuiltInTools/dotnet-watch/Program.cs | 8 +- src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 10 +- .../dotnet-watch/Watch/DotNetWatcher.cs | 10 +- .../HotReload/ApplyDeltaTests.cs | 4 +- .../HotReload/RuntimeProcessLauncherTests.cs | 7 +- .../Watch/BuildEvaluatorTests.cs | 4 +- .../Watch/NoRestoreTests.cs | 2 + 24 files changed, 293 insertions(+), 255 deletions(-) diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 2e4ac195c4f0..aac55b1a4f83 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -192,6 +192,8 @@ public async override Task ApplyStaticAssetUpdatesAsync(ImmutableAr update.IsApplicationProject), ResponseLoggingLevel); + Logger.LogDebug("Sending static file update request for asset '{Url}'.", update.RelativePath); + var success = await SendAndReceiveUpdateAsync(request, isProcessSuspended, cancellationToken); if (success) { diff --git a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs index 5cdf529a0b80..ccd40263e594 100644 --- a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs @@ -22,4 +22,5 @@ public static void Log(this ILogger logger, LogEvent logEvent, params object[] a public static readonly LogEvent UpdatesApplied = Create(LogLevel.Debug, "Updates applied: {0} out of {1}."); public static readonly LogEvent Capabilities = Create(LogLevel.Debug, "Capabilities: '{1}'."); + public static readonly LogEvent HotReloadSucceeded = Create(LogLevel.Information, "Hot reload succeeded."); } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 73868c6ed654..689b1fc6b1b6 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -204,39 +204,28 @@ public async Task WaitForClientConnectionAsync(CancellationToken cancellationTok { using var progressCancellationSource = new CancellationTokenSource(); - // It make take a while to connect since the app might need to build first. - // Indicate progress in the output. Start with 60s and then report progress every 10s. - var firstReportSeconds = TimeSpan.FromSeconds(60); - var nextReportSeconds = TimeSpan.FromSeconds(10); - - var reportDelayInSeconds = firstReportSeconds; - var connectionAttemptReported = false; - - var progressReportingTask = Task.Run(async () => + if (!_browserConnected.Task.IsCompleted) { - while (!progressCancellationSource.Token.IsCancellationRequested) + var progressReportingTask = Task.Run(async () => { - await Task.Delay(Options.TestFlags != TestFlags.None ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + while (!progressCancellationSource.Token.IsCancellationRequested) + { + _logger.LogInformation("Waiting for browser connection..."); + await Task.Delay(Options.TestFlags != TestFlags.None ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), progressCancellationSource.Token); + } + }, progressCancellationSource.Token); - connectionAttemptReported = true; - reportDelayInSeconds = nextReportSeconds; - _logger.LogInformation("Connecting to the browser ..."); + try + { + await _browserConnected.Task.WaitAsync(cancellationToken); + } + finally + { + progressCancellationSource.Cancel(); } - }, progressCancellationSource.Token); - - try - { - await _browserConnected.Task.WaitAsync(cancellationToken); - } - finally - { - progressCancellationSource.Cancel(); } - if (connectionAttemptReported) - { - _logger.LogInformation("Browser connection established."); - } + _logger.LogInformation("Browser connection established."); } private IReadOnlyCollection GetOpenBrowserConnections() diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs index ace27aedb59b..242cc459bfec 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs @@ -16,7 +16,7 @@ namespace Microsoft.DotNet.Watch; /// /// The instances are also reused if the project file is updated or the project graph is reloaded. /// -internal sealed class BrowserRefreshServerFactory(ILoggerFactory loggerFactory, EnvironmentOptions environmentOptions) : IAsyncDisposable +internal sealed class BrowserRefreshServerFactory : IAsyncDisposable { private readonly Lock _serversGuard = new(); @@ -42,7 +42,7 @@ await Task.WhenAll(serversToDispose.Select(async server => })); } - public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, HotReloadAppModel appModel, CancellationToken cancellationToken) + public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, WebApplicationAppModel appModel, CancellationToken cancellationToken) { BrowserRefreshServer? server; bool hasExistingServer; @@ -54,7 +54,7 @@ await Task.WhenAll(serversToDispose.Select(async server => hasExistingServer = _servers.TryGetValue(key, out server); if (!hasExistingServer) { - server = TryCreateRefreshServer(projectNode, appModel); + server = appModel.TryCreateRefreshServer(projectNode); _servers.Add(key, server); } } @@ -74,18 +74,6 @@ await Task.WhenAll(serversToDispose.Select(async server => return server; } - private BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode, HotReloadAppModel appModel) - { - var logger = loggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); - - if (appModel is WebApplicationAppModel webApp && webApp.IsServerSupported(projectNode, environmentOptions, logger)) - { - return new BrowserRefreshServer(environmentOptions, logger, loggerFactory); - } - - return null; - } - public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) { var key = projectNode.GetProjectInstanceId(); diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs index df6218bfcb22..d38abd5f5dcd 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class DotNetWatchContext + internal sealed class DotNetWatchContext : IAsyncDisposable { public const string DefaultLogComponentName = $"{nameof(DotNetWatchContext)}:Default"; public const string BuildLogComponentName = $"{nameof(DotNetWatchContext)}:Build"; @@ -20,5 +20,13 @@ internal sealed class DotNetWatchContext public required ProcessRunner ProcessRunner { get; init; } public required ProjectOptions RootProjectOptions { get; init; } + + public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; } + public required BrowserLauncher BrowserLauncher { get; init; } + + public async ValueTask DisposeAsync() + { + await BrowserRefreshServerFactory.DisposeAsync(); + } } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs index fb56b97a981e..5ea4161525f0 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -11,20 +12,19 @@ namespace Microsoft.DotNet.Watch; /// /// Blazor client-only WebAssembly app. /// -internal sealed class BlazorWebAssemblyAppModel(ProjectGraphNode clientProject, EnvironmentOptions environmentOptions) - // Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server. - : WebApplicationAppModel(agentInjectionProject: null) +internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, ProjectGraphNode clientProject) + : WebApplicationAppModel(context) { + // Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server. + public override ProjectGraphNode? AgentInjectionProject => null; + + public override ProjectGraphNode LaunchingProject => clientProject; + public override bool RequiresBrowserRefresh => true; - public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger) + protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { - if (browserRefreshServer == null) - { - // error has been reported earlier - return HotReloadClients.Empty; - } - - return new(new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, environmentOptions, clientProject)); + Debug.Assert(browserRefreshServer != null); + return new(new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, Context.EnvironmentOptions, clientProject), browserRefreshServer); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs index 376eaa459c30..b5c6618a5e72 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using System.Diagnostics; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -13,23 +14,23 @@ namespace Microsoft.DotNet.Watch; /// App has a client and server projects and deltas are applied to both processes. /// Agent is injected into the server process. The client process is updated via WebSocketScriptInjection.js injected into the browser. /// -internal sealed class BlazorWebAssemblyHostedAppModel(ProjectGraphNode clientProject, ProjectGraphNode serverProject, EnvironmentOptions environmentOptions) - : WebApplicationAppModel(agentInjectionProject: serverProject) +internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context, ProjectGraphNode clientProject, ProjectGraphNode serverProject) + : WebApplicationAppModel(context) { + public override ProjectGraphNode? AgentInjectionProject => serverProject; + public override ProjectGraphNode LaunchingProject => serverProject; + public override bool RequiresBrowserRefresh => true; - public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger) + protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { - if (browserRefreshServer == null) - { - // error has been reported earlier - return HotReloadClients.Empty; - } + Debug.Assert(browserRefreshServer != null); return new( - [ - (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, environmentOptions, clientProject), "client"), - (new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: false), "host") - ]); + [ + (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, Context.EnvironmentOptions, clientProject), "client"), + (new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: false), "host") + ], + browserRefreshServer); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs index 7081ac6cec77..885d9c6f46ec 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs @@ -42,8 +42,7 @@ public override void InitiateConnection(string namedPipeName, CancellationToken } public override async Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) - // Wait for the browser connection to be established as an indication that the process has started. - // Alternatively, we could inject agent into blazor-devserver.dll and establish a connection on the named pipe. + // Wait for the browser connection to be established. Currently we need the browser to be running in order to apply changes. => await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs index 1c4416c624b1..97fee11efa70 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.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.Collections.Immutable; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -11,11 +10,10 @@ namespace Microsoft.DotNet.Watch; /// /// Default model. /// -internal sealed class DefaultAppModel(ProjectGraphNode project) - : HotReloadAppModel(agentInjectionProject: project) +internal sealed class DefaultAppModel(ProjectGraphNode project) : HotReloadAppModel { - public override bool RequiresBrowserRefresh => false; + public override ProjectGraphNode? AgentInjectionProject => project; - public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true)); + public override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true), browserRefreshServer: null)); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs index 88368aa252b6..a955c4ae57cc 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs @@ -8,24 +8,27 @@ namespace Microsoft.DotNet.Watch; -internal abstract partial class HotReloadAppModel(ProjectGraphNode? agentInjectionProject) +internal abstract partial class HotReloadAppModel() { - public abstract bool RequiresBrowserRefresh { get; } + /// + /// Project to inject agent into. + /// + public abstract ProjectGraphNode? AgentInjectionProject { get; } - public abstract HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger); + public abstract ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); /// /// Returns true and the path to the client agent implementation binary if the application needs the agent to be injected. /// public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) { - if (agentInjectionProject == null) + if (AgentInjectionProject == null) { path = null; return false; } - var hookTargetFramework = agentInjectionProject.GetTargetFramework() switch + var hookTargetFramework = AgentInjectionProject.GetTargetFramework() switch { // Note: Hot Reload is only supported on net6.0+ "net6.0" or "net7.0" or "net8.0" or "net9.0" => "net6.0", @@ -36,29 +39,29 @@ public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) return true; } - public static HotReloadAppModel InferFromProject(ProjectGraphNode projectNode, ILogger logger, EnvironmentOptions environmentOptions) + public static HotReloadAppModel InferFromProject(DotNetWatchContext context, ProjectGraphNode projectNode) { var capabilities = projectNode.GetCapabilities(); if (capabilities.Contains(ProjectCapability.WebAssembly)) { - logger.Log(MessageDescriptor.HotReloadProfile_BlazorWebAssembly); - return new BlazorWebAssemblyAppModel(clientProject: projectNode, environmentOptions); + context.Logger.Log(MessageDescriptor.ApplicationKind_BlazorWebAssembly); + return new BlazorWebAssemblyAppModel(context, clientProject: projectNode); } if (capabilities.Contains(ProjectCapability.AspNetCore)) { if (projectNode.GetDescendantsAndSelf().FirstOrDefault(static p => p.GetCapabilities().Contains(ProjectCapability.WebAssembly)) is { } clientProject) { - logger.Log(MessageDescriptor.HotReloadProfile_BlazorHosted, projectNode.ProjectInstance.FullPath, clientProject.ProjectInstance.FullPath); - return new BlazorWebAssemblyHostedAppModel(clientProject: clientProject, serverProject: projectNode, environmentOptions); + context.Logger.Log(MessageDescriptor.ApplicationKind_BlazorHosted, projectNode.ProjectInstance.FullPath, clientProject.ProjectInstance.FullPath); + return new BlazorWebAssemblyHostedAppModel(context, clientProject: clientProject, serverProject: projectNode); } - logger.Log(MessageDescriptor.HotReloadProfile_WebApplication); - return new WebServerAppModel(serverProject: projectNode); + context.Logger.Log(MessageDescriptor.ApplicationKind_WebApplication); + return new WebServerAppModel(context, serverProject: projectNode); } - logger.Log(MessageDescriptor.HotReloadProfile_Default); + context.Logger.Log(MessageDescriptor.ApplicationKind_Default); return new DefaultAppModel(projectNode); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs index aca42594ab88..399e8d2c5b5b 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs @@ -2,19 +2,54 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal abstract class WebApplicationAppModel(ProjectGraphNode? agentInjectionProject) - : HotReloadAppModel(agentInjectionProject) +internal abstract class WebApplicationAppModel(DotNetWatchContext context) : HotReloadAppModel { // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; - public bool IsServerSupported(ProjectGraphNode projectNode, EnvironmentOptions options, ILogger logger) + public DotNetWatchContext Context => context; + + public abstract bool RequiresBrowserRefresh { get; } + + /// + /// Project that's used for launching the application. + /// + public abstract ProjectGraphNode LaunchingProject { get; } + + protected abstract HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer); + + public async sealed override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) + { + var browserRefreshServer = await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(LaunchingProject, this, cancellationToken); + if (RequiresBrowserRefresh && browserRefreshServer == null) + { + // Error has been reported + return null; + } + + return CreateClients(clientLogger, agentLogger, browserRefreshServer); + } + + public BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode) + { + var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); + + if (IsServerSupported(projectNode, logger)) + { + return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); + } + + return null; + } + + public bool IsServerSupported(ProjectGraphNode projectNode, ILogger logger) { - if (options.SuppressBrowserRefresh) + if (context.EnvironmentOptions.SuppressBrowserRefresh) { logger.Log(MessageDescriptor.SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable.WithSeverityWhen(MessageSeverity.Error, RequiresBrowserRefresh), EnvironmentVariables.Names.SuppressBrowserRefresh); return false; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs index 76f8118cbfe0..531dad2153c4 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs @@ -7,12 +7,15 @@ namespace Microsoft.DotNet.Watch; -internal sealed class WebServerAppModel(ProjectGraphNode serverProject) - : WebApplicationAppModel(agentInjectionProject: serverProject) +internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraphNode serverProject) + : WebApplicationAppModel(context) { + public override ProjectGraphNode? AgentInjectionProject => serverProject; + public override ProjectGraphNode LaunchingProject => serverProject; + public override bool RequiresBrowserRefresh => false; - public override HotReloadClients CreateClients(BrowserRefreshServer? browserRefreshServer, ILogger clientLogger, ILogger agentLogger) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true)); + protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) + => new(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true), browserRefreshServer); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index c4d98cea6be2..9b862a20ad4f 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -94,26 +94,13 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) public async Task TrackRunningProjectAsync( ProjectGraphNode projectNode, ProjectOptions projectOptions, - HotReloadAppModel appModel, string namedPipeName, - BrowserRefreshServer? browserRefreshServer, + HotReloadClients clients, ProcessSpec processSpec, RestartOperation restartOperation, CancellationTokenSource processTerminationSource, CancellationToken cancellationToken) { - // create loggers that include project name in messages: - var projectDisplayName = projectNode.GetDisplayName(); - var clientLogger = _loggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); - var agentLogger = _loggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); - - var clients = appModel.CreateClients(browserRefreshServer, clientLogger, agentLogger); - if (clients.IsEmpty) - { - // error already reported - return null; - } - var processExitedSource = new CancellationTokenSource(); var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processExitedSource.Token, cancellationToken); @@ -131,7 +118,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) }; var launchResult = new ProcessLaunchResult(); - var runningProcess = _processRunner.RunAsync(processSpec, clientLogger, launchResult, processTerminationSource.Token); + var runningProcess = _processRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); if (launchResult.ProcessId == null) { // error already reported @@ -146,8 +133,6 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) projectNode, projectOptions, clients, - clientLogger, - browserRefreshServer, runningProcess, launchResult.ProcessId.Value, processExitedSource: processExitedSource, @@ -171,7 +156,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) var updatesToApply = _previousUpdates.Skip(appliedUpdateCount).ToImmutableArray(); if (updatesToApply.Any()) { - _ = await clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updatesToApply), isProcessSuspended: false, processCommunicationCancellationSource.Token); + await clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updatesToApply), isProcessSuspended: false, processCommunicationCancellationSource.Token); } appliedUpdateCount += updatesToApply.Length; @@ -329,19 +314,11 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT try { using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedSource.Token, cancellationToken); - var applySucceded = await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, processCommunicationCancellationSource.Token) != ApplyStatus.Failed; - if (applySucceded) - { - runningProject.Logger.Log(MessageDescriptor.HotReloadSucceeded); - if (runningProject.BrowserRefreshServer is { } server) - { - await server.RefreshBrowserAsync(cancellationToken); - } - } + await runningProject.Clients.ApplyManagedCodeUpdatesAsync(ToManagedCodeUpdates(updates), isProcessSuspended: false, processCommunicationCancellationSource.Token); } catch (OperationCanceledException) when (runningProject.ProcessExitedSource.Token.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - runningProject.Logger.Log(MessageDescriptor.HotReloadCanceledProcessExited); + runningProject.Clients.ClientLogger.Log(MessageDescriptor.HotReloadCanceledProcessExited); } }, cancellationToken); } @@ -397,7 +374,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updat // report or clear diagnostics in the browser UI await ForEachProjectAsync( _runningProjects, - (project, cancellationToken) => project.BrowserRefreshServer?.ReportCompilationErrorsInBrowserAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask, + (project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask, cancellationToken); void ReportCompilationDiagnostics(DiagnosticSeverity severity) @@ -502,7 +479,7 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList>(); + var updates = new Dictionary>(); foreach (var changedFile in files) { @@ -536,7 +513,7 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList HandleStaticAssetChangesAsync(IReadOnlyList { var (runningProject, assets) = entry; - - if (runningProject.BrowserRefreshServer != null) - { - await runningProject.BrowserRefreshServer.UpdateStaticAssetsAsync(assets.Select(a => a.relativeUrl), cancellationToken); - } - else - { - var updates = new List(); - - foreach (var (filePath, relativeUrl, containingProject) in assets) - { - ImmutableArray content; - try - { - content = ImmutableCollectionsMarshal.AsImmutableArray(await File.ReadAllBytesAsync(filePath, cancellationToken)); - } - catch (Exception e) - { - _logger.LogError(e.Message); - continue; - } - - updates.Add(new HotReloadStaticAssetUpdate( - assemblyName: containingProject.GetAssemblyName(), - relativePath: relativeUrl, - content: content, - isApplicationProject: containingProject == runningProject.ProjectNode)); - - _logger.LogDebug("Sending static file update request for asset '{Url}'.", relativeUrl); - } - - await runningProject.Clients.ApplyStaticAssetUpdatesAsync([.. updates], isProcessSuspended: false, cancellationToken); - } + await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, cancellationToken); }); await Task.WhenAll(tasks).WaitAsync(cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs index 8ff8dd783a06..2cde159491b7 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs @@ -2,22 +2,19 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Microsoft.Build.Graph; +using Microsoft.DotNet.Watch; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload; -internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients) : IDisposable +internal sealed class HotReloadClients(ImmutableArray<(HotReloadClient client, string name)> clients, BrowserRefreshServer? browserRefreshServer) : IDisposable { - public static readonly HotReloadClients Empty = new([]); - - public HotReloadClients(HotReloadClient client) - : this([(client, "")]) + public HotReloadClients(HotReloadClient client, BrowserRefreshServer? browserRefreshServer) + : this([(client, "")], browserRefreshServer) { } - public bool IsEmpty - => clients.IsEmpty; - public void Dispose() { foreach (var (client, _) in clients) @@ -26,6 +23,21 @@ public void Dispose() } } + public BrowserRefreshServer? BrowserRefreshServer + => browserRefreshServer; + + /// + /// All clients share the same loggers. + /// + public ILogger ClientLogger + => clients.First().client.Logger; + + /// + /// All clients share the same loggers. + /// + public ILogger AgentLogger + => clients.First().client.AgentLogger; + internal void InitiateConnection(string namedPipeName, CancellationToken cancellationToken) { foreach (var (client, _) in clients) @@ -53,58 +65,60 @@ public async ValueTask> GetUpdateCapabilitiesAsync(Cancel return [.. results.SelectMany(r => r).Distinct(StringComparer.Ordinal).OrderBy(c => c)]; } - public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) + public async ValueTask ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) { + var anyFailure = false; + if (clients is [var (singleClient, _)]) { - return await singleClient.ApplyManagedCodeUpdatesAsync(updates, isProcessSuspended, cancellationToken); + anyFailure = await singleClient.ApplyManagedCodeUpdatesAsync(updates, isProcessSuspended, cancellationToken) == ApplyStatus.Failed; } + else + { + // Apply to all processes. + // The module the change is for does not need to be loaded to any of the processes, yet we still consider it successful if the application does not fail. + // In each process we store the deltas for application when/if the module is loaded to the process later. + // An error is only reported if the delta application fails, which would be a bug either in the runtime (applying valid delta incorrectly), + // the compiler (producing wrong delta), or rude edit detection (the change shouldn't have been allowed). + + var results = await Task.WhenAll(clients.Select(c => c.client.ApplyManagedCodeUpdatesAsync(updates, isProcessSuspended, cancellationToken))); - // Apply to all processes. - // The module the change is for does not need to be loaded to any of the processes, yet we still consider it successful if the application does not fail. - // In each process we store the deltas for application when/if the module is loaded to the process later. - // An error is only reported if the delta application fails, which would be a bug either in the runtime (applying valid delta incorrectly), - // the compiler (producing wrong delta), or rude edit detection (the change shouldn't have been allowed). + var index = 0; + foreach (var status in results) + { + var (client, name) = clients[index++]; - var results = await Task.WhenAll(clients.Select(c => c.client.ApplyManagedCodeUpdatesAsync(updates, isProcessSuspended, cancellationToken))); + switch (status) + { + case ApplyStatus.Failed: + anyFailure = true; + break; - var anyFailure = false; - var anyChangeApplied = false; - var allChangesApplied = false; + case ApplyStatus.AllChangesApplied: + break; + + case ApplyStatus.SomeChangesApplied: + client.Logger.LogWarning("Some changes not applied to {Name} because they are not supported by the runtime.", name); + break; + + case ApplyStatus.NoChangesApplied: + client.Logger.LogWarning("No changes applied to {Name} because they are not supported by the runtime.", name); + break; + } + } + } - var index = 0; - foreach (var status in results) + if (!anyFailure) { - var (client, name) = clients[index++]; + // all clients share the same loggers, pick any: + var logger = clients[0].client.Logger; + logger.Log(LogEvents.HotReloadSucceeded); - switch (status) + if (browserRefreshServer != null) { - case ApplyStatus.Failed: - anyFailure = true; - break; - - case ApplyStatus.AllChangesApplied: - anyChangeApplied = true; - allChangesApplied = true; - break; - - case ApplyStatus.SomeChangesApplied: - anyChangeApplied = true; - allChangesApplied = false; - client.Logger.LogWarning("Some changes not applied to {Name} because they are not supported by the runtime.", name); - break; - - case ApplyStatus.NoChangesApplied: - allChangesApplied = false; - client.Logger.LogWarning("No changes applied to {Name} because they are not supported by the runtime.", name); - break; + await browserRefreshServer.RefreshBrowserAsync(cancellationToken); } } - - return anyFailure ? ApplyStatus.Failed - : allChangesApplied ? ApplyStatus.AllChangesApplied - : anyChangeApplied ? ApplyStatus.SomeChangesApplied - : ApplyStatus.NoChangesApplied; } public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellationToken) @@ -119,6 +133,40 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation } } + public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject)> assets, CancellationToken cancellationToken) + { + if (browserRefreshServer != null) + { + await browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.relativeUrl), cancellationToken); + } + else + { + var updates = new List(); + + foreach (var (filePath, relativeUrl, assemblyName, isApplicationProject) in assets) + { + ImmutableArray content; + try + { + content = ImmutableCollectionsMarshal.AsImmutableArray(await File.ReadAllBytesAsync(filePath, cancellationToken)); + } + catch (Exception e) + { + ClientLogger.LogError("Failed to read file {FilePath}: {Message}", filePath, e.Message); + continue; + } + + updates.Add(new HotReloadStaticAssetUpdate( + assemblyName: assemblyName, + relativePath: relativeUrl, + content: content, + isApplicationProject)); + } + + await ApplyStaticAssetUpdatesAsync([.. updates], isProcessSuspended: false, cancellationToken); + } + } + public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) { if (clients is [var (singleClient, _)]) @@ -130,4 +178,7 @@ public async ValueTask ApplyStaticAssetUpdatesAsync(ImmutableArray c.client.ApplyStaticAssetUpdatesAsync(updates, isProcessSuspended, cancellationToken))); } } + + public ValueTask ReportCompilationErrorsInApplicationAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + => browserRefreshServer?.ReportCompilationErrorsInBrowserAsync(compilationErrors, cancellationToken) ?? ValueTask.CompletedTask; } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs index 5e5270f04750..94742392aab5 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs @@ -66,8 +66,6 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) _context.Logger.Log(MessageDescriptor.HotReloadEnabled with { Severity = MessageSeverity.Verbose }); } - await using var browserConnector = new BrowserRefreshServerFactory(_context.LoggerFactory, _context.EnvironmentOptions); - var browserLauncher = new BrowserLauncher(_context.Logger, _context.EnvironmentOptions); using var fileWatcher = new FileWatcher(_context.Logger, _context.EnvironmentOptions); for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++) @@ -118,8 +116,8 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); compilationHandler = new CompilationHandler(_context.LoggerFactory, _context.Logger, _context.ProcessRunner); - var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, browserConnector, _context.Options, _context.EnvironmentOptions); - var projectLauncher = new ProjectLauncher(_context, projectMap, browserConnector, browserLauncher, compilationHandler, iteration); + var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, _context.BrowserRefreshServerFactory, _context.Options, _context.EnvironmentOptions); + var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Logger); runtimeProcessLauncher = runtimeProcessLauncherFactory?.TryCreate(rootProject, projectLauncher, rootProjectOptions); diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 2a2c97c8801f..58c92878a515 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -11,8 +11,6 @@ namespace Microsoft.DotNet.Watch; internal sealed class ProjectLauncher( DotNetWatchContext context, ProjectNodeMap projectMap, - BrowserRefreshServerFactory browserConnector, - BrowserLauncher browserLauncher, CompilationHandler compilationHandler, int iteration) { @@ -47,7 +45,19 @@ public EnvironmentOptions EnvironmentOptions return null; } - var appModel = HotReloadAppModel.InferFromProject(projectNode, Logger, context.EnvironmentOptions); + var appModel = HotReloadAppModel.InferFromProject(context, projectNode); + + // create loggers that include project name in messages: + var projectDisplayName = projectNode.GetDisplayName(); + var clientLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.ClientLogComponentName, projectDisplayName); + var agentLogger = context.LoggerFactory.CreateLogger(HotReloadDotNetWatcher.AgentLogComponentName, projectDisplayName); + + var clients = await appModel.TryCreateClientsAsync(clientLogger, agentLogger, cancellationToken); + if (clients == null) + { + // error already reported + return null; + } var processSpec = new ProcessSpec { @@ -60,7 +70,6 @@ public EnvironmentOptions EnvironmentOptions // Stream output lines to the process output reporter. // The reporter synchronizes the output of the process with the logger output, // so that the printed lines don't interleave. - var projectDisplayName = projectNode.GetDisplayName(); processSpec.OnOutput += line => { context.ProcessOutputReporter.ReportOutput(context.ProcessOutputReporter.PrefixProcessOutput ? line with { Content = $"[{projectDisplayName}] {line.Content}" } : line); @@ -107,9 +116,27 @@ public EnvironmentOptions EnvironmentOptions } } - var browserRefreshServer = await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectNode, appModel, cancellationToken); - browserRefreshServer?.SetEnvironmentVariables(environmentBuilder); + clients.BrowserRefreshServer?.SetEnvironmentVariables(environmentBuilder); + + processSpec.Arguments = GetProcessArguments(projectOptions, environmentBuilder); + + // Attach trigger to the process that detects when the web server reports to the output that it's listening. + // Launches browser on the URL found in the process output for root projects. + context.BrowserLauncher.InstallBrowserLaunchTrigger(processSpec, projectNode, projectOptions, clients.BrowserRefreshServer, cancellationToken); + + return await compilationHandler.TrackRunningProjectAsync( + projectNode, + projectOptions, + namedPipeName, + clients, + processSpec, + restartOperation, + processTerminationSource, + cancellationToken); + } + private static IReadOnlyList GetProcessArguments(ProjectOptions projectOptions, EnvironmentVariablesBuilder environmentBuilder) + { var arguments = new List() { projectOptions.Command, @@ -123,23 +150,7 @@ public EnvironmentOptions EnvironmentOptions } arguments.AddRange(projectOptions.CommandArguments); - - processSpec.Arguments = arguments; - - // Attach trigger to the process that detects when the web server reports to the output that it's listening. - // Launches browser on the URL found in the process output for root projects. - browserLauncher.InstallBrowserLaunchTrigger(processSpec, projectNode, projectOptions, browserRefreshServer, cancellationToken); - - return await compilationHandler.TrackRunningProjectAsync( - projectNode, - projectOptions, - appModel, - namedPipeName, - browserRefreshServer, - processSpec, - restartOperation, - processTerminationSource, - cancellationToken); + return arguments; } public ValueTask TerminateProcessAsync(RunningProject project, CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs index 92ac153e3ad3..46ecd2c629a1 100644 --- a/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs +++ b/src/BuiltInTools/dotnet-watch/Process/RunningProject.cs @@ -5,7 +5,6 @@ using System.Collections.Immutable; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; -using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { @@ -15,8 +14,6 @@ internal sealed class RunningProject( ProjectGraphNode projectNode, ProjectOptions options, HotReloadClients clients, - ILogger logger, - BrowserRefreshServer? browserRefreshServer, Task runningProcess, int processId, CancellationTokenSource processExitedSource, @@ -27,10 +24,8 @@ internal sealed class RunningProject( { public readonly ProjectGraphNode ProjectNode = projectNode; public readonly ProjectOptions Options = options; - public readonly BrowserRefreshServer? BrowserRefreshServer = browserRefreshServer; public readonly HotReloadClients Clients = clients; public readonly ImmutableArray Capabilities = capabilities; - public readonly ILogger Logger = logger; public readonly Task RunningProcess = runningProcess; public readonly int ProcessId = processId; public readonly RestartOperation RestartOperation = restartOperation; diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 441387bc3810..7ce76326f073 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -193,7 +193,7 @@ internal async Task RunAsync() logger.LogInformation("Polling file watcher is enabled"); } - var context = CreateContext(processRunner); + await using var context = CreateContext(processRunner); if (isHotReloadEnabled) { @@ -227,16 +227,20 @@ internal async Task RunAsync() // internal for testing internal DotNetWatchContext CreateContext(ProcessRunner processRunner) { + var logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName); + return new() { ProcessOutputReporter = processOutputReporter, LoggerFactory = loggerFactory, - Logger = loggerFactory.CreateLogger(DotNetWatchContext.DefaultLogComponentName), + Logger = logger, BuildLogger = loggerFactory.CreateLogger(DotNetWatchContext.BuildLogComponentName), ProcessRunner = processRunner, Options = options.GlobalOptions, EnvironmentOptions = environmentOptions, RootProjectOptions = rootProjectOptions, + BrowserRefreshServerFactory = new BrowserRefreshServerFactory(), + BrowserLauncher = new BrowserLauncher(logger, environmentOptions), }; } diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index b694c8fd59dc..a9f3111b1127 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -189,7 +189,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor WaitingForChanges = Create("Waiting for changes", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor LaunchedProcess = Create("Launched '{0}' with arguments '{1}': process id {2}", Emoji.Launch, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadChangeHandled = Create("Hot reload change handled in {0}ms.", Emoji.HotReload, MessageSeverity.Verbose); - public static readonly MessageDescriptor HotReloadSucceeded = Create("Hot reload succeeded.", Emoji.HotReload, MessageSeverity.Output); + public static readonly MessageDescriptor HotReloadSucceeded = Create(LogEvents.HotReloadSucceeded, Emoji.HotReload); public static readonly MessageDescriptor UpdatesApplied = Create(LogEvents.UpdatesApplied, Emoji.HotReload); public static readonly MessageDescriptor Capabilities = Create(LogEvents.Capabilities, Emoji.HotReload); public static readonly MessageDescriptor WaitingForFileChangeBeforeRestarting = Create("Waiting for a file to change before restarting ...", Emoji.Wait, MessageSeverity.Warning); @@ -242,10 +242,10 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor HotReloadEnabled = Create("Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor PressCtrlRToRestart = Create("Press Ctrl+R to restart.", Emoji.LightBulb, MessageSeverity.Output); public static readonly MessageDescriptor HotReloadCanceledProcessExited = Create("Hot reload canceled because the process exited.", Emoji.HotReload, MessageSeverity.Verbose); - public static readonly MessageDescriptor HotReloadProfile_BlazorHosted = Create("HotReloadProfile: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.HotReload, MessageSeverity.Verbose); - public static readonly MessageDescriptor HotReloadProfile_BlazorWebAssembly = Create("HotReloadProfile: BlazorWebAssembly.", Emoji.HotReload, MessageSeverity.Verbose); - public static readonly MessageDescriptor HotReloadProfile_WebApplication = Create("HotReloadProfile: WebApplication.", Emoji.HotReload, MessageSeverity.Verbose); - public static readonly MessageDescriptor HotReloadProfile_Default = Create("HotReloadProfile: Default.", Emoji.HotReload, MessageSeverity.Verbose); + public static readonly MessageDescriptor ApplicationKind_BlazorHosted = Create("Application kind: BlazorHosted. '{0}' references BlazorWebAssembly project '{1}'.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor ApplicationKind_BlazorWebAssembly = Create("Application kind: BlazorWebAssembly.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, MessageSeverity.Output); diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index 9ff6ab24c422..dcf347a9df73 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -25,8 +25,6 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ChangedFile? changedFile = null; var buildEvaluator = new BuildEvaluator(context); - await using var browserConnector = new BrowserRefreshServerFactory(context.LoggerFactory, context.EnvironmentOptions); - var browserLauncher = new BrowserLauncher(context.Logger, context.EnvironmentOptions); for (var iteration = 0;;iteration++) { @@ -42,7 +40,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke { projectRootNode = evaluationResult.ProjectGraph.GraphRoots.Single(); var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, context.Logger); - staticFileHandler = new StaticFileHandler(context.Logger, projectMap, browserConnector); + staticFileHandler = new StaticFileHandler(context.Logger, projectMap, context.BrowserRefreshServerFactory); } else { @@ -64,8 +62,8 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke } }; - var browserRefreshServer = (projectRootNode != null) - ? await browserConnector.GetOrCreateBrowserRefreshServerAsync(projectRootNode, new DefaultAppModel(projectRootNode), shutdownCancellationToken) + var browserRefreshServer = projectRootNode != null && HotReloadAppModel.InferFromProject(context, projectRootNode) is WebApplicationAppModel webAppModel + ? await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(projectRootNode, webAppModel, shutdownCancellationToken) : null; browserRefreshServer?.SetEnvironmentVariables(environmentBuilder); @@ -73,7 +71,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke if (projectRootNode != null) { - browserLauncher.InstallBrowserLaunchTrigger(processSpec, projectRootNode, context.RootProjectOptions, browserRefreshServer, shutdownCancellationToken); + context.BrowserLauncher.InstallBrowserLaunchTrigger(processSpec, projectRootNode, context.RootProjectOptions, browserRefreshServer, shutdownCancellationToken); } // Reset for next run diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index fb228770dfee..15124f66b6f7 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -850,7 +850,7 @@ public async Task BlazorWasm_Restart() App.SendControlR(); - await App.WaitUntilOutputContains($"dotnet watch ⌚ Reloading browser."); + await App.WaitUntilOutputContains(MessageDescriptor.ReloadingBrowser); } [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/49928")] // "https://github.com/dotnet/sdk/issues/49307") @@ -868,7 +868,7 @@ public async Task BlazorWasmHosted() App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh); App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser); - App.AssertOutputContains("dotnet watch 🔥 HotReloadProfile: BlazorHosted"); + App.AssertOutputContains(MessageDescriptor.ApplicationKind_BlazorHosted); // client capabilities: App.AssertOutputContains($"dotnet watch ⌚ [blazorhosted ({tfm})] Project 'blazorwasm ({tfm})' specifies capabilities: 'Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType'"); diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index f6724e8ea9f1..0e9fb155bd3b 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -116,7 +116,8 @@ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string? serviceHolder.Value = s; }); - var watcher = new HotReloadDotNetWatcher(program.CreateContext(processRunner), console, runtimeProcessLauncherFactory: factory); + var context = program.CreateContext(processRunner); + var watcher = new HotReloadDotNetWatcher(context, console, runtimeProcessLauncherFactory: factory); var shutdownSource = new CancellationTokenSource(); var watchTask = Task.Run(async () => @@ -131,6 +132,10 @@ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string? Logger.WriteLine($"Unexpected exception {e}"); throw; } + finally + { + await context.DisposeAsync(); + } }, shutdownSource.Token); return new RunningWatcher(this, watcher, watchTask, reporter, console, serviceHolder, shutdownSource); diff --git a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs index 7a38d2880250..1efccba2eb22 100644 --- a/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs +++ b/test/dotnet-watch.Tests/Watch/BuildEvaluatorTests.cs @@ -25,7 +25,9 @@ private static DotNetWatchContext CreateContext(bool suppressMSBuildIncrementali ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), Options = new(), RootProjectOptions = TestOptions.ProjectOptions, - EnvironmentOptions = environmentOptions + EnvironmentOptions = environmentOptions, + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, environmentOptions), + BrowserRefreshServerFactory = new BrowserRefreshServerFactory() }; } diff --git a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs index 93be5fc3f9c1..a28d91c3423e 100644 --- a/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs +++ b/test/dotnet-watch.Tests/Watch/NoRestoreTests.cs @@ -23,6 +23,8 @@ private static DotNetWatchContext CreateContext(string[] args = null, Environmen Options = new(), RootProjectOptions = TestOptions.GetProjectOptions(args), EnvironmentOptions = environmentOptions, + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, environmentOptions), + BrowserRefreshServerFactory = new BrowserRefreshServerFactory() }; } From da751fa65f97295c57f59e5c54e5235208dfba75 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 29 Aug 2025 12:19:12 -0700 Subject: [PATCH 16/32] Add HotReloadClient.ConfigureLaunchEnvironment --- .../HotReloadClient/DefaultHotReloadClient.cs | 26 +++++++-- .../HotReloadClient/HotReloadClient.cs | 4 +- .../Utilities/EnvironmentUtilities.cs | 24 +++++++++ .../Browser/BrowserRefreshServer.cs | 31 ++++++++--- .../CommandLine/EnvironmentVariables.cs | 1 + .../EnvironmentVariablesBuilder.cs | 54 ++----------------- .../AppModels/BlazorWebAssemblyAppModel.cs | 3 -- .../BlazorWebAssemblyHostedAppModel.cs | 3 +- .../BlazorWebAssemblyHotReloadClient.cs | 7 ++- .../HotReload/AppModels/DefaultAppModel.cs | 4 +- .../HotReload/AppModels/HotReloadAppModel.cs | 25 +++------ .../AppModels/WebApplicationAppModel.cs | 6 ++- .../HotReload/AppModels/WebServerAppModel.cs | 3 +- .../HotReload/CompilationHandler.cs | 3 +- .../HotReload/HotReloadClients.cs | 14 ++++- .../dotnet-watch/Process/ProjectLauncher.cs | 46 ++++------------ .../dotnet-watch/Watch/DotNetWatcher.cs | 8 ++- .../HotReloadClientTests.cs | 7 ++- .../EnvironmentVariablesBuilderTests.cs | 40 -------------- 19 files changed, 131 insertions(+), 178 deletions(-) create mode 100644 src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs delete mode 100644 test/dotnet-watch.Tests/CommandLine/EnvironmentVariablesBuilderTests.cs diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index aac55b1a4f83..a239b4fd4957 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -15,12 +15,16 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.Watch; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload { - internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, bool enableStaticAssetUpdates) : HotReloadClient(logger, agentLogger) + internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger, string startupHookPath, bool enableStaticAssetUpdates) + : HotReloadClient(logger, agentLogger) { + private readonly string _namedPipeName = Guid.NewGuid().ToString("N"); + private Task>? _capabilitiesTask; private NamedPipeServerStream? _pipe; private bool _managedCodeUpdateFailedOrCancelled; @@ -49,14 +53,18 @@ private void DisposePipe() internal Task PendingUpdates => _pendingUpdates; - public override void InitiateConnection(string namedPipeName, CancellationToken cancellationToken) + // for testing + internal string NamedPipeName + => _namedPipeName; + + public override void InitiateConnection(CancellationToken cancellationToken) { #if NET var options = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly; #else var options = PipeOptions.Asynchronous; #endif - _pipe = new NamedPipeServerStream(namedPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, options); + _pipe = new NamedPipeServerStream(_namedPipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, options); // It is important to establish the connection (WaitForConnectionAsync) before we return, // otherwise the client wouldn't be able to connect. @@ -67,7 +75,7 @@ async Task> ConnectAsync() { try { - Logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", namedPipeName); + Logger.LogDebug("Waiting for application to connect to pipe {NamedPipeName}.", _namedPipeName); await _pipe.WaitForConnectionAsync(cancellationToken); @@ -110,6 +118,16 @@ private void RequireReadyForUpdates() throw new InvalidOperationException("Pipe has been disposed."); } + public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) + { + environmentBuilder[AgentEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + + // HotReload startup hook should be loaded before any other startup hooks: + environmentBuilder.InsertListItem(AgentEnvironmentVariables.DotNetStartupHooks, startupHookPath, Path.PathSeparator); + + environmentBuilder[AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName] = _namedPipeName; + } + public override Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) => GetCapabilitiesTask(); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs index c487fd62814d..f57f7c0e6e54 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs @@ -24,10 +24,12 @@ internal abstract class HotReloadClient(ILogger logger, ILogger agentLogger) : I public readonly ILogger Logger = logger; public readonly ILogger AgentLogger = agentLogger; + public abstract void ConfigureLaunchEnvironment(IDictionary environmentBuilder); + /// /// Initiates connection with the agent in the target process. /// - public abstract void InitiateConnection(string namedPipeName, CancellationToken cancellationToken); + public abstract void InitiateConnection(CancellationToken cancellationToken); /// /// Waits until the connection with the agent is established. diff --git a/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs b/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs new file mode 100644 index 000000000000..37d058e5b82c --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs @@ -0,0 +1,24 @@ +// 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; +using System.Collections.Generic; + +namespace Microsoft.DotNet.HotReload; + +internal static class EnvironmentUtilities +{ + public static void InsertListItem(this IDictionary environment, string key, string value, char separator) + { + if (!environment.TryGetValue(key, out var existingValue)) + { + environment[key] = value; + } + else if (existingValue.Split(separator).IndexOf(value) == -1) + { + environment[key] = value + separator + existingValue; + } + } +} diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index 689b1fc6b1b6..fe67065d920b 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.CodeAnalysis; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -40,6 +41,7 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable private readonly ILogger _logger; private readonly TaskCompletionSource _terminateWebSocket; private readonly TaskCompletionSource _browserConnected; + private readonly string _middlewareAssemblyPath; // initialized by StartAsync private IHost? _lazyServer; @@ -47,7 +49,7 @@ internal sealed class BrowserRefreshServer : IAsyncDisposable public readonly EnvironmentOptions Options; - public BrowserRefreshServer(EnvironmentOptions options, ILogger logger, ILoggerFactory loggerFactory) + public BrowserRefreshServer(EnvironmentOptions options, string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) { _rsa = RSA.Create(2048); Options = options; @@ -55,6 +57,7 @@ public BrowserRefreshServer(EnvironmentOptions options, ILogger logger, ILoggerF _logger = logger; _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _middlewareAssemblyPath = middlewareAssemblyPath; } public async ValueTask DisposeAsync() @@ -79,21 +82,35 @@ public async ValueTask DisposeAsync() _terminateWebSocket.TrySetResult(); } - public void SetEnvironmentVariables(EnvironmentVariablesBuilder environmentBuilder) + public void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { Debug.Assert(_lazyServer != null); Debug.Assert(_lazyServerUrls != null); - environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint, _lazyServerUrls); - environmentBuilder.SetVariable(EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey, GetServerKey()); + environmentBuilder[EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint] = _lazyServerUrls; + environmentBuilder[EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey] = GetServerKey(); - environmentBuilder.DotNetStartupHooks.Add(Path.Combine(AppContext.BaseDirectory, "middleware", "Microsoft.AspNetCore.Watch.BrowserRefresh.dll")); - environmentBuilder.AspNetCoreHostingStartupAssemblies.Add("Microsoft.AspNetCore.Watch.BrowserRefresh"); + environmentBuilder.InsertListItem( + EnvironmentVariables.Names.DotNetStartupHooks, + _middlewareAssemblyPath, + Path.PathSeparator); + + environmentBuilder.InsertListItem( + EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, + Path.GetFileName(_middlewareAssemblyPath), + EnvironmentVariables.Names.AspNetCoreHostingStartupAssembliesSeparator); + + // Note: + // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware + // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. + // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: + // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 + environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetModifiableAssemblies, "debug"); if (_logger.IsEnabled(LogLevel.Debug)) { // enable debug logging from middleware: - environmentBuilder.SetVariable("Logging__LogLevel__Microsoft.AspNetCore.Watch", "Debug"); + environmentBuilder["Logging__LogLevel__Microsoft.AspNetCore.Watch"] = "Debug"; } } diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs index 843d3b5dc22e..cb843d14481d 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs @@ -16,6 +16,7 @@ public static class Names public const string AspNetCoreHostingStartupAssemblies = "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"; public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; + public const char AspNetCoreHostingStartupAssembliesSeparator = ';'; public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs index 640389af9175..161f105c38b0 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs @@ -7,67 +7,21 @@ namespace Microsoft.DotNet.Watch { internal sealed class EnvironmentVariablesBuilder { - private static readonly char s_startupHooksSeparator = Path.PathSeparator; - private const char AssembliesSeparator = ';'; - - public List DotNetStartupHooks { get; } = []; - public List AspNetCoreHostingStartupAssemblies { get; } = []; - - /// - /// Environment variables set on the dotnet run process. - /// - private readonly Dictionary _variables = []; - - public static EnvironmentVariablesBuilder FromCurrentEnvironment() + public static IDictionary FromCurrentEnvironment() { - var builder = new EnvironmentVariablesBuilder(); + var builder = new Dictionary(); if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotNetStartupHooks) is { } dotnetStartupHooks) { - builder.DotNetStartupHooks.AddRange(dotnetStartupHooks.Split(s_startupHooksSeparator)); + builder[EnvironmentVariables.Names.DotNetStartupHooks] = dotnetStartupHooks; } if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies) is { } assemblies) { - builder.AspNetCoreHostingStartupAssemblies.AddRange(assemblies.Split(AssembliesSeparator)); + builder[EnvironmentVariables.Names.DotNetStartupHooks] = assemblies; } return builder; } - - public void SetVariable(string name, string value) - { - // should use AspNetCoreHostingStartupAssembliesVariable/DotNetStartupHookDirective - Debug.Assert(!name.Equals(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, StringComparison.OrdinalIgnoreCase)); - Debug.Assert(!name.Equals(EnvironmentVariables.Names.DotNetStartupHooks, StringComparison.OrdinalIgnoreCase)); - - _variables[name] = value; - } - - public void SetProcessEnvironmentVariables(ProcessSpec processSpec) - { - foreach (var (name, value) in GetEnvironment()) - { - processSpec.EnvironmentVariables.Add(name, value); - } - } - - public IEnumerable<(string name, string value)> GetEnvironment() - { - foreach (var (name, value) in _variables) - { - yield return (name, value); - } - - if (DotNetStartupHooks is not []) - { - yield return (EnvironmentVariables.Names.DotNetStartupHooks, string.Join(s_startupHooksSeparator, DotNetStartupHooks)); - } - - if (AspNetCoreHostingStartupAssemblies is not []) - { - yield return (EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, string.Join(AssembliesSeparator, AspNetCoreHostingStartupAssemblies)); - } - } } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs index 5ea4161525f0..f228f8c75206 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs @@ -15,9 +15,6 @@ namespace Microsoft.DotNet.Watch; internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, ProjectGraphNode clientProject) : WebApplicationAppModel(context) { - // Blazor WASM does not need agent injected as all changes are applied in the browser, the process being launched is a dev server. - public override ProjectGraphNode? AgentInjectionProject => null; - public override ProjectGraphNode LaunchingProject => clientProject; public override bool RequiresBrowserRefresh => true; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs index b5c6618a5e72..43206ad955fb 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -17,7 +17,6 @@ namespace Microsoft.DotNet.Watch; internal sealed class BlazorWebAssemblyHostedAppModel(DotNetWatchContext context, ProjectGraphNode clientProject, ProjectGraphNode serverProject) : WebApplicationAppModel(context) { - public override ProjectGraphNode? AgentInjectionProject => serverProject; public override ProjectGraphNode LaunchingProject => serverProject; public override bool RequiresBrowserRefresh => true; @@ -29,7 +28,7 @@ protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger return new( [ (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, Context.EnvironmentOptions, clientProject), "client"), - (new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: false), "host") + (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: false), "host") ], browserRefreshServer); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs index 885d9c6f46ec..967f62e11835 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs @@ -37,7 +37,12 @@ public override void Dispose() // Do nothing. } - public override void InitiateConnection(string namedPipeName, CancellationToken cancellationToken) + public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) + { + // the environment is configued via browser refesh server + } + + public override void InitiateConnection(CancellationToken cancellationToken) { } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs index 97fee11efa70..300236d7250a 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/DefaultAppModel.cs @@ -12,8 +12,6 @@ namespace Microsoft.DotNet.Watch; /// internal sealed class DefaultAppModel(ProjectGraphNode project) : HotReloadAppModel { - public override ProjectGraphNode? AgentInjectionProject => project; - public override ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken) - => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true), browserRefreshServer: null)); + => new(new HotReloadClients(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(project), enableStaticAssetUpdates: true), browserRefreshServer: null)); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs index a955c4ae57cc..37502de871c7 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/HotReloadAppModel.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.Diagnostics.CodeAnalysis; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -10,33 +9,21 @@ namespace Microsoft.DotNet.Watch; internal abstract partial class HotReloadAppModel() { - /// - /// Project to inject agent into. - /// - public abstract ProjectGraphNode? AgentInjectionProject { get; } - public abstract ValueTask TryCreateClientsAsync(ILogger clientLogger, ILogger agentLogger, CancellationToken cancellationToken); - /// - /// Returns true and the path to the client agent implementation binary if the application needs the agent to be injected. - /// - public bool TryGetStartupHookPath([NotNullWhen(true)] out string? path) - { - if (AgentInjectionProject == null) - { - path = null; - return false; - } + protected static string GetInjectedAssemblyPath(string targetFramework, string assemblyName) + => Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", targetFramework, assemblyName + ".dll"); - var hookTargetFramework = AgentInjectionProject.GetTargetFramework() switch + public static string GetStartupHookPath(ProjectGraphNode project) + { + var hookTargetFramework = project.GetTargetFramework() switch { // Note: Hot Reload is only supported on net6.0+ "net6.0" or "net7.0" or "net8.0" or "net9.0" => "net6.0", _ => "net10.0", }; - path = Path.Combine(Path.GetDirectoryName(typeof(HotReloadAppModel).Assembly.Location)!, "hotreload", hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier.dll"); - return true; + return GetInjectedAssemblyPath(hookTargetFramework, "Microsoft.Extensions.DotNetDeltaApplier"); } public static HotReloadAppModel InferFromProject(DotNetWatchContext context, ProjectGraphNode projectNode) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs index 399e8d2c5b5b..139f4ba2bf1d 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs @@ -11,6 +11,7 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot { // This needs to be in sync with the version BrowserRefreshMiddleware is compiled against. private static readonly Version s_minimumSupportedVersion = Versions.Version6_0; + private const string MiddlewareTargetFramework = "net6.0"; public DotNetWatchContext Context => context; @@ -35,13 +36,16 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot return CreateClients(clientLogger, agentLogger, browserRefreshServer); } + private static string GetMiddlewareAssemblyPath() + => GetInjectedAssemblyPath(MiddlewareTargetFramework, "Microsoft.AspNetCore.Watch.BrowserRefresh"); + public BrowserRefreshServer? TryCreateRefreshServer(ProjectGraphNode projectNode) { var logger = context.LoggerFactory.CreateLogger(BrowserRefreshServer.ServerLogComponentName, projectNode.GetDisplayName()); if (IsServerSupported(projectNode, logger)) { - return new BrowserRefreshServer(context.EnvironmentOptions, logger, context.LoggerFactory); + return new BrowserRefreshServer(context.EnvironmentOptions, GetMiddlewareAssemblyPath(), logger, context.LoggerFactory); } return null; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs index 531dad2153c4..d30703b87530 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebServerAppModel.cs @@ -10,12 +10,11 @@ namespace Microsoft.DotNet.Watch; internal sealed class WebServerAppModel(DotNetWatchContext context, ProjectGraphNode serverProject) : WebApplicationAppModel(context) { - public override ProjectGraphNode? AgentInjectionProject => serverProject; public override ProjectGraphNode LaunchingProject => serverProject; public override bool RequiresBrowserRefresh => false; protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) - => new(new DefaultHotReloadClient(clientLogger, agentLogger, enableStaticAssetUpdates: true), browserRefreshServer); + => new(new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: true), browserRefreshServer); } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index 9b862a20ad4f..cedccb2351f1 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -94,7 +94,6 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) public async Task TrackRunningProjectAsync( ProjectGraphNode projectNode, ProjectOptions projectOptions, - string namedPipeName, HotReloadClients clients, ProcessSpec processSpec, RestartOperation restartOperation, @@ -109,7 +108,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) // It is important to first create the named pipe connection (Hot Reload client is the named pipe server) // and then start the process (named pipe client). Otherwise, the connection would fail. - clients.InitiateConnection(namedPipeName, processCommunicationCancellationSource.Token); + clients.InitiateConnection(processCommunicationCancellationSource.Token); processSpec.OnExit += (_, _) => { diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs index 2cde159491b7..963eadc9f0ee 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs @@ -38,11 +38,21 @@ public ILogger ClientLogger public ILogger AgentLogger => clients.First().client.AgentLogger; - internal void InitiateConnection(string namedPipeName, CancellationToken cancellationToken) + internal void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { foreach (var (client, _) in clients) { - client.InitiateConnection(namedPipeName, cancellationToken); + client.ConfigureLaunchEnvironment(environmentBuilder); + } + + browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder); + } + + internal void InitiateConnection(CancellationToken cancellationToken) + { + foreach (var (client, _) in clients) + { + client.InitiateConnection(cancellationToken); } } diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index 58c92878a515..e7b8ae44a5fa 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -75,48 +75,25 @@ public EnvironmentOptions EnvironmentOptions context.ProcessOutputReporter.ReportOutput(context.ProcessOutputReporter.PrefixProcessOutput ? line with { Content = $"[{projectDisplayName}] {line.Content}" } : line); }; - var environmentBuilder = EnvironmentVariablesBuilder.FromCurrentEnvironment(); - var namedPipeName = Guid.NewGuid().ToString(); + var environmentBuilder = new Dictionary(); + // initialize with project settings: foreach (var (name, value) in projectOptions.LaunchEnvironmentVariables) { - // ignore dotnet-watch reserved variables -- these shouldn't be set by the project - if (name.Equals(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, StringComparison.OrdinalIgnoreCase) || - name.Equals(EnvironmentVariables.Names.DotNetStartupHooks, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - environmentBuilder.SetVariable(name, value); + environmentBuilder[name] = value; } // override any project settings: - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotnetWatch, "1"); - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotnetWatchIteration, (Iteration + 1).ToString(CultureInfo.InvariantCulture)); + environmentBuilder[EnvironmentVariables.Names.DotnetWatch] = "1"; + environmentBuilder[EnvironmentVariables.Names.DotnetWatchIteration] = (Iteration + 1).ToString(CultureInfo.InvariantCulture); - // Note: - // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware - // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. - // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: - // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetModifiableAssemblies, "debug"); - - if (appModel.TryGetStartupHookPath(out var startupHookPath)) + if (Logger.IsEnabled(LogLevel.Debug)) { - // HotReload startup hook should be loaded before any other startup hooks: - environmentBuilder.DotNetStartupHooks.Insert(0, startupHookPath); - - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetWatchHotReloadNamedPipeName, namedPipeName); - - if (context.Options.Verbose) - { - environmentBuilder.SetVariable( - EnvironmentVariables.Names.HotReloadDeltaClientLogMessages, - (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"); - } + environmentBuilder[EnvironmentVariables.Names.HotReloadDeltaClientLogMessages] = + (EnvironmentOptions.SuppressEmojis ? Emoji.Default : Emoji.Agent).GetLogMessagePrefix() + $"[{projectDisplayName}]"; } - clients.BrowserRefreshServer?.SetEnvironmentVariables(environmentBuilder); + clients.ConfigureLaunchEnvironment(environmentBuilder); processSpec.Arguments = GetProcessArguments(projectOptions, environmentBuilder); @@ -127,7 +104,6 @@ public EnvironmentOptions EnvironmentOptions return await compilationHandler.TrackRunningProjectAsync( projectNode, projectOptions, - namedPipeName, clients, processSpec, restartOperation, @@ -135,7 +111,7 @@ public EnvironmentOptions EnvironmentOptions cancellationToken); } - private static IReadOnlyList GetProcessArguments(ProjectOptions projectOptions, EnvironmentVariablesBuilder environmentBuilder) + private static IReadOnlyList GetProcessArguments(ProjectOptions projectOptions, IDictionary environmentBuilder) { var arguments = new List() { @@ -143,7 +119,7 @@ private static IReadOnlyList GetProcessArguments(ProjectOptions projectO "--no-build" }; - foreach (var (name, value) in environmentBuilder.GetEnvironment()) + foreach (var (name, value) in environmentBuilder) { arguments.Add("-e"); arguments.Add($"{name}={value}"); diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index dcf347a9df73..0d4b3b40157a 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -66,8 +66,12 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ? await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(projectRootNode, webAppModel, shutdownCancellationToken) : null; - browserRefreshServer?.SetEnvironmentVariables(environmentBuilder); - environmentBuilder.SetProcessEnvironmentVariables(processSpec); + browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder); + + foreach (var (name, value) in environmentBuilder) + { + processSpec.EnvironmentVariables.Add(name, value); + } if (projectRootNode != null) { diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs index 14d752efa439..1031aa39be35 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/HotReloadClientTests.cs @@ -17,15 +17,14 @@ private sealed class Test : IAsyncDisposable public Test(ITestOutputHelper output, TestHotReloadAgent agent) { - var pipeName = Guid.NewGuid().ToString(); Logger = new TestLogger(output); AgentLogger = new TestLogger(output); - Client = new DefaultHotReloadClient(Logger, AgentLogger, enableStaticAssetUpdates: true); + Client = new DefaultHotReloadClient(Logger, AgentLogger, startupHookPath: "", enableStaticAssetUpdates: true); _cancellationSource = new CancellationTokenSource(); - Client.InitiateConnection(pipeName, CancellationToken.None); - var listener = new PipeListener(pipeName, agent, log: _ => { }, connectionTimeoutMS: Timeout.Infinite); + Client.InitiateConnection(CancellationToken.None); + var listener = new PipeListener(Client.NamedPipeName, agent, log: _ => { }, connectionTimeoutMS: Timeout.Infinite); _listenerTaskFactory = Task.Run(() => listener.Listen(_cancellationSource.Token)); } diff --git a/test/dotnet-watch.Tests/CommandLine/EnvironmentVariablesBuilderTests.cs b/test/dotnet-watch.Tests/CommandLine/EnvironmentVariablesBuilderTests.cs deleted file mode 100644 index 509cadb17854..000000000000 --- a/test/dotnet-watch.Tests/CommandLine/EnvironmentVariablesBuilderTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Watch.UnitTests -{ - public class EnvironmentVariablesBuilderTests - { - [Fact] - public void Value() - { - var builder = new EnvironmentVariablesBuilder(); - builder.DotNetStartupHooks.Add("a"); - builder.AspNetCoreHostingStartupAssemblies.Add("b"); - - var env = builder.GetEnvironment(); - AssertEx.SequenceEqual( - [ - ("DOTNET_STARTUP_HOOKS", "a"), - ("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "b") - ], env); - } - - [Fact] - public void MultipleValues() - { - var builder = new EnvironmentVariablesBuilder(); - builder.DotNetStartupHooks.Add("a1"); - builder.DotNetStartupHooks.Add("a2"); - builder.AspNetCoreHostingStartupAssemblies.Add("b1"); - builder.AspNetCoreHostingStartupAssemblies.Add("b2"); - - var env = builder.GetEnvironment(); - AssertEx.SequenceEqual( - [ - ("DOTNET_STARTUP_HOOKS", $"a1{Path.PathSeparator}a2"), - ("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "b1;b2") - ], env); - } - } -} From df902635fd6316b9da3221aaa6be3f87e153da20 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 29 Aug 2025 13:05:30 -0700 Subject: [PATCH 17/32] Add BrowserRefreshServer abstraction to Client package --- .../HotReloadClient/DefaultHotReloadClient.cs | 1 - .../HotReloadClient/Logging/LogEvents.cs | 7 + .../Logging/LoggingUtilities.cs | 2 +- ...oft.DotNet.HotReload.Client.Package.csproj | 1 + .../Utilities/ArrayBufferWriter.cs | 273 +++++++++++++ .../Utilities/RSAExtensions.cs | 141 +++++++ .../Utilities/ResponseAction.cs | 12 + .../HotReloadClient/Utilities/VoidResult.cs | 10 + .../Web/AbstractBrowserRefreshServer.cs | 330 +++++++++++++++ .../Web/AbstractWebServerHost.cs | 21 + .../Web}/BrowserConnection.cs | 26 +- .../Web/IEnvironmentVariableBuilder.cs | 12 + .../Web/MiddlewareEnvironmentVariables.cs | 43 ++ .../Browser/BrowserRefreshServer.cs | 384 +++--------------- .../Browser/BrowserRefreshServerFactory.cs | 1 + .../dotnet-watch/Browser/WebServerHost.cs | 17 + .../CommandLine/EnvironmentVariables.cs | 6 - .../EnvironmentVariablesBuilder.cs | 27 -- .../dotnet-watch/Process/ProjectLauncher.cs | 1 + src/BuiltInTools/dotnet-watch/UI/IReporter.cs | 13 +- .../dotnet-watch/Watch/DotNetWatcher.cs | 2 +- .../dotnet-watch/dotnet-watch.csproj | 2 +- .../CommandLine/EnvironmentUtilitiesTests.cs | 20 + 23 files changed, 968 insertions(+), 384 deletions(-) create mode 100644 src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs create mode 100644 src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs create mode 100644 src/BuiltInTools/HotReloadClient/Utilities/ResponseAction.cs create mode 100644 src/BuiltInTools/HotReloadClient/Utilities/VoidResult.cs create mode 100644 src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs create mode 100644 src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs rename src/BuiltInTools/{dotnet-watch/Browser => HotReloadClient/Web}/BrowserConnection.cs (74%) create mode 100644 src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs create mode 100644 src/BuiltInTools/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs create mode 100644 src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs delete mode 100644 src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs create mode 100644 test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index a239b4fd4957..25f5c06b3598 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -15,7 +15,6 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.DotNet.Watch; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.HotReload diff --git a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs index ccd40263e594..a74e5097a6d5 100644 --- a/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs @@ -23,4 +23,11 @@ public static void Log(this ILogger logger, LogEvent logEvent, params object[] a public static readonly LogEvent UpdatesApplied = Create(LogLevel.Debug, "Updates applied: {0} out of {1}."); public static readonly LogEvent Capabilities = Create(LogLevel.Debug, "Capabilities: '{1}'."); public static readonly LogEvent HotReloadSucceeded = Create(LogLevel.Information, "Hot reload succeeded."); + public static readonly LogEvent RefreshingBrowser = Create(LogLevel.Debug, "Refreshing browser."); + public static readonly LogEvent ReloadingBrowser = Create(LogLevel.Debug, "Reloading browser."); + public static readonly LogEvent NoBrowserConnected = Create(LogLevel.Debug, "No browser is connected."); + public static readonly LogEvent FailedToReceiveResponseFromConnectedBrowser = Create(LogLevel.Debug, "Failed to receive response from a connected browser."); + public static readonly LogEvent UpdatingDiagnostics = Create(LogLevel.Debug, "Updating diagnostics."); + public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'."); + public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}."); } diff --git a/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs b/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs index f9a87f0ebb18..28b60f04fa7b 100644 --- a/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs +++ b/src/BuiltInTools/HotReloadClient/Logging/LoggingUtilities.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch; +namespace Microsoft.DotNet.HotReload; internal static class LoggingUtilities { diff --git a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index bb03be41d290..9e8dc75ecb97 100644 --- a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -34,6 +34,7 @@ + diff --git a/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs new file mode 100644 index 000000000000..e3e5c9a6633d --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs @@ -0,0 +1,273 @@ +// 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; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +#if NET + +[assembly: TypeForwardedTo(typeof(ArrayBufferWriter<>))] + +#else + +namespace System.Buffers; + +/// +/// Represents a heap-based, array-backed output sink into which data can be written. +/// +internal sealed class ArrayBufferWriter : IBufferWriter +{ + // Copy of Array.MaxLength. + // Used by projects targeting .NET Framework. + private const int ArrayMaxLength = 0x7FFFFFC7; + + private const int DefaultInitialBufferSize = 256; + + private T[] _buffer; + private int _index; + + /// + /// Creates an instance of an , in which data can be written to, + /// with the default initial capacity. + /// + public ArrayBufferWriter() + { + _buffer = Array.Empty(); + _index = 0; + } + + /// + /// Creates an instance of an , in which data can be written to, + /// with an initial capacity specified. + /// + /// The minimum capacity with which to initialize the underlying buffer. + /// + /// Thrown when is not positive (i.e. less than or equal to 0). + /// + public ArrayBufferWriter(int initialCapacity) + { + if (initialCapacity <= 0) + throw new ArgumentException(null, nameof(initialCapacity)); + + _buffer = new T[initialCapacity]; + _index = 0; + } + + /// + /// Returns the data written to the underlying buffer so far, as a . + /// + public ReadOnlyMemory WrittenMemory => _buffer.AsMemory(0, _index); + + /// + /// Returns the data written to the underlying buffer so far, as a . + /// + public ReadOnlySpan WrittenSpan => _buffer.AsSpan(0, _index); + + /// + /// Returns the data written to the underlying buffer so far, as a . + /// + public ArraySegment WrittenSegment => new(_buffer, 0, _index); + + /// + /// Returns the amount of data written to the underlying buffer so far. + /// + public int WrittenCount => _index; + + /// + /// Returns the total amount of space within the underlying buffer. + /// + public int Capacity => _buffer.Length; + + /// + /// Returns the amount of space available that can still be written into without forcing the underlying buffer to grow. + /// + public int FreeCapacity => _buffer.Length - _index; + + /// + /// Clears the data written to the underlying buffer. + /// + /// + /// + /// You must reset or clear the before trying to re-use it. + /// + /// + /// The method is faster since it only sets to zero the writer's index + /// while the method additionally zeroes the content of the underlying buffer. + /// + /// + /// + public void Clear() + { + Debug.Assert(_buffer.Length >= _index); + _buffer.AsSpan(0, _index).Clear(); + _index = 0; + } + + /// + /// Resets the data written to the underlying buffer without zeroing its content. + /// + /// + /// + /// You must reset or clear the before trying to re-use it. + /// + /// + /// If you reset the writer using the method, the underlying buffer will not be cleared. + /// + /// + /// + public void ResetWrittenCount() => _index = 0; + + /// + /// Notifies that amount of data was written to the output / + /// + /// + /// Thrown when is negative. + /// + /// + /// Thrown when attempting to advance past the end of the underlying buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + public void Advance(int count) + { + if (count < 0) + throw new ArgumentException(null, nameof(count)); + + if (_index > _buffer.Length - count) + ThrowInvalidOperationException_AdvancedTooFar(_buffer.Length); + + _index += count; + } + + /// + /// Returns a to write to that is at least the requested length (specified by ). + /// If no is provided (or it's equal to 0), some non-empty buffer is returned. + /// + /// + /// Thrown when is negative. + /// + /// + /// + /// This will never return an empty . + /// + /// + /// There is no guarantee that successive calls will return the same buffer or the same-sized buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + /// + /// If you reset the writer using the method, this method may return a non-cleared . + /// + /// + /// If you clear the writer using the method, this method will return a with its content zeroed. + /// + /// + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsMemory(_index); + } + + public ArraySegment GetArraySegment(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return new ArraySegment(_buffer, _index, _buffer.Length - _index); + } + + /// + /// Returns a to write to that is at least the requested length (specified by ). + /// If no is provided (or it's equal to 0), some non-empty buffer is returned. + /// + /// + /// Thrown when is negative. + /// + /// + /// + /// This will never return an empty . + /// + /// + /// There is no guarantee that successive calls will return the same buffer or the same-sized buffer. + /// + /// + /// You must request a new buffer after calling Advance to continue writing more data and cannot write to a previously acquired buffer. + /// + /// + /// If you reset the writer using the method, this method may return a non-cleared . + /// + /// + /// If you clear the writer using the method, this method will return a with its content zeroed. + /// + /// + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsSpan(_index); + } + + private void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint < 0) + throw new ArgumentException(nameof(sizeHint)); + + if (sizeHint == 0) + { + sizeHint = 1; + } + + if (sizeHint > FreeCapacity) + { + int currentLength = _buffer.Length; + + // Attempt to grow by the larger of the sizeHint and double the current size. + int growBy = Math.Max(sizeHint, currentLength); + + if (currentLength == 0) + { + growBy = Math.Max(growBy, DefaultInitialBufferSize); + } + + int newSize = currentLength + growBy; + + if ((uint)newSize > int.MaxValue) + { + // Attempt to grow to ArrayMaxLength. + uint needed = (uint)(currentLength - FreeCapacity + sizeHint); + Debug.Assert(needed > currentLength); + + if (needed > ArrayMaxLength) + { + ThrowOutOfMemoryException(needed); + } + + newSize = ArrayMaxLength; + } + + Array.Resize(ref _buffer, newSize); + } + + Debug.Assert(FreeCapacity > 0 && FreeCapacity >= sizeHint); + } + + private static void ThrowInvalidOperationException_AdvancedTooFar(int capacity) + { + throw new InvalidOperationException($"Buffer writer advanced too far: {capacity}"); + } + + private static void ThrowOutOfMemoryException(uint capacity) + { +#pragma warning disable CA2201 // Do not raise reserved exception types + throw new OutOfMemoryException($"Buffer maximum size exceeded: {capacity}"); +#pragma warning restore CA2201 + } +} + +#endif diff --git a/src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs b/src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs new file mode 100644 index 000000000000..edf3a5330560 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +#nullable enable + +#if !NET + +using System; +using System.IO; +using System.Security.Cryptography; + +namespace Microsoft.DotNet.HotReload; + +internal static class RSAExtensions +{ + /// + /// Export the public key in the X.509 SubjectPublicKeyInfo representation which is equivalent to the .NET Core RSA api + /// ExportSubjectPublicKeyInfo. + /// + /// Algorithm from https://stackoverflow.com/a/28407693 or https://github.com/Azure/azure-powershell/blob/main/src/KeyVault/KeyVault/Helpers/JwkHelper.cs + /// + internal static string ExportSubjectPublicKeyInfoAsBase64(this RSA rsa) + { + var writer = new StringWriter(); + ExportPublicKey(rsa.ExportParameters(includePrivateParameters: false), writer); + return writer.ToString(); + } + + private static void ExportPublicKey(RSAParameters parameters, TextWriter outputStream) + { + if (parameters.Exponent == null || parameters.Modulus == null) + { + throw new ArgumentException($"{parameters} does not contain valid public key information"); + } + + using (var stream = new MemoryStream()) + { + var writer = new BinaryWriter(stream); + writer.Write((byte)0x30); // SEQUENCE + using (var innerStream = new MemoryStream()) + { + var innerWriter = new BinaryWriter(innerStream); + innerWriter.Write((byte)0x30); // SEQUENCE + EncodeLength(innerWriter, 13); + innerWriter.Write((byte)0x06); // OBJECT IDENTIFIER + var rsaEncryptionOid = new byte[] { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 }; + EncodeLength(innerWriter, rsaEncryptionOid.Length); + innerWriter.Write(rsaEncryptionOid); + innerWriter.Write((byte)0x05); // NULL + EncodeLength(innerWriter, 0); + innerWriter.Write((byte)0x03); // BIT STRING + using (var bitStringStream = new MemoryStream()) + { + var bitStringWriter = new BinaryWriter(bitStringStream); + bitStringWriter.Write((byte)0x00); // # of unused bits + bitStringWriter.Write((byte)0x30); // SEQUENCE + using (var paramsStream = new MemoryStream()) + { + var paramsWriter = new BinaryWriter(paramsStream); + EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus + EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent + var paramsLength = (int)paramsStream.Length; + EncodeLength(bitStringWriter, paramsLength); + bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength); + } + var bitStringLength = (int)bitStringStream.Length; + EncodeLength(innerWriter, bitStringLength); + innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength); + } + var length = (int)innerStream.Length; + EncodeLength(writer, length); + writer.Write(innerStream.GetBuffer(), 0, length); + } + + var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray(); + for (var i = 0; i < base64.Length; i += 64) + { + outputStream.Write(base64, i, Math.Min(64, base64.Length - i)); + } + } + } + + private static void EncodeLength(BinaryWriter stream, int length) + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be non-negative"); + if (length < 0x80) + { + // Short form + stream.Write((byte)length); + } + else + { + // Long form + var temp = length; + var bytesRequired = 0; + while (temp > 0) + { + temp >>= 8; + bytesRequired++; + } + stream.Write((byte)(bytesRequired | 0x80)); + for (var i = bytesRequired - 1; i >= 0; i--) + { + stream.Write((byte)(length >> (8 * i) & 0xff)); + } + } + } + + private static void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true) + { + stream.Write((byte)0x02); // INTEGER + var prefixZeros = 0; + for (var i = 0; i < value.Length; i++) + { + if (value[i] != 0) break; + prefixZeros++; + } + if (value.Length - prefixZeros == 0) + { + EncodeLength(stream, 1); + stream.Write((byte)0); + } + else + { + if (forceUnsigned && value[prefixZeros] > 0x7f) + { + // Add a prefix zero to force unsigned if the MSB is 1 + EncodeLength(stream, value.Length - prefixZeros + 1); + stream.Write((byte)0); + } + else + { + EncodeLength(stream, value.Length - prefixZeros); + } + for (var i = prefixZeros; i < value.Length; i++) + { + stream.Write(value[i]); + } + } + } +} +#endif diff --git a/src/BuiltInTools/HotReloadClient/Utilities/ResponseAction.cs b/src/BuiltInTools/HotReloadClient/Utilities/ResponseAction.cs new file mode 100644 index 000000000000..64a2cbcb7183 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/ResponseAction.cs @@ -0,0 +1,12 @@ +// 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; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +// Workaround for ReadOnlySpan not working as a generic parameter on .NET Framework +public delegate void ResponseAction(ReadOnlySpan data, ILogger logger); diff --git a/src/BuiltInTools/HotReloadClient/Utilities/VoidResult.cs b/src/BuiltInTools/HotReloadClient/Utilities/VoidResult.cs new file mode 100644 index 000000000000..d024e9cb07e9 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/VoidResult.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. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct VoidResult +{ +} diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs new file mode 100644 index 000000000000..5a59fa1ce84a --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -0,0 +1,330 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.WebSockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +/// +/// Communicates with aspnetcore-browser-refresh.js loaded in the browser. +/// Associated with a project instance. +/// +internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) : IAsyncDisposable +{ + public const string ServerLogComponentName = "BrowserRefreshServer"; + + private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); + private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); + private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); + + private readonly List _activeConnections = []; + private readonly TaskCompletionSource _browserConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private readonly RSA _rsa = CreateRsa(); + + // initialized by StartAsync + private AbstractWebServerHost? _lazyHost; + + public virtual async ValueTask DisposeAsync() + { + BrowserConnection[] connectionsToDispose; + lock (_activeConnections) + { + connectionsToDispose = [.. _activeConnections]; + _activeConnections.Clear(); + } + + foreach (var connection in connectionsToDispose) + { + connection.ServerLogger.LogDebug("Disconnecting from browser."); + await connection.DisposeAsync(); + } + + _lazyHost?.Dispose(); + _rsa.Dispose(); + } + + protected abstract ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken); + protected abstract bool SuppressTimeouts { get; } + + private static RSA CreateRsa() + { + var rsa = RSA.Create(); + rsa.KeySize = 2048; + return rsa; + } + + public void ConfigureLaunchEnvironment(IDictionary builder) + { + Debug.Assert(_lazyHost != null); + + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSEndPoint] = string.Join(",", _lazyHost.EndPoints); + +#if NET + var publicKey = Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); +#else + var publicKey = _rsa.ExportSubjectPublicKeyInfoAsBase64(); +#endif + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSKey] = publicKey; + + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadVirtualDirectory] = _lazyHost.VirtualDirectory; + + builder.InsertListItem(MiddlewareEnvironmentVariables.DotNetStartupHooks, middlewareAssemblyPath, Path.PathSeparator); + builder.InsertListItem(MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssemblies, Path.GetFileNameWithoutExtension(middlewareAssemblyPath), MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssembliesSeparator); + + // Note: + // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware + // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. + // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: + // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 + builder[MiddlewareEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + + if (logger.IsEnabled(LogLevel.Debug)) + { + // enable debug logging from middleware: + builder[MiddlewareEnvironmentVariables.LoggingLevel] = "Debug"; + } + } + + public async ValueTask StartAsync(CancellationToken cancellationToken) + { + Debug.Assert(_lazyHost == null); + + _lazyHost = await CreateAndStartHostAsync(cancellationToken); + logger.Log(LogEvents.RefreshServerRunningAt, string.Join(",", _lazyHost.EndPoints)); + } + + protected BrowserConnection OnBrowserConnected(WebSocket clientSocket, string? subProtocol) + { + var sharedSecret = (subProtocol != null) + ? Convert.ToBase64String(_rsa.Decrypt(Convert.FromBase64String(WebUtility.UrlDecode(subProtocol)), RSAEncryptionPadding.OaepSHA256)) + : null; + + var connection = new BrowserConnection(clientSocket, sharedSecret, loggerFactory); + + lock (_activeConnections) + { + _activeConnections.Add(connection); + } + + _browserConnected.TrySetResult(default); + return connection; + } + + /// + /// For testing. + /// + internal void EmulateClientConnected() + { + _browserConnected.TrySetResult(default); + } + + public async Task WaitForClientConnectionAsync(CancellationToken cancellationToken) + { + using var progressCancellationSource = new CancellationTokenSource(); + + // It make take a while to connect since the app might need to build first. + // Indicate progress in the output. Start with 60s and then report progress every 10s. + var firstReportSeconds = TimeSpan.FromSeconds(60); + var nextReportSeconds = TimeSpan.FromSeconds(10); + + var reportDelayInSeconds = firstReportSeconds; + var connectionAttemptReported = false; + + var progressReportingTask = Task.Run(async () => + { + while (!progressCancellationSource.Token.IsCancellationRequested) + { + await Task.Delay(SuppressTimeouts ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + + connectionAttemptReported = true; + reportDelayInSeconds = nextReportSeconds; + logger.LogInformation("Connecting to the browser ..."); + } + }, progressCancellationSource.Token); + + // Work around lack of Task.WaitAsync(cancellationToken) on .NET Framework: + cancellationToken.Register(() => _browserConnected.SetCanceled()); + + try + { + await _browserConnected.Task; + } + finally + { + progressCancellationSource.Cancel(); + } + + if (connectionAttemptReported) + { + logger.LogInformation("Browser connection established."); + } + } + + private IReadOnlyCollection GetOpenBrowserConnections() + { + lock (_activeConnections) + { + return [.. _activeConnections.Where(b => b.ClientSocket.State == WebSocketState.Open)]; + } + } + + private async ValueTask DisposeClosedBrowserConnectionsAsync() + { + List? lazyConnectionsToDispose = null; + + lock (_activeConnections) + { + var j = 0; + for (var i = 0; i < _activeConnections.Count; i++) + { + var connection = _activeConnections[i]; + if (connection.ClientSocket.State == WebSocketState.Open) + { + _activeConnections[j++] = connection; + } + else + { + lazyConnectionsToDispose ??= []; + lazyConnectionsToDispose.Add(connection); + } + } + + _activeConnections.RemoveRange(j, _activeConnections.Count - j); + } + + if (lazyConnectionsToDispose != null) + { + foreach (var connection in lazyConnectionsToDispose) + { + await connection.DisposeAsync(); + } + } + } + + public static ReadOnlyMemory SerializeJson(TValue value) + => JsonSerializer.SerializeToUtf8Bytes(value, s_jsonSerializerOptions); + + public static TValue DeserializeJson(ReadOnlySpan value) + => JsonSerializer.Deserialize(value, s_jsonSerializerOptions) ?? throw new InvalidDataException("Unexpected null object"); + + public ValueTask SendJsonMessageAsync(TValue value, CancellationToken cancellationToken) + => SendAsync(SerializeJson(value), cancellationToken); + + public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) + { + logger.Log(LogEvents.ReloadingBrowser); + return SendAsync(s_reloadMessage, cancellationToken); + } + + public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) + => SendAsync(s_waitMessage, cancellationToken); + + private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) + => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); + + public async ValueTask SendAndReceiveAsync( + Func? request, + ResponseAction? response, + CancellationToken cancellationToken) + { + var responded = false; + var openConnections = GetOpenBrowserConnections(); + + foreach (var connection in openConnections) + { + if (request != null) + { + var requestValue = request(connection.SharedSecret); + var requestBytes = requestValue is ReadOnlyMemory bytes ? bytes : SerializeJson(requestValue); + + if (!await connection.TrySendMessageAsync(requestBytes, cancellationToken)) + { + continue; + } + } + + if (response != null && !await connection.TryReceiveMessageAsync(response, cancellationToken)) + { + continue; + } + + responded = true; + } + + if (openConnections.Count == 0) + { + logger.Log(LogEvents.NoBrowserConnected); + } + else if (response != null && !responded) + { + logger.Log(LogEvents.FailedToReceiveResponseFromConnectedBrowser); + } + + await DisposeClosedBrowserConnectionsAsync(); + } + + public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) + { + logger.Log(LogEvents.RefreshingBrowser); + return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + } + + public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) + { + logger.Log(LogEvents.UpdatingDiagnostics); + if (compilationErrors.IsEmpty) + { + return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); + } + else + { + return SendJsonMessageAsync(new HotReloadDiagnostics { Diagnostics = compilationErrors }, cancellationToken); + } + } + + public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) + { + // Serialize all requests sent to a single server: + foreach (var relativeUrl in relativeUrls) + { + logger.Log(LogEvents.SendingStaticAssetUpdateRequest, relativeUrl); + var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions); + await SendAsync(message, cancellationToken); + } + } + + private readonly struct AspNetCoreHotReloadApplied + { + public string Type => "AspNetCoreHotReloadApplied"; + } + + private readonly struct HotReloadDiagnostics + { + public string Type => "HotReloadDiagnosticsv1"; + + public IEnumerable Diagnostics { get; init; } + } + + private readonly struct UpdateStaticFileMessage + { + public string Type => "UpdateStaticFile"; + public string Path { get; init; } + } +} diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs new file mode 100644 index 000000000000..f42bfa62bf32 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.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. + +#nullable enable + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal abstract class AbstractWebServerHost(ImmutableArray endPoints, string virtualDirectory) : IDisposable +{ + public ImmutableArray EndPoints { get; } = endPoints; + public string VirtualDirectory => virtualDirectory; + + public abstract Task StartAsync(CancellationToken cancellation); + + public abstract void Dispose(); +} diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnection.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs similarity index 74% rename from src/BuiltInTools/dotnet-watch/Browser/BrowserConnection.cs rename to src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs index ea63f03b728f..b51081d2b06e 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserConnection.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs @@ -1,12 +1,16 @@ // 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; using System.Buffers; using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch; +namespace Microsoft.DotNet.HotReload; internal readonly struct BrowserConnection : IAsyncDisposable { @@ -21,6 +25,8 @@ namespace Microsoft.DotNet.Watch; public ILogger ServerLogger { get; } public ILogger AgentLogger { get; } + public readonly TaskCompletionSource Disconnected = new(TaskCreationOptions.RunContinuationsAsynchronously); + public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFactory loggerFactory) { ClientSocket = clientSocket; @@ -39,14 +45,20 @@ public async ValueTask DisposeAsync() await ClientSocket.CloseOutputAsync(WebSocketCloseStatus.Empty, null, default); ClientSocket.Dispose(); + Disconnected.TrySetResult(default); ServerLogger.LogDebug("Disconnected."); } internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) { +#if NET + var data = messageBytes; +#else + var data = new ArraySegment(messageBytes.ToArray()); +#endif try { - await ClientSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + await ClientSocket.SendAsync(data, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); } catch (Exception e) when (e is not OperationCanceledException) { @@ -57,16 +69,22 @@ internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageB return true; } - internal async ValueTask TryReceiveMessageAsync(Action, ILogger> receiver, CancellationToken cancellationToken) + internal async ValueTask TryReceiveMessageAsync(ResponseAction receiver, CancellationToken cancellationToken) { var writer = new ArrayBufferWriter(initialCapacity: 1024); while (true) { +#if NET ValueWebSocketReceiveResult result; + var data = writer.GetMemory(); +#else + WebSocketReceiveResult result; + var data = writer.GetArraySegment(); +#endif try { - result = await ClientSocket.ReceiveAsync(writer.GetMemory(), cancellationToken); + result = await ClientSocket.ReceiveAsync(data, cancellationToken); } catch (Exception e) when (e is not OperationCanceledException) { diff --git a/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs b/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs new file mode 100644 index 000000000000..db397932a321 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal interface IEnvironmentVariableBuilder +{ + void SetValue(string name, string value); + void AppendValue(string name, string value); +} diff --git a/src/BuiltInTools/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs b/src/BuiltInTools/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs new file mode 100644 index 000000000000..ea4754a54e0d --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/MiddlewareEnvironmentVariables.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal static class MiddlewareEnvironmentVariables +{ + /// + /// dotnet runtime environment variable used to load middleware assembly into the web server process. + /// https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-environment-variables#dotnet_startup_hooks + /// + public const string DotNetStartupHooks = "DOTNET_STARTUP_HOOKS"; + + /// + /// dotnet runtime environment variable. + /// + public const string DotNetModifiableAssemblies = "DOTNET_MODIFIABLE_ASSEMBLIES"; + + /// + /// Simple names of assemblies that implement middleware components to be added to the web server. + /// + public const string AspNetCoreHostingStartupAssemblies = "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"; + public const char AspNetCoreHostingStartupAssembliesSeparator = ';'; + + /// + /// Comma-separated list of WebSocket end points to communicate with browser refresh client. + /// + public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; + + public const string AspNetCoreAutoReloadVirtualDirectory = "ASPNETCORE_AUTO_RELOAD_VDIR"; + + /// + /// Public key to use to communicate with browser refresh client. + /// + public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; + + /// + /// Variable used to set the logging level of the middleware logger. + /// + public const string LoggingLevel = "Logging__LogLevel__Microsoft.AspNetCore.Watch"; +} diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs index fe67065d920b..d113f0d30dee 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs @@ -1,19 +1,15 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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.Net; -using System.Net.WebSockets; using System.Security.Cryptography; -using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; -using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,116 +17,25 @@ namespace Microsoft.DotNet.Watch; -/// -/// Communicates with aspnetcore-browser-refresh.js loaded in the browser. -/// Associated with a project instance. -/// -internal sealed class BrowserRefreshServer : IAsyncDisposable +internal sealed class BrowserRefreshServer(EnvironmentOptions options, string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) + : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { - public const string ServerLogComponentName = nameof(BrowserRefreshServer); - - private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); - private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); - private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); - private static bool? s_lazyTlsSupported; - private readonly List _activeConnections = []; - private readonly RSA _rsa; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly TaskCompletionSource _terminateWebSocket; - private readonly TaskCompletionSource _browserConnected; - private readonly string _middlewareAssemblyPath; - - // initialized by StartAsync - private IHost? _lazyServer; - private string? _lazyServerUrls; + protected override bool SuppressTimeouts + => options.TestFlags != TestFlags.None; - public readonly EnvironmentOptions Options; - - public BrowserRefreshServer(EnvironmentOptions options, string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) + protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - _rsa = RSA.Create(2048); - Options = options; - _loggerFactory = loggerFactory; - _logger = logger; - _terminateWebSocket = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _browserConnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _middlewareAssemblyPath = middlewareAssemblyPath; - } - - public async ValueTask DisposeAsync() - { - _rsa.Dispose(); - - BrowserConnection[] connectionsToDispose; - lock (_activeConnections) - { - connectionsToDispose = [.. _activeConnections]; - _activeConnections.Clear(); - } - - foreach (var connection in connectionsToDispose) - { - connection.ServerLogger.LogDebug("Disconnecting from browser."); - await connection.DisposeAsync(); - } - - _lazyServer?.Dispose(); - - _terminateWebSocket.TrySetResult(); - } - - public void ConfigureLaunchEnvironment(IDictionary environmentBuilder) - { - Debug.Assert(_lazyServer != null); - Debug.Assert(_lazyServerUrls != null); - - environmentBuilder[EnvironmentVariables.Names.AspNetCoreAutoReloadWSEndPoint] = _lazyServerUrls; - environmentBuilder[EnvironmentVariables.Names.AspNetCoreAutoReloadWSKey] = GetServerKey(); - - environmentBuilder.InsertListItem( - EnvironmentVariables.Names.DotNetStartupHooks, - _middlewareAssemblyPath, - Path.PathSeparator); - - environmentBuilder.InsertListItem( - EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, - Path.GetFileName(_middlewareAssemblyPath), - EnvironmentVariables.Names.AspNetCoreHostingStartupAssembliesSeparator); + var hostName = options.AutoReloadWebSocketHostName ?? "127.0.0.1"; - // Note: - // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware - // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. - // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: - // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 - environmentBuilder.SetVariable(EnvironmentVariables.Names.DotNetModifiableAssemblies, "debug"); + var supportsTls = await IsTlsSupportedAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Debug)) - { - // enable debug logging from middleware: - environmentBuilder["Logging__LogLevel__Microsoft.AspNetCore.Watch"] = "Debug"; - } - } - - public string GetServerKey() - => Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); - - public async ValueTask StartAsync(CancellationToken cancellationToken) - { - Debug.Assert(_lazyServer == null); - Debug.Assert(_lazyServerUrls == null); - - var hostName = Options.AutoReloadWebSocketHostName ?? "127.0.0.1"; - - var supportsTLS = await SupportsTlsAsync(cancellationToken); - - _lazyServer = new HostBuilder() + var host = new HostBuilder() .ConfigureWebHost(builder => { builder.UseKestrel(); - if (supportsTLS) + if (supportsTls) { builder.UseUrls($"https://{hostName}:0", $"http://{hostName}:0"); } @@ -147,14 +52,39 @@ public async ValueTask StartAsync(CancellationToken cancellationToken) }) .Build(); - await _lazyServer.StartAsync(cancellationToken); + await host.StartAsync(cancellationToken); // URLs are only available after the server has started. - _lazyServerUrls = string.Join(',', GetServerUrls(_lazyServer)); - _logger.LogDebug("Refresh server running at {Urls}.", _lazyServerUrls); + return new WebServerHost(host, GetServerUrls(host)); + } + + private async ValueTask IsTlsSupportedAsync(CancellationToken cancellationToken) + { + var result = s_lazyTlsSupported; + if (result.HasValue) + { + return result.Value; + } + + try + { + using var process = Process.Start(options.MuxerPath, "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; } - private IEnumerable GetServerUrls(IHost server) + private ImmutableArray GetServerUrls(IHost server) { var serverUrls = server.Services .GetRequiredService() @@ -164,14 +94,14 @@ private IEnumerable GetServerUrls(IHost server) Debug.Assert(serverUrls != null); - if (Options.AutoReloadWebSocketHostName is null) + if (options.AutoReloadWebSocketHostName is null) { - return serverUrls.Select(s => + return [.. serverUrls.Select(s => s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) - .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal)); + .Replace("https://127.0.0.1", "wss://localhost", StringComparison.Ordinal))]; } - return + return [ serverUrls .First() @@ -188,234 +118,14 @@ private async Task WebSocketRequestAsync(HttpContext context) return; } - string? subProtocol = null; - string? sharedSecret = null; - if (context.WebSockets.WebSocketRequestedProtocols.Count == 1) + if (context.WebSockets.WebSocketRequestedProtocols is not [var subProtocol]) { - subProtocol = context.WebSockets.WebSocketRequestedProtocols[0]; - var subProtocolBytes = Convert.FromBase64String(WebUtility.UrlDecode(subProtocol)); - sharedSecret = Convert.ToBase64String(_rsa.Decrypt(subProtocolBytes, RSAEncryptionPadding.OaepSHA256)); + subProtocol = null; } var clientSocket = await context.WebSockets.AcceptWebSocketAsync(subProtocol); - var connection = new BrowserConnection(clientSocket, sharedSecret, _loggerFactory); - - lock (_activeConnections) - { - _activeConnections.Add(connection); - } - - _browserConnected.TrySetResult(); - await _terminateWebSocket.Task; - } - - /// - /// For testing. - /// - internal void EmulateClientConnected() - { - _browserConnected.TrySetResult(); - } - - public async Task WaitForClientConnectionAsync(CancellationToken cancellationToken) - { - using var progressCancellationSource = new CancellationTokenSource(); - - if (!_browserConnected.Task.IsCompleted) - { - var progressReportingTask = Task.Run(async () => - { - while (!progressCancellationSource.Token.IsCancellationRequested) - { - _logger.LogInformation("Waiting for browser connection..."); - await Task.Delay(Options.TestFlags != TestFlags.None ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), progressCancellationSource.Token); - } - }, progressCancellationSource.Token); - - try - { - await _browserConnected.Task.WaitAsync(cancellationToken); - } - finally - { - progressCancellationSource.Cancel(); - } - } - - _logger.LogInformation("Browser connection established."); - } - - private IReadOnlyCollection GetOpenBrowserConnections() - { - lock (_activeConnections) - { - return [.. _activeConnections.Where(b => b.ClientSocket.State == WebSocketState.Open)]; - } - } - - private async ValueTask DisposeClosedBrowserConnectionsAsync() - { - List? lazyConnectionsToDispose = null; - - lock (_activeConnections) - { - var j = 0; - for (var i = 0; i < _activeConnections.Count; i++) - { - var connection = _activeConnections[i]; - if (connection.ClientSocket.State == WebSocketState.Open) - { - _activeConnections[j++] = connection; - } - else - { - lazyConnectionsToDispose ??= []; - lazyConnectionsToDispose.Add(connection); - } - } - - _activeConnections.RemoveRange(j, _activeConnections.Count - j); - } - - if (lazyConnectionsToDispose != null) - { - foreach (var connection in lazyConnectionsToDispose) - { - await connection.DisposeAsync(); - } - } - } - - public static ReadOnlyMemory SerializeJson(TValue value) - => JsonSerializer.SerializeToUtf8Bytes(value, s_jsonSerializerOptions); - - public static TValue DeserializeJson(ReadOnlySpan value) - => JsonSerializer.Deserialize(value, s_jsonSerializerOptions) ?? throw new InvalidDataException("Unexpected null object"); - - public ValueTask SendJsonMessageAsync(TValue value, CancellationToken cancellationToken) - => SendAsync(SerializeJson(value), cancellationToken); - - public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) - { - _logger.Log(MessageDescriptor.ReloadingBrowser); - return SendAsync(s_reloadMessage, cancellationToken); - } - - public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) - => SendAsync(s_waitMessage, cancellationToken); - - private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) - => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); - - public async ValueTask SendAndReceiveAsync( - Func? request, - Action, ILogger>? response, - CancellationToken cancellationToken) - { - var responded = false; - var openConnections = GetOpenBrowserConnections(); - - foreach (var connection in openConnections) - { - if (request != null) - { - var requestValue = request(connection.SharedSecret); - var requestBytes = requestValue is ReadOnlyMemory bytes ? bytes : SerializeJson(requestValue); - - if (!await connection.TrySendMessageAsync(requestBytes, cancellationToken)) - { - continue; - } - } - - if (response != null && !await connection.TryReceiveMessageAsync(response, cancellationToken)) - { - continue; - } - - responded = true; - } - - if (openConnections.Count == 0) - { - _logger.Log(MessageDescriptor.NoBrowserConnected); - } - else if (response != null && !responded) - { - _logger.Log(MessageDescriptor.FailedToReceiveResponseFromConnectedBrowser); - } - - await DisposeClosedBrowserConnectionsAsync(); - } - - private async Task SupportsTlsAsync(CancellationToken cancellationToken) - { - var result = s_lazyTlsSupported; - if (result.HasValue) - { - return result.Value; - } - - try - { - using var process = Process.Start(Options.MuxerPath, "dev-certs https --check --quiet"); - await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - result = process.ExitCode == 0; - } - catch - { - result = false; - } - - s_lazyTlsSupported = result; - return result.Value; - } - - public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) - { - _logger.Log(MessageDescriptor.RefreshingBrowser); - return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); - } - public ValueTask ReportCompilationErrorsInBrowserAsync(ImmutableArray compilationErrors, CancellationToken cancellationToken) - { - _logger.Log(MessageDescriptor.UpdatingDiagnostics); - if (compilationErrors.IsEmpty) - { - return SendJsonMessageAsync(new AspNetCoreHotReloadApplied(), cancellationToken); - } - else - { - return SendJsonMessageAsync(new HotReloadDiagnostics { Diagnostics = compilationErrors }, cancellationToken); - } - } - - public async ValueTask UpdateStaticAssetsAsync(IEnumerable relativeUrls, CancellationToken cancellationToken) - { - // Serialize all requests sent to a single server: - foreach (var relativeUrl in relativeUrls) - { - _logger.Log(MessageDescriptor.SendingStaticAssetUpdateRequest, relativeUrl); - var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = relativeUrl }, s_jsonSerializerOptions); - await SendAsync(message, cancellationToken); - } - } - - private readonly struct AspNetCoreHotReloadApplied - { - public string Type => "AspNetCoreHotReloadApplied"; - } - - private readonly struct HotReloadDiagnostics - { - public string Type => "HotReloadDiagnosticsv1"; - - public IEnumerable Diagnostics { get; init; } - } - - private readonly struct UpdateStaticFileMessage - { - public string Type => "UpdateStaticFile"; - public string Path { get; init; } + var connection = OnBrowserConnected(clientSocket, subProtocol); + await connection.Disconnected.Task; } } diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs index 242cc459bfec..a777426e072b 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; diff --git a/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs b/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs new file mode 100644 index 000000000000..c0e285b212af --- /dev/null +++ b/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.Extensions.Hosting; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class WebServerHost(IHost host, ImmutableArray endPoints) + : AbstractWebServerHost(endPoints, virtualDirectory: "/") +{ + public override void Dispose() + => host.Dispose(); + + public override Task StartAsync(CancellationToken cancellation) + => host.StartAsync(cancellation); +} diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs index cb843d14481d..c222eb9a3f73 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariables.cs @@ -12,12 +12,6 @@ public static class Names public const string DotnetLaunchProfile = "DOTNET_LAUNCH_PROFILE"; - public const string AspNetCoreUrls = "ASPNETCORE_URLS"; - public const string AspNetCoreHostingStartupAssemblies = "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"; - public const string AspNetCoreAutoReloadWSEndPoint = "ASPNETCORE_AUTO_RELOAD_WS_ENDPOINT"; - public const string AspNetCoreAutoReloadWSKey = "ASPNETCORE_AUTO_RELOAD_WS_KEY"; - public const char AspNetCoreHostingStartupAssembliesSeparator = ';'; - public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; public const string DotNetModifiableAssemblies = HotReload.AgentEnvironmentVariables.DotNetModifiableAssemblies; diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs b/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs deleted file mode 100644 index 161f105c38b0..000000000000 --- a/src/BuiltInTools/dotnet-watch/CommandLine/EnvironmentVariablesBuilder.cs +++ /dev/null @@ -1,27 +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 System.Diagnostics; - -namespace Microsoft.DotNet.Watch -{ - internal sealed class EnvironmentVariablesBuilder - { - public static IDictionary FromCurrentEnvironment() - { - var builder = new Dictionary(); - - if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.DotNetStartupHooks) is { } dotnetStartupHooks) - { - builder[EnvironmentVariables.Names.DotNetStartupHooks] = dotnetStartupHooks; - } - - if (Environment.GetEnvironmentVariable(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies) is { } assemblies) - { - builder[EnvironmentVariables.Names.DotNetStartupHooks] = assemblies; - } - - return builder; - } - } -} diff --git a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs index e7b8ae44a5fa..3299710fd18f 100644 --- a/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; diff --git a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs index a9f3111b1127..c6f64394da88 100644 --- a/src/BuiltInTools/dotnet-watch/UI/IReporter.cs +++ b/src/BuiltInTools/dotnet-watch/UI/IReporter.cs @@ -206,11 +206,12 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = Create("Using browser-refresh middleware", Emoji.Default, MessageSeverity.Verbose); public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_SuppressedViaEnvironmentVariable = Create("Skipping configuring browser-refresh middleware since its refresh server suppressed via environment variable {0}.", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor SkippingConfiguringBrowserRefresh_TargetFrameworkNotSupported = Create("Skipping configuring browser-refresh middleware since the target framework version is not supported. For more information see 'https://aka.ms/dotnet/watch/unsupported-tfm'.", Emoji.Watch, MessageSeverity.Warning); - public static readonly MessageDescriptor UpdatingDiagnostics = Create("Updating diagnostics.", Emoji.Default, MessageSeverity.Verbose); - public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create("Failed to receive response from a connected browser.", Emoji.Default, MessageSeverity.Verbose); - public static readonly MessageDescriptor NoBrowserConnected = Create("No browser is connected.", Emoji.Default, MessageSeverity.Verbose); - public static readonly MessageDescriptor RefreshingBrowser = Create("Refreshing browser.", Emoji.Default, MessageSeverity.Verbose); - public static readonly MessageDescriptor ReloadingBrowser = Create("Reloading browser.", Emoji.Default, MessageSeverity.Verbose); + public static readonly MessageDescriptor UpdatingDiagnostics = Create(LogEvents.UpdatingDiagnostics, Emoji.Default); + public static readonly MessageDescriptor FailedToReceiveResponseFromConnectedBrowser = Create(LogEvents.FailedToReceiveResponseFromConnectedBrowser, Emoji.Default); + public static readonly MessageDescriptor NoBrowserConnected = Create(LogEvents.NoBrowserConnected, Emoji.Default); + public static readonly MessageDescriptor RefreshingBrowser = Create(LogEvents.RefreshingBrowser, Emoji.Default); + public static readonly MessageDescriptor ReloadingBrowser = Create(LogEvents.ReloadingBrowser, Emoji.Default); + public static readonly MessageDescriptor RefreshServerRunningAt = Create(LogEvents.RefreshServerRunningAt, Emoji.Default); public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, MessageSeverity.Verbose); public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, MessageSeverity.Verbose); @@ -234,7 +235,7 @@ public MessageDescriptor WithSeverityWhen(MessageSeverity severity, bool conditi public static readonly MessageDescriptor HotReloadOfScopedCssPartiallySucceeded = Create("Hot reload of scoped css partially succeeded: {0} project(s) out of {1} were updated.", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor HotReloadOfScopedCssFailed = Create("Hot reload of scoped css failed.", Emoji.Error, MessageSeverity.Error); public static readonly MessageDescriptor HotReloadOfStaticAssetsSucceeded = Create("Hot reload of static assets succeeded.", Emoji.HotReload, MessageSeverity.Output); - public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create("Sending static asset update request to connected browsers: '{0}'.", Emoji.Refresh, MessageSeverity.Verbose); + public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default); public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, MessageSeverity.Verbose); public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, MessageSeverity.Output); public static readonly MessageDescriptor UnableToApplyChanges = Create("Unable to apply changes due to compilation errors.", Emoji.HotReload, MessageSeverity.Output); diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index 0d4b3b40157a..eee08bd88bb2 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -21,7 +21,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke context.Logger.LogDebug("MSBuild incremental optimizations suppressed."); } - var environmentBuilder = EnvironmentVariablesBuilder.FromCurrentEnvironment(); + var environmentBuilder = new Dictionary(); ChangedFile? changedFile = null; var buildEvaluator = new BuildEvaluator(context); diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj index d86872ff8d49..9d089001e2b2 100644 --- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj +++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj @@ -66,7 +66,7 @@ true false TargetFramework;TargetFrameworks - middleware\Microsoft.AspNetCore.Watch.BrowserRefresh.dll + hotreload\net6.0\Microsoft.AspNetCore.Watch.BrowserRefresh.dll PreserveNewest diff --git a/test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs b/test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs new file mode 100644 index 000000000000..b57736c7e599 --- /dev/null +++ b/test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs @@ -0,0 +1,20 @@ +// 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.Watch.UnitTests; + +namespace Microsoft.DotNet.HotReload.UnitTests; + +public class EnvironmentUtilitiesTests +{ + [Fact] + public void MultipleValues() + { + var builder = new Dictionary(); + builder.InsertListItem("X", "a", separator: ';'); + builder.InsertListItem("X", "b", separator: ';'); + builder.InsertListItem("X", "a", separator: ';'); + + AssertEx.SequenceEqual([KeyValuePair.Create("X", "b;a")], builder); + } +} From 04c9bdf9c079116c471e65c363e22a97717db0bb Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 29 Aug 2025 14:44:44 -0700 Subject: [PATCH 18/32] Move WebAssemblyHotReloadClient to shared package --- ...oft.AspNetCore.Watch.BrowserRefresh.csproj | 2 +- .../Web/WebAssemblyHotReloadClient.cs} | 37 ++++++++++++------- .../AppModels/BlazorWebAssemblyAppModel.cs | 2 +- .../BlazorWebAssemblyHostedAppModel.cs | 2 +- .../AppModels/WebApplicationAppModel.cs | 9 +++++ testEnvironments.json | 17 +++++++++ 6 files changed, 53 insertions(+), 16 deletions(-) rename src/BuiltInTools/{dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs => HotReloadClient/Web/WebAssemblyHotReloadClient.cs} (88%) create mode 100644 testEnvironments.json diff --git a/src/BuiltInTools/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj b/src/BuiltInTools/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj index 81f623562fcf..efa0b352ef7d 100644 --- a/src/BuiltInTools/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj +++ b/src/BuiltInTools/BrowserRefresh/Microsoft.AspNetCore.Watch.BrowserRefresh.csproj @@ -2,7 +2,7 @@ net6.0 MicrosoftAspNetCore diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs similarity index 88% rename from src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs rename to src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 967f62e11835..3701da3b7b4b 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -1,15 +1,28 @@ // 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; using System.Buffers; +using System.Collections.Generic; using System.Collections.Immutable; -using Microsoft.Build.Graph; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch { - internal sealed class BlazorWebAssemblyHotReloadClient(ILogger logger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, EnvironmentOptions environmentOptions, ProjectGraphNode project) + internal sealed class WebAssemblyHotReloadClient( + ILogger logger, + ILogger agentLogger, + AbstractBrowserRefreshServer browserRefreshServer, + ImmutableArray projectHotReloadCapabilities, + Version projectTargetFrameworkVersion, + bool suppressBrowserRequestsForTesting) : HotReloadClient(logger, agentLogger) { private static readonly ImmutableArray s_defaultCapabilities60 = @@ -52,15 +65,13 @@ public override async Task WaitForConnectionEstablishedAsync(CancellationToken c public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) { - var capabilities = project.GetWebAssemblyCapabilities().ToImmutableArray(); + var capabilities = projectHotReloadCapabilities; if (capabilities.IsEmpty) { - var targetFramework = project.GetTargetFrameworkVersion(); - - Logger.LogDebug("Using capabilities based on project target framework: '{TargetFramework}'.", targetFramework); + Logger.LogDebug("Using capabilities based on project target framework version: '{Version}'.", projectTargetFrameworkVersion); - capabilities = targetFramework?.Major switch + capabilities = projectTargetFrameworkVersion.Major switch { 9 => s_defaultCapabilities90, 8 => s_defaultCapabilities80, @@ -71,7 +82,7 @@ public override Task> GetUpdateCapabilitiesAsync(Cancella } else { - Logger.LogDebug("Project specifies capabilities: '{Capabilities}'", string.Join(' ', capabilities)); + Logger.LogDebug("Project specifies capabilities: '{Capabilities}'", string.Join(" ", capabilities)); } return Task.FromResult(capabilities); @@ -85,9 +96,9 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr return ApplyStatus.NoChangesApplied; } - if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)) + // When testing abstract away the browser and pretend all changes have been applied: + if (suppressBrowserRequestsForTesting) { - // When testing abstract away the browser and pretend all changes have been applied: return ApplyStatus.AllChangesApplied; } @@ -123,7 +134,7 @@ await browserRefreshServer.SendAndReceiveAsync( Deltas = deltas, ResponseLoggingLevel = (int)loggingLevel }, - response: isProcessSuspended ? null : (value, logger) => + response: isProcessSuspended ? null : new ResponseAction((value, logger) => { if (ProcessUpdateResponse(value, logger)) { @@ -133,7 +144,7 @@ await browserRefreshServer.SendAndReceiveAsync( { anyFailure = true; } - }, + }), cancellationToken); if (isProcessSuspended) @@ -180,7 +191,7 @@ private async ValueTask ProcessPendingUpdatesAsync(CancellationToken cancellatio private static bool ProcessUpdateResponse(ReadOnlySpan value, ILogger logger) { - var data = BrowserRefreshServer.DeserializeJson(value); + var data = AbstractBrowserRefreshServer.DeserializeJson(value); foreach (var entry in data.Log) { diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs index f228f8c75206..ea3480f979f6 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyAppModel.cs @@ -22,6 +22,6 @@ internal sealed class BlazorWebAssemblyAppModel(DotNetWatchContext context, Proj protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer? browserRefreshServer) { Debug.Assert(browserRefreshServer != null); - return new(new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, Context.EnvironmentOptions, clientProject), browserRefreshServer); + return new(CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), browserRefreshServer); } } diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs index 43206ad955fb..12108762305b 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/BlazorWebAssemblyHostedAppModel.cs @@ -27,7 +27,7 @@ protected override HotReloadClients CreateClients(ILogger clientLogger, ILogger return new( [ - (new BlazorWebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, Context.EnvironmentOptions, clientProject), "client"), + (CreateWebAssemblyClient(clientLogger, agentLogger, browserRefreshServer, clientProject), "client"), (new DefaultHotReloadClient(clientLogger, agentLogger, GetStartupHookPath(serverProject), enableStaticAssetUpdates: false), "host") ], browserRefreshServer); diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs index 139f4ba2bf1d..9e3039bd56e8 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Microsoft.Build.Graph; using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; @@ -36,6 +37,14 @@ internal abstract class WebApplicationAppModel(DotNetWatchContext context) : Hot return CreateClients(clientLogger, agentLogger, browserRefreshServer); } + protected WebAssemblyHotReloadClient CreateWebAssemblyClient(ILogger clientLogger, ILogger agentLogger, BrowserRefreshServer browserRefreshServer, ProjectGraphNode clientProject) + { + var capabilities = clientProject.GetWebAssemblyCapabilities().ToImmutableArray(); + var targetFramework = clientProject.GetTargetFrameworkVersion() ?? throw new InvalidOperationException("Project doesn't define TargetFrameworkVersion"); + + return new WebAssemblyHotReloadClient(clientLogger, agentLogger, browserRefreshServer, capabilities, targetFramework, context.EnvironmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser)); + } + private static string GetMiddlewareAssemblyPath() => GetInjectedAssemblyPath(MiddlewareTargetFramework, "Microsoft.AspNetCore.Watch.BrowserRefresh"); diff --git a/testEnvironments.json b/testEnvironments.json new file mode 100644 index 000000000000..a110b57df138 --- /dev/null +++ b/testEnvironments.json @@ -0,0 +1,17 @@ +{ + "version": "1", + "environments": [ + // See https://aka.ms/remotetesting for more details + // about how to configure remote environments. + //{ + // "name": "WSL Ubuntu", + // "type": "wsl", + // "wslDistribution": "Ubuntu" + //}, + //{ + // "name": "Docker dotnet/sdk", + // "type": "docker", + // "dockerImage": "mcr.microsoft.com/dotnet/sdk" + //} + ] +} \ No newline at end of file From 2190db8765996bca8858c43b36b2fbc63bc0fe0f Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 29 Aug 2025 16:07:39 -0700 Subject: [PATCH 19/32] Cleanup --- .../Web/IEnvironmentVariableBuilder.cs | 12 ------------ .../Web/WebAssemblyHotReloadClient.cs | 3 +-- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs diff --git a/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs b/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs deleted file mode 100644 index db397932a321..000000000000 --- a/src/BuiltInTools/HotReloadClient/Web/IEnvironmentVariableBuilder.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable enable - -namespace Microsoft.DotNet.HotReload; - -internal interface IEnvironmentVariableBuilder -{ - void SetValue(string name, string value); - void AppendValue(string name, string value); -} diff --git a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 3701da3b7b4b..0dfdfd8bad07 100644 --- a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -11,10 +11,9 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch +namespace Microsoft.DotNet.HotReload { internal sealed class WebAssemblyHotReloadClient( ILogger logger, From 1e0e54cbdacc8e8da4784894c410d125709a0dbf Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 2 Sep 2025 09:35:55 -0700 Subject: [PATCH 20/32] Share Kestrel-based BRS --- ...oft.DotNet.HotReload.Client.Package.csproj | 1 + .../Web}/BrowserRefreshServer.cs | 44 +++++++++++++++---- .../dotnet-watch/Browser/BrowserLauncher.cs | 1 + .../dotnet-watch/Browser/WebServerHost.cs | 17 ------- .../AppModels/WebApplicationAppModel.cs | 8 +++- .../HotReload/StaticFileHandler.cs | 1 + ...Extensions.DotNetDeltaApplier.Tests.csproj | 1 + 7 files changed, 47 insertions(+), 26 deletions(-) rename src/BuiltInTools/{dotnet-watch/Browser => HotReloadClient/Web}/BrowserRefreshServer.cs (77%) delete mode 100644 src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs diff --git a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index 9e8dc75ecb97..c5ec2b77fc02 100644 --- a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -40,6 +40,7 @@ + diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs similarity index 77% rename from src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs rename to src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs index d113f0d30dee..963448e1fff3 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs @@ -1,33 +1,59 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#nullable enable + +#if NET + +using System; using System.Collections.Immutable; using System.Diagnostics; +using System.Linq; using System.Net; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; -using Microsoft.DotNet.HotReload; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Microsoft.DotNet.Watch; - -internal sealed class BrowserRefreshServer(EnvironmentOptions options, string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) +namespace Microsoft.DotNet.HotReload; + +/// +/// Kestrel-based Browser Refesh Server implementation. +/// +internal sealed class BrowserRefreshServer( + ILogger logger, + ILoggerFactory loggerFactory, + string middlewareAssemblyPath, + string dotnetPath, + string? autoReloadWebSocketHostName, + bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { + private sealed class WebServerHost(IHost host, ImmutableArray endPoints) + : AbstractWebServerHost(endPoints, virtualDirectory: "/") + { + public override void Dispose() + => host.Dispose(); + + public override Task StartAsync(CancellationToken cancellation) + => host.StartAsync(cancellation); + } + private static bool? s_lazyTlsSupported; protected override bool SuppressTimeouts - => options.TestFlags != TestFlags.None; + => suppressTimeouts; protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { - var hostName = options.AutoReloadWebSocketHostName ?? "127.0.0.1"; + var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; var supportsTls = await IsTlsSupportedAsync(cancellationToken); @@ -68,7 +94,7 @@ private async ValueTask IsTlsSupportedAsync(CancellationToken cancellation try { - using var process = Process.Start(options.MuxerPath, "dev-certs https --check --quiet"); + using var process = Process.Start(dotnetPath, "dev-certs https --check --quiet"); await process .WaitForExitAsync(cancellationToken) .WaitAsync(SuppressTimeouts ? TimeSpan.MaxValue : TimeSpan.FromSeconds(10), cancellationToken); @@ -94,7 +120,7 @@ private ImmutableArray GetServerUrls(IHost server) Debug.Assert(serverUrls != null); - if (options.AutoReloadWebSocketHostName is null) + if (autoReloadWebSocketHostName is null) { return [.. serverUrls.Select(s => s.Replace("http://127.0.0.1", "ws://localhost", StringComparison.Ordinal) @@ -129,3 +155,5 @@ private async Task WebSocketRequestAsync(HttpContext context) await connection.Disconnected.Task; } } + +#endif diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs index 25c8c8161591..422cb1b8a774 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; diff --git a/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs b/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs deleted file mode 100644 index c0e285b212af..000000000000 --- a/src/BuiltInTools/dotnet-watch/Browser/WebServerHost.cs +++ /dev/null @@ -1,17 +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 System.Collections.Immutable; -using Microsoft.Extensions.Hosting; - -namespace Microsoft.DotNet.HotReload; - -internal sealed class WebServerHost(IHost host, ImmutableArray endPoints) - : AbstractWebServerHost(endPoints, virtualDirectory: "/") -{ - public override void Dispose() - => host.Dispose(); - - public override Task StartAsync(CancellationToken cancellation) - => host.StartAsync(cancellation); -} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs index 9e3039bd56e8..65424aef7ec3 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/AppModels/WebApplicationAppModel.cs @@ -54,7 +54,13 @@ private static string GetMiddlewareAssemblyPath() if (IsServerSupported(projectNode, logger)) { - return new BrowserRefreshServer(context.EnvironmentOptions, GetMiddlewareAssemblyPath(), logger, context.LoggerFactory); + return new BrowserRefreshServer( + logger, + context.LoggerFactory, + middlewareAssemblyPath: GetMiddlewareAssemblyPath(), + dotnetPath: context.EnvironmentOptions.MuxerPath, + autoReloadWebSocketHostName: context.EnvironmentOptions.AutoReloadWebSocketHostName, + suppressTimeouts: context.EnvironmentOptions.TestFlags != TestFlags.None); } return null; diff --git a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs index 342b787cc907..d20603095ab3 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/StaticFileHandler.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch diff --git a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj index 49eb77052ca3..6ab22f97a92d 100644 --- a/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj +++ b/test/Microsoft.Extensions.DotNetDeltaApplier.Tests/Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj @@ -13,6 +13,7 @@ + From 5739efca9e89d6ca9ffd155f9c115fa2d887de1c Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Tue, 2 Sep 2025 16:03:23 -0700 Subject: [PATCH 21/32] Simplify --- .../Web/AbstractBrowserRefreshServer.cs | 52 ++++++++++++------- .../Web/AbstractWebServerHost.cs | 21 -------- .../Web/BrowserRefreshServer.cs | 14 +---- .../HotReloadClient/Web/WebServerHost.cs | 21 ++++++++ .../HotReload/HotReloadClients.cs | 2 +- .../dotnet-watch/Watch/DotNetWatcher.cs | 2 +- 6 files changed, 59 insertions(+), 53 deletions(-) delete mode 100644 src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs create mode 100644 src/BuiltInTools/HotReloadClient/Web/WebServerHost.cs diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index 5a59fa1ce84a..2770e55d4544 100644 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -31,6 +31,7 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa private static readonly ReadOnlyMemory s_reloadMessage = Encoding.UTF8.GetBytes("Reload"); private static readonly ReadOnlyMemory s_waitMessage = Encoding.UTF8.GetBytes("Wait"); + private static readonly ReadOnlyMemory s_pingMessage = Encoding.UTF8.GetBytes("""{ "type" : "Ping" }"""); private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web); private readonly List _activeConnections = []; @@ -39,7 +40,7 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa private readonly RSA _rsa = CreateRsa(); // initialized by StartAsync - private AbstractWebServerHost? _lazyHost; + private WebServerHost? _lazyHost; public virtual async ValueTask DisposeAsync() { @@ -60,9 +61,12 @@ public virtual async ValueTask DisposeAsync() _rsa.Dispose(); } - protected abstract ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken); + protected abstract ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken); protected abstract bool SuppressTimeouts { get; } + public ILogger Logger + => logger; + private static RSA CreateRsa() { var rsa = RSA.Create(); @@ -70,9 +74,23 @@ private static RSA CreateRsa() return rsa; } - public void ConfigureLaunchEnvironment(IDictionary builder) + public async ValueTask StartAsync(CancellationToken cancellationToken) { - Debug.Assert(_lazyHost != null); + if (_lazyHost != null) + { + throw new InvalidOperationException("Server already started"); + } + + _lazyHost = await CreateAndStartHostAsync(cancellationToken); + logger.Log(LogEvents.RefreshServerRunningAt, string.Join(",", _lazyHost.EndPoints)); + } + + public void ConfigureLaunchEnvironment(IDictionary builder, bool enableHotReload) + { + if (_lazyHost == null) + { + throw new InvalidOperationException("Server not started"); + } builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSEndPoint] = string.Join(",", _lazyHost.EndPoints); @@ -88,12 +106,15 @@ public void ConfigureLaunchEnvironment(IDictionary builder) builder.InsertListItem(MiddlewareEnvironmentVariables.DotNetStartupHooks, middlewareAssemblyPath, Path.PathSeparator); builder.InsertListItem(MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssemblies, Path.GetFileNameWithoutExtension(middlewareAssemblyPath), MiddlewareEnvironmentVariables.AspNetCoreHostingStartupAssembliesSeparator); - // Note: - // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware - // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. - // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: - // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 - builder[MiddlewareEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + if (enableHotReload) + { + // Note: + // Microsoft.AspNetCore.Components.WebAssembly.Server.ComponentWebAssemblyConventions and Microsoft.AspNetCore.Watch.BrowserRefresh.BrowserRefreshMiddleware + // expect DOTNET_MODIFIABLE_ASSEMBLIES to be set in the blazor-devserver process, even though we are not performing Hot Reload in this process. + // The value is converted to DOTNET-MODIFIABLE-ASSEMBLIES header, which is in turn converted back to environment variable in Mono browser runtime loader: + // https://github.com/dotnet/runtime/blob/342936c5a88653f0f622e9d6cb727a0e59279b31/src/mono/browser/runtime/loader/config.ts#L330 + builder[MiddlewareEnvironmentVariables.DotNetModifiableAssemblies] = "debug"; + } if (logger.IsEnabled(LogLevel.Debug)) { @@ -102,14 +123,6 @@ public void ConfigureLaunchEnvironment(IDictionary builder) } } - public async ValueTask StartAsync(CancellationToken cancellationToken) - { - Debug.Assert(_lazyHost == null); - - _lazyHost = await CreateAndStartHostAsync(cancellationToken); - logger.Log(LogEvents.RefreshServerRunningAt, string.Join(",", _lazyHost.EndPoints)); - } - protected BrowserConnection OnBrowserConnected(WebSocket clientSocket, string? subProtocol) { var sharedSecret = (subProtocol != null) @@ -236,6 +249,9 @@ public ValueTask SendReloadMessageAsync(CancellationToken cancellationToken) public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendAsync(s_waitMessage, cancellationToken); + public ValueTask SendPingMessageAsync(CancellationToken cancellationToken) + => SendAsync(s_pingMessage, cancellationToken); + private ValueTask SendAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) => SendAndReceiveAsync(request: _ => messageBytes, response: null, cancellationToken); diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs deleted file mode 100644 index f42bfa62bf32..000000000000 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractWebServerHost.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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; -using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.DotNet.HotReload; - -internal abstract class AbstractWebServerHost(ImmutableArray endPoints, string virtualDirectory) : IDisposable -{ - public ImmutableArray EndPoints { get; } = endPoints; - public string VirtualDirectory => virtualDirectory; - - public abstract Task StartAsync(CancellationToken cancellation); - - public abstract void Dispose(); -} diff --git a/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs index 963448e1fff3..0030d669c21c 100644 --- a/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserRefreshServer.cs @@ -36,22 +36,12 @@ internal sealed class BrowserRefreshServer( bool suppressTimeouts) : AbstractBrowserRefreshServer(middlewareAssemblyPath, logger, loggerFactory) { - private sealed class WebServerHost(IHost host, ImmutableArray endPoints) - : AbstractWebServerHost(endPoints, virtualDirectory: "/") - { - public override void Dispose() - => host.Dispose(); - - public override Task StartAsync(CancellationToken cancellation) - => host.StartAsync(cancellation); - } - private static bool? s_lazyTlsSupported; protected override bool SuppressTimeouts => suppressTimeouts; - protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) + protected override async ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken) { var hostName = autoReloadWebSocketHostName ?? "127.0.0.1"; @@ -81,7 +71,7 @@ protected override async ValueTask CreateAndStartHostAsyn await host.StartAsync(cancellationToken); // URLs are only available after the server has started. - return new WebServerHost(host, GetServerUrls(host)); + return new WebServerHost(host, GetServerUrls(host), virtualDirectory: "/"); } private async ValueTask IsTlsSupportedAsync(CancellationToken cancellationToken) diff --git a/src/BuiltInTools/HotReloadClient/Web/WebServerHost.cs b/src/BuiltInTools/HotReloadClient/Web/WebServerHost.cs new file mode 100644 index 000000000000..e89a00c65135 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/WebServerHost.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. + +#nullable enable + +using System; +using System.Collections.Immutable; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class WebServerHost(IDisposable listener, ImmutableArray endPoints, string virtualDirectory) : IDisposable +{ + public ImmutableArray EndPoints + => endPoints; + + public string VirtualDirectory + => virtualDirectory; + + public void Dispose() + => listener.Dispose(); +} diff --git a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs index 963eadc9f0ee..b06fcc838485 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/HotReloadClients.cs @@ -45,7 +45,7 @@ internal void ConfigureLaunchEnvironment(IDictionary environment client.ConfigureLaunchEnvironment(environmentBuilder); } - browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder); + browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: true); } internal void InitiateConnection(CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs index eee08bd88bb2..b6fb10c29286 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/DotNetWatcher.cs @@ -66,7 +66,7 @@ public static async Task WatchAsync(DotNetWatchContext context, CancellationToke ? await context.BrowserRefreshServerFactory.GetOrCreateBrowserRefreshServerAsync(projectRootNode, webAppModel, shutdownCancellationToken) : null; - browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder); + browserRefreshServer?.ConfigureLaunchEnvironment(environmentBuilder, enableHotReload: false); foreach (var (name, value) in environmentBuilder) { From 635467e756f94859e43b18b7d6df888c180b916e Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Wed, 3 Sep 2025 18:13:02 -0700 Subject: [PATCH 22/32] Refactoring and tests --- eng/Versions.props | 2 +- sdk.slnx | 1 + ...oft.DotNet.HotReload.Client.Package.csproj | 10 +- .../Utilities/ArrayBufferWriter.cs | 10 +- .../Web/AbstractBrowserRefreshServer.cs | 42 +- .../HotReloadClient/Web/BrowserConnection.cs | 9 +- .../SharedSecretProvider.cs} | 27 +- .../Web/WebAssemblyHotReloadClient.cs | 47 +- src/BuiltInTools/dotnet-watch.slnf | 1 + .../Properties/launchSettings.json | 2 +- ...pNetCore.Watch.BrowserRefresh.Tests.csproj | 1 + .../ArrayBufferWriterTests.cs | 580 ++++++++++++++++++ ...osoft.DotNet.HotReload.Client.Tests.csproj | 21 + .../SharedSecretProviderTests.cs | 59 ++ test/dotnet-watch.Tests/Web/RSATests.cs | 17 + .../dotnet-watch.Tests.csproj | 4 +- 16 files changed, 761 insertions(+), 72 deletions(-) rename src/BuiltInTools/HotReloadClient/{Utilities/RSAExtensions.cs => Web/SharedSecretProvider.cs} (87%) create mode 100644 test/Microsoft.DotNet.HotReload.Client.Tests/ArrayBufferWriterTests.cs create mode 100644 test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj create mode 100644 test/Microsoft.DotNet.HotReload.Client.Tests/SharedSecretProviderTests.cs create mode 100644 test/dotnet-watch.Tests/Web/RSATests.cs diff --git a/eng/Versions.props b/eng/Versions.props index 46d4de6740a6..291b7673b94f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -82,7 +82,7 @@ 2.1.0 - 8.0.0 + 9.0.0 2.0.0-preview.1.24427.4 9.0.0 4.5.1 diff --git a/sdk.slnx b/sdk.slnx index ca4b722b9bc9..0246a040f447 100644 --- a/sdk.slnx +++ b/sdk.slnx @@ -318,6 +318,7 @@ + diff --git a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index c5ec2b77fc02..4ef1e17a052c 100644 --- a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -2,9 +2,9 @@ - $(SdkTargetFramework);netstandard2.0 + net9.0;net472 false none false @@ -24,8 +24,8 @@ - - @@ -39,7 +39,7 @@ - + diff --git a/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs index e3e5c9a6633d..814668c22c6f 100644 --- a/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs +++ b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +#if !NET + #nullable enable using System; @@ -8,12 +10,6 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -#if NET - -[assembly: TypeForwardedTo(typeof(ArrayBufferWriter<>))] - -#else - namespace System.Buffers; /// @@ -36,7 +32,7 @@ internal sealed class ArrayBufferWriter : IBufferWriter /// public ArrayBufferWriter() { - _buffer = Array.Empty(); + _buffer = []; _index = 0; } diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index 2770e55d4544..edd0cd556222 100644 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -37,7 +37,7 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa private readonly List _activeConnections = []; private readonly TaskCompletionSource _browserConnected = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly RSA _rsa = CreateRsa(); + private readonly SharedSecretProvider _sharedSecretProvider = new(); // initialized by StartAsync private WebServerHost? _lazyHost; @@ -53,12 +53,11 @@ public virtual async ValueTask DisposeAsync() foreach (var connection in connectionsToDispose) { - connection.ServerLogger.LogDebug("Disconnecting from browser."); await connection.DisposeAsync(); } _lazyHost?.Dispose(); - _rsa.Dispose(); + _sharedSecretProvider.Dispose(); } protected abstract ValueTask CreateAndStartHostAsync(CancellationToken cancellationToken); @@ -67,13 +66,6 @@ public virtual async ValueTask DisposeAsync() public ILogger Logger => logger; - private static RSA CreateRsa() - { - var rsa = RSA.Create(); - rsa.KeySize = 2048; - return rsa; - } - public async ValueTask StartAsync(CancellationToken cancellationToken) { if (_lazyHost != null) @@ -93,14 +85,7 @@ public void ConfigureLaunchEnvironment(IDictionary builder, bool } builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSEndPoint] = string.Join(",", _lazyHost.EndPoints); - -#if NET - var publicKey = Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); -#else - var publicKey = _rsa.ExportSubjectPublicKeyInfoAsBase64(); -#endif - builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSKey] = publicKey; - + builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadWSKey] = _sharedSecretProvider.GetPublicKey(); builder[MiddlewareEnvironmentVariables.AspNetCoreAutoReloadVirtualDirectory] = _lazyHost.VirtualDirectory; builder.InsertListItem(MiddlewareEnvironmentVariables.DotNetStartupHooks, middlewareAssemblyPath, Path.PathSeparator); @@ -125,9 +110,7 @@ public void ConfigureLaunchEnvironment(IDictionary builder, bool protected BrowserConnection OnBrowserConnected(WebSocket clientSocket, string? subProtocol) { - var sharedSecret = (subProtocol != null) - ? Convert.ToBase64String(_rsa.Decrypt(Convert.FromBase64String(WebUtility.UrlDecode(subProtocol)), RSAEncryptionPadding.OaepSHA256)) - : null; + var sharedSecret = (subProtocol != null) ? _sharedSecretProvider.DecryptSecret(WebUtility.UrlDecode(subProtocol)) : null; var connection = new BrowserConnection(clientSocket, sharedSecret, loggerFactory); @@ -162,13 +145,20 @@ public async Task WaitForClientConnectionAsync(CancellationToken cancellationTok var progressReportingTask = Task.Run(async () => { - while (!progressCancellationSource.Token.IsCancellationRequested) + try { - await Task.Delay(SuppressTimeouts ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); + while (!progressCancellationSource.Token.IsCancellationRequested) + { + await Task.Delay(SuppressTimeouts ? TimeSpan.MaxValue : reportDelayInSeconds, progressCancellationSource.Token); - connectionAttemptReported = true; - reportDelayInSeconds = nextReportSeconds; - logger.LogInformation("Connecting to the browser ..."); + connectionAttemptReported = true; + reportDelayInSeconds = nextReportSeconds; + logger.LogInformation("Connecting to the browser ..."); + } + } + catch (OperationCanceledException) + { + // nop } }, progressCancellationSource.Token); diff --git a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs index b51081d2b06e..bb3ae6a6b111 100644 --- a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs @@ -40,13 +40,13 @@ public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFa ServerLogger.LogDebug("Connected to referesh server."); } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - await ClientSocket.CloseOutputAsync(WebSocketCloseStatus.Empty, null, default); ClientSocket.Dispose(); Disconnected.TrySetResult(default); ServerLogger.LogDebug("Disconnected."); + return new(); } internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) @@ -71,8 +71,11 @@ internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageB internal async ValueTask TryReceiveMessageAsync(ResponseAction receiver, CancellationToken cancellationToken) { +#if NET + var writer = new System.Buffers.ArrayBufferWriter(initialCapacity: 1024); +#else var writer = new ArrayBufferWriter(initialCapacity: 1024); - +#endif while (true) { #if NET diff --git a/src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs b/src/BuiltInTools/HotReloadClient/Web/SharedSecretProvider.cs similarity index 87% rename from src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs rename to src/BuiltInTools/HotReloadClient/Web/SharedSecretProvider.cs index edf3a5330560..813a1b126499 100644 --- a/src/BuiltInTools/HotReloadClient/Utilities/RSAExtensions.cs +++ b/src/BuiltInTools/HotReloadClient/Web/SharedSecretProvider.cs @@ -2,29 +2,45 @@ #nullable enable -#if !NET - using System; using System.IO; using System.Security.Cryptography; namespace Microsoft.DotNet.HotReload; -internal static class RSAExtensions +internal sealed class SharedSecretProvider : IDisposable { + private readonly RSA _rsa = RSA.Create(2048); + + public void Dispose() + => _rsa.Dispose(); + + internal string DecryptSecret(string secret) + => Convert.ToBase64String(_rsa.Decrypt(Convert.FromBase64String(secret), RSAEncryptionPadding.OaepSHA256)); + + internal string GetPublicKey() +#if NET + => Convert.ToBase64String(_rsa.ExportSubjectPublicKeyInfo()); +#else + => ExportPublicKeyNetFramework(); +#endif + /// /// Export the public key in the X.509 SubjectPublicKeyInfo representation which is equivalent to the .NET Core RSA api /// ExportSubjectPublicKeyInfo. /// /// Algorithm from https://stackoverflow.com/a/28407693 or https://github.com/Azure/azure-powershell/blob/main/src/KeyVault/KeyVault/Helpers/JwkHelper.cs /// - internal static string ExportSubjectPublicKeyInfoAsBase64(this RSA rsa) + internal string ExportPublicKeyNetFramework() { var writer = new StringWriter(); - ExportPublicKey(rsa.ExportParameters(includePrivateParameters: false), writer); + ExportPublicKey(ExportPublicKeyParameters(), writer); return writer.ToString(); } + internal RSAParameters ExportPublicKeyParameters() + => _rsa.ExportParameters(includePrivateParameters: false); + private static void ExportPublicKey(RSAParameters parameters, TextWriter outputStream) { if (parameters.Exponent == null || parameters.Modulus == null) @@ -138,4 +154,3 @@ private static void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bo } } } -#endif diff --git a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 0dfdfd8bad07..42f0cb97dc01 100644 --- a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -44,31 +44,15 @@ internal sealed class WebAssemblyHotReloadClient( /// private readonly Queue _pendingUpdates = []; - public override void Dispose() - { - // Do nothing. - } - - public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) - { - // the environment is configued via browser refesh server - } + private readonly ImmutableArray _capabilities = GetUpdateCapabilities(logger, projectHotReloadCapabilities, projectTargetFrameworkVersion); - public override void InitiateConnection(CancellationToken cancellationToken) - { - } - - public override async Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) - // Wait for the browser connection to be established. Currently we need the browser to be running in order to apply changes. - => await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); - - public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) + private static ImmutableArray GetUpdateCapabilities(ILogger logger, ImmutableArray projectHotReloadCapabilities, Version projectTargetFrameworkVersion) { var capabilities = projectHotReloadCapabilities; if (capabilities.IsEmpty) { - Logger.LogDebug("Using capabilities based on project target framework version: '{Version}'.", projectTargetFrameworkVersion); + logger.LogDebug("Using capabilities based on project target framework version: '{Version}'.", projectTargetFrameworkVersion); capabilities = projectTargetFrameworkVersion.Major switch { @@ -81,12 +65,33 @@ public override Task> GetUpdateCapabilitiesAsync(Cancella } else { - Logger.LogDebug("Project specifies capabilities: '{Capabilities}'", string.Join(" ", capabilities)); + logger.LogDebug("Project specifies capabilities: '{Capabilities}'", string.Join(" ", capabilities)); } - return Task.FromResult(capabilities); + return capabilities; + } + + public override void Dispose() + { + // Do nothing. + } + + public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) + { + // the environment is configued via browser refesh server } + public override void InitiateConnection(CancellationToken cancellationToken) + { + } + + public override async Task WaitForConnectionEstablishedAsync(CancellationToken cancellationToken) + // Wait for the browser connection to be established. Currently we need the browser to be running in order to apply changes. + => await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); + + public override Task> GetUpdateCapabilitiesAsync(CancellationToken cancellationToken) + => Task.FromResult(_capabilities); + public override async Task ApplyManagedCodeUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) { var applicableUpdates = await FilterApplicableUpdatesAsync(updates, cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch.slnf index 2ebb390b7391..09d529c61e9c 100644 --- a/src/BuiltInTools/dotnet-watch.slnf +++ b/src/BuiltInTools/dotnet-watch.slnf @@ -21,6 +21,7 @@ "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj", "test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj", "test\\Microsoft.WebTools.AspireService.Tests\\Microsoft.WebTools.AspireService.Tests.csproj", + "test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj", "test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj" ] } diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index b6981fd0ae9d..52e962bf0ac4 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose -bl", - "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", + "workingDirectory": "C:\\Temp\\Blazor-WasmHosted\\blazorwasmhosted", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", diff --git a/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj b/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj index c765d9acbbe8..03b46badbae2 100644 --- a/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj +++ b/test/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests/Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj @@ -5,6 +5,7 @@ Microsoft.AspNetCore.Watch.BrowserRefresh MicrosoftAspNetCore Exe + false diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/ArrayBufferWriterTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/ArrayBufferWriterTests.cs new file mode 100644 index 000000000000..82b4215bb1a4 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/ArrayBufferWriterTests.cs @@ -0,0 +1,580 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Xunit; + +// Copied from +// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Memory/tests/ArrayBufferWriter/ArrayBufferWriterTests.T.cs and +// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Memory/tests/ArrayBufferWriter/ArrayBufferWriterTests.Byte.cs +// and added asserts for GetArraySegment. + +namespace Microsoft.DotNet.HotReload.UnitTests; + +internal static class TestExtensions +{ +#if NET + public static ArraySegment GetArraySegment(this ArrayBufferWriter writer, int sizeHint = 0) + { + Assert.True(MemoryMarshal.TryGetArray(writer.GetMemory(sizeHint), out ArraySegment segment)); + return segment; + } +#endif + + public static T GetItem(this ArraySegment segment, int index) + => segment.Array![segment.Offset + index]; +} + +public abstract class ArrayBufferWriterTests where T : IEquatable +{ + [Fact] + public void ArrayBufferWriter_Ctor() + { + { + var output = new ArrayBufferWriter(); + Assert.Equal(0, output.FreeCapacity); + Assert.Equal(0, output.Capacity); + Assert.Equal(0, output.WrittenCount); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + } + + { + var output = new ArrayBufferWriter(200); + Assert.True(output.FreeCapacity >= 200); + Assert.True(output.Capacity >= 200); + Assert.Equal(0, output.WrittenCount); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + } + + { + ArrayBufferWriter output = null!; + Assert.Null(output); + } + } + + [Fact] + [ActiveIssue("https://github.com/mono/mono/issues/15002", TestRuntimes.Mono)] + public void Invalid_Ctor() + { + Assert.Throws(() => new ArrayBufferWriter(0)); + Assert.Throws(() => new ArrayBufferWriter(-1)); + Assert.Throws(() => new ArrayBufferWriter(int.MaxValue)); + } + + [Fact] + public void Clear() + { + var output = new ArrayBufferWriter(256); + int previousAvailable = output.FreeCapacity; + WriteData(output, 2); + Assert.True(output.FreeCapacity < previousAvailable); + Assert.True(output.WrittenCount > 0); + Assert.False(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.False(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + + ReadOnlyMemory transientMemory = output.WrittenMemory; + ReadOnlySpan transientSpan = output.WrittenSpan; + T t0 = transientMemory.Span[0]; + T t1 = transientSpan[1]; + Assert.NotEqual(default, t0); + Assert.NotEqual(default, t1); + output.Clear(); + Assert.Equal(default, transientMemory.Span[0]); + Assert.Equal(default, transientSpan[1]); + + Assert.Equal(0, output.WrittenCount); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.Equal(previousAvailable, output.FreeCapacity); + } + + [Fact] + public void ResetWrittenCount() + { + var output = new ArrayBufferWriter(256); + int previousAvailable = output.FreeCapacity; + WriteData(output, 2); + Assert.True(output.FreeCapacity < previousAvailable); + Assert.True(output.WrittenCount > 0); + Assert.False(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.False(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + + ReadOnlyMemory transientMemory = output.WrittenMemory; + ReadOnlySpan transientSpan = output.WrittenSpan; + T t0 = transientMemory.Span[0]; + T t1 = transientSpan[1]; + Assert.NotEqual(default, t0); + Assert.NotEqual(default, t1); + output.ResetWrittenCount(); + Assert.Equal(t0, transientMemory.Span[0]); + Assert.Equal(t1, transientSpan[1]); + + Assert.Equal(0, output.WrittenCount); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenSpan)); + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.Equal(previousAvailable, output.FreeCapacity); + } + + [Fact] + public void Advance() + { + { + var output = new ArrayBufferWriter(); + int capacity = output.Capacity; + Assert.Equal(capacity, output.FreeCapacity); + output.Advance(output.FreeCapacity); + Assert.Equal(capacity, output.WrittenCount); + Assert.Equal(0, output.FreeCapacity); + } + + { + var output = new ArrayBufferWriter(); + output.Advance(output.Capacity); + Assert.Equal(output.Capacity, output.WrittenCount); + Assert.Equal(0, output.FreeCapacity); + int previousCapacity = output.Capacity; + Span _ = output.GetSpan(); + Assert.True(output.Capacity > previousCapacity); + } + + { + var output = new ArrayBufferWriter(256); + WriteData(output, 2); + ReadOnlyMemory previousMemory = output.WrittenMemory; + ReadOnlySpan previousSpan = output.WrittenSpan; + Assert.True(previousSpan.SequenceEqual(previousMemory.Span)); + output.Advance(10); + Assert.False(previousMemory.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.False(previousSpan.SequenceEqual(output.WrittenSpan)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + } + + { + var output = new ArrayBufferWriter(); + _ = output.GetSpan(20); + WriteData(output, 10); + ReadOnlyMemory previousMemory = output.WrittenMemory; + ReadOnlySpan previousSpan = output.WrittenSpan; + Assert.True(previousSpan.SequenceEqual(previousMemory.Span)); + Assert.Throws(() => output.Advance(247)); + output.Advance(10); + Assert.False(previousMemory.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.False(previousSpan.SequenceEqual(output.WrittenSpan)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + } + } + + [Fact] + public void AdvanceZero() + { + var output = new ArrayBufferWriter(); + WriteData(output, 2); + Assert.Equal(2, output.WrittenCount); + ReadOnlyMemory previousMemory = output.WrittenMemory; + ReadOnlySpan previousSpan = output.WrittenSpan; + Assert.True(previousSpan.SequenceEqual(previousMemory.Span)); + output.Advance(0); + Assert.Equal(2, output.WrittenCount); + Assert.True(previousMemory.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(previousSpan.SequenceEqual(output.WrittenSpan)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + } + + [Fact] + public void InvalidAdvance() + { + { + var output = new ArrayBufferWriter(); + Assert.Throws(() => output.Advance(-1)); + Assert.Throws(() => output.Advance(output.Capacity + 1)); + } + + { + var output = new ArrayBufferWriter(); + WriteData(output, 100); + Assert.Throws(() => output.Advance(output.FreeCapacity + 1)); + } + } + + [Fact] + public void GetSpan_DefaultCtor() + { + var output = new ArrayBufferWriter(); + var span = output.GetSpan(); + Assert.Equal(256, span.Length); + } + + [Theory] + [MemberData(nameof(SizeHints))] + public void GetSpan_DefaultCtor_WithSizeHint(int sizeHint) + { + var output = new ArrayBufferWriter(); + var span = output.GetSpan(sizeHint); + Assert.Equal(sizeHint <= 256 ? 256 : sizeHint, span.Length); + } + + [Fact] + public void GetSpan_InitSizeCtor() + { + var output = new ArrayBufferWriter(100); + var span = output.GetSpan(); + Assert.Equal(100, span.Length); + } + + [Theory] + [MemberData(nameof(SizeHints))] + public void GetSpan_InitSizeCtor_WithSizeHint(int sizeHint) + { + { + var output = new ArrayBufferWriter(256); + var span = output.GetSpan(sizeHint); + Assert.Equal(sizeHint <= 256 ? 256 : sizeHint + 256, span.Length); + } + + { + var output = new ArrayBufferWriter(1000); + var span = output.GetSpan(sizeHint); + Assert.Equal(sizeHint <= 1000 ? 1000 : sizeHint + 1000, span.Length); + } + } + + [Fact] + public void GetMemory_DefaultCtor() + { + var output = new ArrayBufferWriter(); + var memory = output.GetMemory(); + Assert.Equal(256, memory.Length); + + var segment = output.GetArraySegment(); + Assert.Equal(memory.Length, segment.Count); + } + + [Theory] + [MemberData(nameof(SizeHints))] + public void GetMemory_DefaultCtor_WithSizeHint(int sizeHint) + { + var output = new ArrayBufferWriter(); + var memory = output.GetMemory(sizeHint); + Assert.Equal(sizeHint <= 256 ? 256 : sizeHint, memory.Length); + + var segment = output.GetArraySegment(sizeHint); + Assert.Equal(memory.Length, segment.Count); + } + + [Fact] + public void GetMemory_ExceedMaximumBufferSize_WithSmallStartingSize() + { + var output = new ArrayBufferWriter(256); + Assert.Throws(() => output.GetMemory(int.MaxValue)); + Assert.Throws(() => output.GetArraySegment(int.MaxValue)); + } + + [Fact] + public void GetMemory_InitSizeCtor() + { + var output = new ArrayBufferWriter(100); + var memory = output.GetMemory(); + Assert.Equal(100, memory.Length); + + var segment = output.GetArraySegment(); + Assert.Equal(memory.Length, segment.Count); + } + + [Theory] + [MemberData(nameof(SizeHints))] + public void GetMemory_InitSizeCtor_WithSizeHint(int sizeHint) + { + { + var output = new ArrayBufferWriter(256); + var memory = output.GetMemory(sizeHint); + Assert.Equal(sizeHint <= 256 ? 256 : sizeHint + 256, memory.Length); + + var segment = output.GetArraySegment(); + Assert.Equal(memory.Length, segment.Count); + } + + { + var output = new ArrayBufferWriter(1000); + var memory = output.GetMemory(sizeHint); + Assert.Equal(sizeHint <= 1000 ? 1000 : sizeHint + 1000, memory.Length); + + var segment = output.GetArraySegment(); + Assert.Equal(memory.Length, segment.Count); + } + } + + // NOTE: InvalidAdvance_Large test is constrained to run on Windows and MacOSX because it causes + // problems on Linux due to the way deferred memory allocation works. On Linux, the allocation can + // succeed even if there is not enough memory but then the test may get killed by the OOM killer at the + // time the memory is accessed which triggers the full memory allocation. + [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.OSX)] + [ConditionalFact(typeof(Environment), nameof(Environment.Is64BitProcess))] + [OuterLoop] + public void InvalidAdvance_Large() + { + try + { + { + var output = new ArrayBufferWriter(2_000_000_000); + WriteData(output, 1_000); + Assert.Throws(() => output.Advance(int.MaxValue)); + Assert.Throws(() => output.Advance(2_000_000_000 - 1_000 + 1)); + } + } + catch (OutOfMemoryException) { } + } + + [Fact] + public void GetMemoryAndSpan() + { + { + var output = new ArrayBufferWriter(); + WriteData(output, 2); + var span = output.GetSpan(); + var memory = output.GetMemory(); + var segment = output.GetArraySegment(); + Span memorySpan = memory.Span; + Assert.True(span.Length > 0); + Assert.Equal(span.Length, memorySpan.Length); + Assert.Equal(span.Length, segment.Count); + + for (int i = 0; i < span.Length; i++) + { + Assert.Equal(default, span[i]); + Assert.Equal(default, memorySpan[i]); + Assert.Equal(default, segment.GetItem(i)); + } + } + + { + var output = new ArrayBufferWriter(); + WriteData(output, 2); + ReadOnlyMemory writtenSoFarMemory = output.WrittenMemory; + ReadOnlySpan writtenSoFar = output.WrittenSpan; + Assert.True(writtenSoFarMemory.Span.SequenceEqual(writtenSoFar)); + int previousAvailable = output.FreeCapacity; + var span = output.GetSpan(500); + Assert.True(span.Length >= 500); + Assert.True(output.FreeCapacity >= 500); + Assert.True(output.FreeCapacity > previousAvailable); + + Assert.Equal(writtenSoFar.Length, output.WrittenCount); + Assert.False(writtenSoFar.SequenceEqual(span.Slice(0, output.WrittenCount))); + + var memory = output.GetMemory(); + var segment = output.GetArraySegment(); + Span memorySpan = memory.Span; + Assert.True(span.Length >= 500); + Assert.True(memorySpan.Length >= 500); + Assert.Equal(span.Length, memorySpan.Length); + Assert.Equal(span.Length, segment.Count); + for (int i = 0; i < span.Length; i++) + { + Assert.Equal(default, span[i]); + Assert.Equal(default, memorySpan[i]); + Assert.Equal(default, segment.GetItem(i)); + } + + memory = output.GetMemory(500); + segment = output.GetArraySegment(500); + memorySpan = memory.Span; + Assert.True(memorySpan.Length >= 500); + Assert.Equal(span.Length, memorySpan.Length); + for (int i = 0; i < memorySpan.Length; i++) + { + Assert.Equal(default, memorySpan[i]); + Assert.Equal(default, segment.GetItem(i)); + } + } + } + + [Fact] + public void GetSpanShouldAtleastDoubleWhenGrowing() + { + var output = new ArrayBufferWriter(256); + WriteData(output, 100); + int previousAvailable = output.FreeCapacity; + + _ = output.GetSpan(previousAvailable); + Assert.Equal(previousAvailable, output.FreeCapacity); + + _ = output.GetSpan(previousAvailable + 1); + Assert.True(output.FreeCapacity >= previousAvailable * 2); + } + + [Fact] + public void GetSpanOnlyGrowsAboveThreshold() + { + { + var output = new ArrayBufferWriter(); + _ = output.GetSpan(); + int previousAvailable = output.FreeCapacity; + + for (int i = 0; i < 10; i++) + { + _ = output.GetSpan(); + Assert.Equal(previousAvailable, output.FreeCapacity); + } + } + + { + var output = new ArrayBufferWriter(); + _ = output.GetSpan(10); + int previousAvailable = output.FreeCapacity; + + for (int i = 0; i < 10; i++) + { + _ = output.GetSpan(previousAvailable); + Assert.Equal(previousAvailable, output.FreeCapacity); + } + } + } + + [Fact] + public void InvalidGetMemoryAndSpan() + { + var output = new ArrayBufferWriter(); + WriteData(output, 2); + Assert.Throws(() => output.GetSpan(-1)); + Assert.Throws(() => output.GetMemory(-1)); + Assert.Throws(() => output.GetArraySegment(-1)); + } + + protected abstract void WriteData(IBufferWriter bufferWriter, int numBytes); + + public static IEnumerable SizeHints + { + get + { + return new List + { + new object[] { 0 }, + new object[] { 1 }, + new object[] { 2 }, + new object[] { 3 }, + new object[] { 99 }, + new object[] { 100 }, + new object[] { 101 }, + new object[] { 255 }, + new object[] { 256 }, + new object[] { 257 }, + new object[] { 1000 }, + new object[] { 2000 }, + }; + } + } +} + +public class ArrayBufferWriterTests_Byte : ArrayBufferWriterTests +{ + protected override void WriteData(IBufferWriter bufferWriter, int numBytes) + { + Span outputSpan = bufferWriter.GetSpan(numBytes); + Assert.True(outputSpan.Length >= numBytes); + var random = new Random(42); + + var data = new byte[numBytes]; + random.NextBytes(data); + data.CopyTo(outputSpan); + + bufferWriter.Advance(numBytes); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void WriteAndCopyToStream(bool clearContent) + { + System.Buffers.ArrayBufferWriter output = new(); + WriteData(output, 100); + + using MemoryStream memStream = new(100); + + Assert.Equal(100, output.WrittenCount); + + ReadOnlySpan outputSpan = output.WrittenMemory.ToArray(); + + ReadOnlyMemory transientMemory = output.WrittenMemory; + ReadOnlySpan transientSpan = output.WrittenSpan; + + Assert.True(transientSpan.SequenceEqual(transientMemory.Span)); + + Assert.True(transientSpan[0] != 0); + byte expectedFirstByte = transientSpan[0]; + + memStream.Write(transientSpan.ToArray(), 0, transientSpan.Length); + + if (clearContent) + { + expectedFirstByte = 0; + output.Clear(); + } + else + { + output.ResetWrittenCount(); + } + + Assert.Equal(expectedFirstByte, transientSpan[0]); + Assert.Equal(expectedFirstByte, transientMemory.Span[0]); + + Assert.Equal(0, output.WrittenCount); + byte[] streamOutput = memStream.ToArray(); + + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(output.WrittenSpan.SequenceEqual(output.WrittenMemory.Span)); + + Assert.Equal(outputSpan.Length, streamOutput.Length); + Assert.True(outputSpan.SequenceEqual(streamOutput)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WriteAndCopyToStreamAsync(bool clearContent) + { + System.Buffers.ArrayBufferWriter output = new(); + WriteData(output, 100); + + using MemoryStream memStream = new(100); + + Assert.Equal(100, output.WrittenCount); + + ReadOnlyMemory outputMemory = output.WrittenMemory.ToArray(); + + ReadOnlyMemory transient = output.WrittenMemory; + + Assert.True(transient.Span[0] != 0); + byte expectedFirstByte = transient.Span[0]; + + await memStream.WriteAsync(transient.ToArray(), 0, transient.Length); + + if (clearContent) + { + expectedFirstByte = 0; + output.Clear(); + } + else + { + output.ResetWrittenCount(); + } + + Assert.True(transient.Span[0] == expectedFirstByte); + + Assert.Equal(0, output.WrittenCount); + byte[] streamOutput = memStream.ToArray(); + + Assert.True(ReadOnlyMemory.Empty.Span.SequenceEqual(output.WrittenMemory.Span)); + Assert.True(ReadOnlySpan.Empty.SequenceEqual(output.WrittenMemory.Span)); + + Assert.Equal(outputMemory.Length, streamOutput.Length); + Assert.True(outputMemory.Span.SequenceEqual(streamOutput)); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj new file mode 100644 index 000000000000..f6ed946da32c --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj @@ -0,0 +1,21 @@ + + + + net472;$(SdkTargetFramework) + Exe + Microsoft.DotNet.HotReload.UnitTests + MicrosoftAspNetCore + + + + + + + + + + + + + + diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/SharedSecretProviderTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/SharedSecretProviderTests.cs new file mode 100644 index 000000000000..e7f9d8ceb845 --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/SharedSecretProviderTests.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace Microsoft.DotNet.HotReload.UnitTests; + +public class SharedSecretProviderTests +{ + private static byte[] GetRandomBytes(int length) + { + var result = new byte[length]; + var random = new Random(); + random.NextBytes(result); + return result; + } + + [Fact] + public void EncryptDecrypt() + { + using var provider = new SharedSecretProvider(); + + // Server generates public key and sends it over to the middleware: + var publicKeyNetfx = provider.ExportPublicKeyNetFramework(); + var publicKeyParameters = provider.ExportPublicKeyParameters(); + var publicKey = provider.GetPublicKey(); + + // Middleware embeds key by in the .js file loaded to the browser: + Assert.Equal(publicKey, publicKeyNetfx); + + // The browser generates 32-byte random secret: + var secret = GetRandomBytes(32); + var secretBase64 = Convert.ToBase64String(secret); + + // The secret is encrypted using public key and sent + // as subprotocol when client connects to the server over WebSocket: + var encrypted = GetEncryptedSecret(publicKey, publicKeyParameters, secret); + + // The server decrypts the secret using private key. + // The secret is sent over to the client with every request over WebSocket. + // The client validates that the secrete matches the one it generated. + var decrypted = provider.DecryptSecret(encrypted); + Assert.Equal(secretBase64, decrypted); + } + + // Equivalent to getSecret function in WebSocketScriptInjection.js: + public static string GetEncryptedSecret(string key, RSAParameters publicKeyParameters, byte[] secret) + { + // Import server key for RSA-OAEP + using var rsa = RSA.Create(); +#if NET + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(key), out _); +#else + rsa.ImportParameters(publicKeyParameters); +#endif + // Encrypt using RSA-OAEP + return Convert.ToBase64String(rsa.Encrypt(secret, RSAEncryptionPadding.OaepSHA256)); + } +} diff --git a/test/dotnet-watch.Tests/Web/RSATests.cs b/test/dotnet-watch.Tests/Web/RSATests.cs new file mode 100644 index 000000000000..445325fb7736 --- /dev/null +++ b/test/dotnet-watch.Tests/Web/RSATests.cs @@ -0,0 +1,17 @@ +// 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.UnitTests; + +public class RSATests +{ + [Fact] + public void TestNetFrameworkImpl() + { + + } +} diff --git a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj index 41b346c0a555..6a81d97e1ed3 100644 --- a/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj +++ b/test/dotnet-watch.Tests/dotnet-watch.Tests.csproj @@ -3,7 +3,7 @@ Exe $(ToolsetTargetFramework) MicrosoftAspNetCore - Microsoft.DotNet.Watcher.Tools + Microsoft.DotNet.Watch.UnitTests @@ -15,7 +15,7 @@ See TestUtilities/ModuleInitializer.cs. --> - + From 27ebc87812891bb35907b162fe6fb8d09a8779dd Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 5 Sep 2025 17:39:07 -0700 Subject: [PATCH 23/32] Update assembly version of Microsoft.Bcl.AsyncInterfaces available in VS to 9.0.0.0 --- .../Microsoft.DotNet.MSBuildSdkResolver.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj index 06ca1f9cb0c0..58ea4ea2ab03 100644 --- a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj +++ b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj @@ -117,7 +117,7 @@ - + From 04af27e67af61fc696ffb0ae1412bf3c69fe652a Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 5 Sep 2025 17:40:25 -0700 Subject: [PATCH 24/32] Pending tasks for suspended WASM processes --- .../Web/WebAssemblyHotReloadClient.cs | 121 +++++++++--------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 42f0cb97dc01..71e2a3c583b0 100644 --- a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -37,12 +37,13 @@ internal sealed class WebAssemblyHotReloadClient( private static readonly ImmutableArray s_defaultCapabilities90 = s_defaultCapabilities80; - private int _updateId; + private int _updateBatchId; /// /// Updates that were sent over to the agent while the process has been suspended. /// - private readonly Queue _pendingUpdates = []; + private readonly object _pendingUpdatesGate = new(); + private Task _pendingUpdates = Task.CompletedTask; private readonly ImmutableArray _capabilities = GetUpdateCapabilities(logger, projectHotReloadCapabilities, projectTargetFrameworkVersion); @@ -76,6 +77,10 @@ public override void Dispose() // Do nothing. } + // for testing + internal Task PendingUpdates + => _pendingUpdates; + public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { // the environment is configued via browser refesh server @@ -106,19 +111,7 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr return ApplyStatus.AllChangesApplied; } - if (!isProcessSuspended) - { - await ProcessPendingUpdatesAsync(cancellationToken); - } - - var anySuccess = false; - var anyFailure = false; - // Make sure to send the same update to all browsers, the only difference is the shared secret. - - var updateId = _updateId++; - Logger.LogDebug("Sending update #{UpdateId}", updateId); - var deltas = updates.Select(static update => new JsonDelta { ModuleId = update.ModuleId, @@ -130,32 +123,7 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr var loggingLevel = Logger.IsEnabled(LogLevel.Debug) ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors; - await browserRefreshServer.SendAndReceiveAsync( - request: sharedSecret => new JsonApplyHotReloadDeltasRequest - { - SharedSecret = sharedSecret, - UpdateId = updateId, - Deltas = deltas, - ResponseLoggingLevel = (int)loggingLevel - }, - response: isProcessSuspended ? null : new ResponseAction((value, logger) => - { - if (ProcessUpdateResponse(value, logger)) - { - anySuccess = true; - } - else - { - anyFailure = true; - } - }), - cancellationToken); - - if (isProcessSuspended) - { - Logger.LogDebug("Update #{UpdateId} will be completed after app resumes.", updateId); - _pendingUpdates.Enqueue(updateId); - } + var (anySuccess, anyFailure) = await SendAndReceiveUpdateAsync(deltas, loggingLevel, isProcessSuspended, cancellationToken); // If no browser is connected we assume the changes have been applied. // If at least one browser suceeds we consider the changes successfully applied. @@ -166,34 +134,67 @@ await browserRefreshServer.SendAndReceiveAsync( return (!anySuccess && anyFailure) ? ApplyStatus.Failed : (applicableUpdates.Count < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied; } - public override Task ApplyStaticAssetUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) - // static asset updates are handled by browser refresh server: - => Task.FromResult(ApplyStatus.NoChangesApplied); - - private async ValueTask ProcessPendingUpdatesAsync(CancellationToken cancellationToken) + private async ValueTask<(bool anySuccess, bool anyFailure)> SendAndReceiveUpdateAsync(JsonDelta[] deltas, ResponseLoggingLevel loggingLevel, bool isProcessSuspended, CancellationToken cancellationToken) { - while (_pendingUpdates.Count > 0) + var batchId = _updateBatchId++; + + if (!isProcessSuspended) { - var updateId = _pendingUpdates.Dequeue(); - var success = false; + return await SendAndReceiveAsync(batchId, cancellationToken); + } - await browserRefreshServer.SendAndReceiveAsync( - request: null, - response: (value, logger) => success = ProcessUpdateResponse(value, logger), - cancellationToken); + lock (_pendingUpdatesGate) + { + var previous = _pendingUpdates; - if (success) - { - Logger.LogDebug("Update #{UpdateId} completed.", updateId); - } - else + _pendingUpdates = Task.Run(async () => { - Logger.LogDebug("Update #{UpdateId} failed.", updateId); - } + await previous; + await SendAndReceiveAsync(batchId, cancellationToken); + }, cancellationToken); + } + + return (anySuccess: true, anyFailure: false); + + async ValueTask<(bool anySuccess, bool anyFailure)> SendAndReceiveAsync(int batchId, CancellationToken cancellationToken) + { + Logger.LogDebug("Sending update batch #{UpdateId}", batchId); + + var anySuccess = false; + var anyFailure = false; + + await browserRefreshServer.SendAndReceiveAsync( + request: sharedSecret => new JsonApplyHotReloadDeltasRequest + { + SharedSecret = sharedSecret, + UpdateId = batchId, + Deltas = deltas, + ResponseLoggingLevel = (int)loggingLevel + }, + response: new ResponseAction((value, logger) => + { + if (ReceiveUpdateResponseAsync(value, logger)) + { + Logger.LogDebug("Update batch #{UpdateId} completed.", batchId); + anySuccess = true; + } + else + { + Logger.LogDebug("Update batch #{UpdateId} failed.", batchId); + anyFailure = true; + } + }), + cancellationToken); + + return (anySuccess, anyFailure); } } - private static bool ProcessUpdateResponse(ReadOnlySpan value, ILogger logger) + public override Task ApplyStaticAssetUpdatesAsync(ImmutableArray updates, bool isProcessSuspended, CancellationToken cancellationToken) + // static asset updates are handled by browser refresh server: + => Task.FromResult(ApplyStatus.NoChangesApplied); + + private static bool ReceiveUpdateResponseAsync(ReadOnlySpan value, ILogger logger) { var data = AbstractBrowserRefreshServer.DeserializeJson(value); From 4e970f1269d1e9c01ce1508810fed5797abc225d Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 5 Sep 2025 17:40:43 -0700 Subject: [PATCH 25/32] Simplify dispose --- .../Web/AbstractBrowserRefreshServer.cs | 12 ++++++------ .../HotReloadClient/Web/BrowserConnection.cs | 5 ++--- .../Browser/BrowserRefreshServerFactory.cs | 13 +++++-------- .../dotnet-watch/CommandLine/DotNetWatchContext.cs | 6 +++--- src/BuiltInTools/dotnet-watch/Program.cs | 2 +- .../HotReload/RuntimeProcessLauncherTests.cs | 2 +- 6 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs index edd0cd556222..eda15d200f96 100644 --- a/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs +++ b/src/BuiltInTools/HotReloadClient/Web/AbstractBrowserRefreshServer.cs @@ -25,7 +25,7 @@ namespace Microsoft.DotNet.HotReload; /// Communicates with aspnetcore-browser-refresh.js loaded in the browser. /// Associated with a project instance. /// -internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) : IAsyncDisposable +internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPath, ILogger logger, ILoggerFactory loggerFactory) : IDisposable { public const string ServerLogComponentName = "BrowserRefreshServer"; @@ -42,7 +42,7 @@ internal abstract class AbstractBrowserRefreshServer(string middlewareAssemblyPa // initialized by StartAsync private WebServerHost? _lazyHost; - public virtual async ValueTask DisposeAsync() + public virtual void Dispose() { BrowserConnection[] connectionsToDispose; lock (_activeConnections) @@ -53,7 +53,7 @@ public virtual async ValueTask DisposeAsync() foreach (var connection in connectionsToDispose) { - await connection.DisposeAsync(); + connection.Dispose(); } _lazyHost?.Dispose(); @@ -188,7 +188,7 @@ private IReadOnlyCollection GetOpenBrowserConnections() } } - private async ValueTask DisposeClosedBrowserConnectionsAsync() + private void DisposeClosedBrowserConnections() { List? lazyConnectionsToDispose = null; @@ -216,7 +216,7 @@ private async ValueTask DisposeClosedBrowserConnectionsAsync() { foreach (var connection in lazyConnectionsToDispose) { - await connection.DisposeAsync(); + connection.Dispose(); } } } @@ -283,7 +283,7 @@ public async ValueTask SendAndReceiveAsync( logger.Log(LogEvents.FailedToReceiveResponseFromConnectedBrowser); } - await DisposeClosedBrowserConnectionsAsync(); + DisposeClosedBrowserConnections(); } public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken) diff --git a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs index bb3ae6a6b111..d12f8b8b99bf 100644 --- a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs @@ -12,7 +12,7 @@ namespace Microsoft.DotNet.HotReload; -internal readonly struct BrowserConnection : IAsyncDisposable +internal readonly struct BrowserConnection : IDisposable { public const string ServerLogComponentName = $"{nameof(BrowserConnection)}:Server"; public const string AgentLogComponentName = $"{nameof(BrowserConnection)}:Agent"; @@ -40,13 +40,12 @@ public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFa ServerLogger.LogDebug("Connected to referesh server."); } - public ValueTask DisposeAsync() + public void Dispose() { ClientSocket.Dispose(); Disconnected.TrySetResult(default); ServerLogger.LogDebug("Disconnected."); - return new(); } internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageBytes, CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs index a777426e072b..ea99bd9a22d4 100644 --- a/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServerFactory.cs @@ -17,14 +17,14 @@ namespace Microsoft.DotNet.Watch; /// /// The instances are also reused if the project file is updated or the project graph is reloaded. /// -internal sealed class BrowserRefreshServerFactory : IAsyncDisposable +internal sealed class BrowserRefreshServerFactory : IDisposable { private readonly Lock _serversGuard = new(); // Null value is cached for project instances that are not web projects or do not support browser refresh for other reason. private readonly Dictionary _servers = []; - public async ValueTask DisposeAsync() + public void Dispose() { BrowserRefreshServer?[] serversToDispose; @@ -34,13 +34,10 @@ public async ValueTask DisposeAsync() _servers.Clear(); } - await Task.WhenAll(serversToDispose.Select(async server => + foreach (var server in serversToDispose) { - if (server != null) - { - await server.DisposeAsync(); - } - })); + server?.Dispose(); + }; } public async ValueTask GetOrCreateBrowserRefreshServerAsync(ProjectGraphNode projectNode, WebApplicationAppModel appModel, CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs index d38abd5f5dcd..591b6e283302 100644 --- a/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs +++ b/src/BuiltInTools/dotnet-watch/CommandLine/DotNetWatchContext.cs @@ -6,7 +6,7 @@ namespace Microsoft.DotNet.Watch { - internal sealed class DotNetWatchContext : IAsyncDisposable + internal sealed class DotNetWatchContext : IDisposable { public const string DefaultLogComponentName = $"{nameof(DotNetWatchContext)}:Default"; public const string BuildLogComponentName = $"{nameof(DotNetWatchContext)}:Build"; @@ -24,9 +24,9 @@ internal sealed class DotNetWatchContext : IAsyncDisposable public required BrowserRefreshServerFactory BrowserRefreshServerFactory { get; init; } public required BrowserLauncher BrowserLauncher { get; init; } - public async ValueTask DisposeAsync() + public void Dispose() { - await BrowserRefreshServerFactory.DisposeAsync(); + BrowserRefreshServerFactory.Dispose(); } } } diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 7ce76326f073..b2b9bd03bbd9 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -193,7 +193,7 @@ internal async Task RunAsync() logger.LogInformation("Polling file watcher is enabled"); } - await using var context = CreateContext(processRunner); + using var context = CreateContext(processRunner); if (isHotReloadEnabled) { diff --git a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs index 0e9fb155bd3b..76993fbe54e3 100644 --- a/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs +++ b/test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs @@ -134,7 +134,7 @@ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string? } finally { - await context.DisposeAsync(); + context.Dispose(); } }, shutdownSource.Token); From 6718b0ff1922e6932a38a0bb0cd374f6e2b526d3 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Fri, 5 Sep 2025 18:02:53 -0700 Subject: [PATCH 26/32] Cleanup --- .../dotnet-watch/Properties/launchSettings.json | 2 +- testEnvironments.json | 17 ----------------- 2 files changed, 1 insertion(+), 18 deletions(-) delete mode 100644 testEnvironments.json diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index 52e962bf0ac4..b6981fd0ae9d 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -3,7 +3,7 @@ "dotnet-watch": { "commandName": "Project", "commandLineArgs": "--verbose -bl", - "workingDirectory": "C:\\Temp\\Blazor-WasmHosted\\blazorwasmhosted", + "workingDirectory": "$(RepoRoot)src\\Assets\\TestProjects\\BlazorWasmWithLibrary\\blazorwasm", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", diff --git a/testEnvironments.json b/testEnvironments.json deleted file mode 100644 index a110b57df138..000000000000 --- a/testEnvironments.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "1", - "environments": [ - // See https://aka.ms/remotetesting for more details - // about how to configure remote environments. - //{ - // "name": "WSL Ubuntu", - // "type": "wsl", - // "wslDistribution": "Ubuntu" - //}, - //{ - // "name": "Docker dotnet/sdk", - // "type": "docker", - // "dockerImage": "mcr.microsoft.com/dotnet/sdk" - //} - ] -} \ No newline at end of file From 727929bcbb0537de9dccb46324d272071bf0c2bd Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sat, 6 Sep 2025 15:41:53 -0700 Subject: [PATCH 27/32] Update SBMSBuildSdkResolver dependencies --- eng/Versions.props | 2 +- .../Microsoft.DotNet.MSBuildSdkResolver.csproj | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 291b7673b94f..4e94f2631945 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -91,7 +91,7 @@ 9.0.0 9.0.0 9.0.0 - 8.0.5 + 9.0.0.0 4.5.4 8.0.0 diff --git a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj index 58ea4ea2ab03..8851f8b98981 100644 --- a/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj +++ b/src/Resolvers/Microsoft.DotNet.MSBuildSdkResolver/Microsoft.DotNet.MSBuildSdkResolver.csproj @@ -112,10 +112,11 @@ You can find the MSBuild.exe binding redirects here: https://github.com/dotnet/msbuild/blob//src/MSBuild/app.amd64.config --> - - + + + From 65f4b9dc138fb81fb3aab59b31c47b1f8ba52e48 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sat, 6 Sep 2025 17:07:11 -0700 Subject: [PATCH 28/32] Fix TFMs --- Directory.Build.props | 3 ++- ...rosoft.DotNet.HotReload.Client.Package.csproj | 12 ++++++------ ...icrosoft.DotNet.HotReload.Client.Tests.csproj | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d6ef1fa48fb5..30d83f68c73f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -49,7 +49,8 @@ net10.0 $(NetCurrent) $(SdkTargetFramework) - net8.0 + net9.0 + net472 $(SdkTargetFramework) diff --git a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj index 4ef1e17a052c..8dd5b87ac79b 100644 --- a/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj +++ b/src/BuiltInTools/HotReloadClient/Microsoft.DotNet.HotReload.Client.Package.csproj @@ -3,8 +3,9 @@ - net9.0;net472 + $(VisualStudioServiceTargetFramework);$(SdkTargetFramework);$(VisualStudioTargetFramework) false none false @@ -24,9 +25,9 @@ - - @@ -39,9 +40,8 @@ - + - diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj index f6ed946da32c..84454e051850 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj @@ -1,7 +1,11 @@  - net472;$(SdkTargetFramework) + + $(SdkTargetFramework);$(VisualStudioTargetFramework) Exe Microsoft.DotNet.HotReload.UnitTests MicrosoftAspNetCore @@ -11,6 +15,14 @@ + + + + + + + + @@ -18,4 +30,6 @@ + + From 43a6b90c8e6865c4cb59a5cd9287a1b4cce9fdf7 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 7 Sep 2025 09:48:02 -0700 Subject: [PATCH 29/32] Fix --- .../AspireService/Models/RunSessionRequest.cs | 6 +-- .../EnvironmentUtilitiesTests.cs | 15 +++++- ...osoft.DotNet.HotReload.Client.Tests.csproj | 6 +++ ...rosoft.WebTools.AspireService.Tests.csproj | 4 ++ .../RunSessionRequestTests.cs | 52 ++++++++++++------- 5 files changed, 59 insertions(+), 24 deletions(-) rename test/{dotnet-watch.Tests/CommandLine => Microsoft.DotNet.HotReload.Client.Tests}/EnvironmentUtilitiesTests.cs (53%) diff --git a/src/BuiltInTools/AspireService/Models/RunSessionRequest.cs b/src/BuiltInTools/AspireService/Models/RunSessionRequest.cs index 3c043591ec05..6290ee5e6083 100644 --- a/src/BuiltInTools/AspireService/Models/RunSessionRequest.cs +++ b/src/BuiltInTools/AspireService/Models/RunSessionRequest.cs @@ -54,10 +54,10 @@ internal class RunSessionRequest [Required] [JsonPropertyName("launch_configurations")] - public LaunchConfiguration[] LaunchConfigurations { get; set; } = Array.Empty(); + public LaunchConfiguration[] LaunchConfigurations { get; set; } = []; [JsonPropertyName("env")] - public EnvVar[] Environment { get; set; } = Array.Empty(); + public EnvVar[] Environment { get; set; } = []; [JsonPropertyName("args")] public string[]? Arguments { get; set; } @@ -78,7 +78,7 @@ internal class RunSessionRequest ProjectPath = projectLaunchConfig.ProjectPath, Debug = string.Equals(projectLaunchConfig.LaunchMode, DebugLaunchMode, StringComparison.OrdinalIgnoreCase), Arguments = Arguments, - Environment = Environment.Select(envVar => new KeyValuePair(envVar.Name, envVar.Value!)), + Environment = Environment.Select(envVar => new KeyValuePair(envVar.Name, envVar.Value ?? "")), LaunchProfile = projectLaunchConfig.LaunchProfile, DisableLaunchProfile = projectLaunchConfig.DisableLaunchProfile }; diff --git a/test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/EnvironmentUtilitiesTests.cs similarity index 53% rename from test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs rename to test/Microsoft.DotNet.HotReload.Client.Tests/EnvironmentUtilitiesTests.cs index b57736c7e599..a09e200d224d 100644 --- a/test/dotnet-watch.Tests/CommandLine/EnvironmentUtilitiesTests.cs +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/EnvironmentUtilitiesTests.cs @@ -15,6 +15,19 @@ public void MultipleValues() builder.InsertListItem("X", "b", separator: ';'); builder.InsertListItem("X", "a", separator: ';'); - AssertEx.SequenceEqual([KeyValuePair.Create("X", "b;a")], builder); + AssertEx.SequenceEqual([new KeyValuePair("X", "b;a")], builder); + } + + [Fact] + public void EmptyValue() + { + var builder = new Dictionary(); + builder["X"] = ""; + + builder.InsertListItem("X", "a", separator: ';'); + builder.InsertListItem("X", "b", separator: ';'); + builder.InsertListItem("X", "a", separator: ';'); + + AssertEx.SequenceEqual([new KeyValuePair("X", "b;a")], builder); } } diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj index 84454e051850..b81c300e8c5c 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj @@ -29,6 +29,12 @@ + + + External\%(NuGetPackageId)\%(Link) + + + diff --git a/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj index 4dd40597f0b0..914af7b1e68d 100644 --- a/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj +++ b/test/Microsoft.WebTools.AspireService.Tests/Microsoft.WebTools.AspireService.Tests.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs index 38ab5221846d..01161b55cba2 100644 --- a/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs +++ b/test/Microsoft.WebTools.AspireService.Tests/RunSessionRequestTests.cs @@ -3,6 +3,8 @@ #nullable disable +using Microsoft.DotNet.Watch.UnitTests; + namespace Aspire.Tools.Service.UnitTests; public class RunSessionRequestTests @@ -10,35 +12,45 @@ public class RunSessionRequestTests [Fact] public void RunSessionRequest_ToProjectLaunchRequest() { - var runSessionReq = new RunSessionRequest() + var request = new RunSessionRequest() { - Arguments = new string[] { "--someArg" }, - Environment = new EnvVar[] - { - new EnvVar { Name = "var1", Value = "value1"}, - new EnvVar { Name = "var2", Value = "value2"}, - }, - LaunchConfigurations = new LaunchConfiguration[] - { - new() { + Arguments = [ "--someArg" ], + Environment = + [ + new() { Name = "var1", Value = "value1"}, + new() { Name = "var2", Value = "value2"}, + new() { Name = "var3", Value = null}, + ], + LaunchConfigurations = + [ + new() + { ProjectPath = @"c:\test\Projects\project1.csproj", LaunchType = RunSessionRequest.ProjectLaunchConfigurationType, LaunchMode= RunSessionRequest.DebugLaunchMode, LaunchProfile = "specificProfileName", DisableLaunchProfile = true } - } + ] }; - var projectReq = runSessionReq.ToProjectLaunchInformation(); + var info = request.ToProjectLaunchInformation(); + + AssertEx.SequenceEqual( + [ + "--someArg" + ], info.Arguments); + + AssertEx.SequenceEqual( + [ + "var1='value1'", + "var2='value2'", + "var3=''" + ], info.Environment.Select(e => $"{e.Key}='{e.Value}'")); - Assert.Equal(runSessionReq.Arguments[0], projectReq.Arguments.First()); - Assert.Equal(runSessionReq.Environment.Length, projectReq.Environment.Count()); - Assert.Equal(runSessionReq.Environment[0].Name, projectReq.Environment.First().Key); - Assert.Equal(runSessionReq.Environment[0].Value, projectReq.Environment.First().Value); - Assert.Equal(runSessionReq.LaunchConfigurations[0].ProjectPath, projectReq.ProjectPath); - Assert.True(projectReq.Debug); - Assert.Equal(runSessionReq.LaunchConfigurations[0].LaunchProfile, projectReq.LaunchProfile); - Assert.Equal(runSessionReq.LaunchConfigurations[0].DisableLaunchProfile, projectReq.DisableLaunchProfile); + Assert.Equal(@"c:\test\Projects\project1.csproj", info.ProjectPath); + Assert.True(info.Debug); + Assert.Equal("specificProfileName", info.LaunchProfile); + Assert.True(info.DisableLaunchProfile); } } From 50f4a80c71113d539f835a870fcc99e371583ba0 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 7 Sep 2025 13:59:33 -0700 Subject: [PATCH 30/32] Fix env utils --- .../HotReloadClient/Utilities/EnvironmentUtilities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs b/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs index 37d058e5b82c..d52af59f52ab 100644 --- a/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs +++ b/src/BuiltInTools/HotReloadClient/Utilities/EnvironmentUtilities.cs @@ -12,7 +12,7 @@ internal static class EnvironmentUtilities { public static void InsertListItem(this IDictionary environment, string key, string value, char separator) { - if (!environment.TryGetValue(key, out var existingValue)) + if (!environment.TryGetValue(key, out var existingValue) || existingValue is "") { environment[key] = value; } From 4ea877b54738644fcfa760cc298c5ed5a2d91207 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Sun, 7 Sep 2025 13:59:53 -0700 Subject: [PATCH 31/32] Move pending update handling to base class --- .../HotReloadClient/DefaultHotReloadClient.cs | 38 +++-------------- .../HotReloadClient/HotReloadClient.cs | 42 +++++++++++++++++++ .../Web/WebAssemblyHotReloadClient.cs | 41 +++--------------- 3 files changed, 53 insertions(+), 68 deletions(-) diff --git a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs index 25f5c06b3598..7587b7623117 100644 --- a/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/DefaultHotReloadClient.cs @@ -28,14 +28,6 @@ internal sealed class DefaultHotReloadClient(ILogger logger, ILogger agentLogger private NamedPipeServerStream? _pipe; private bool _managedCodeUpdateFailedOrCancelled; - private int _updateBatchId; - - /// - /// Updates that were sent over to the agent while the process has been suspended. - /// - private readonly object _pendingUpdatesGate = new(); - private Task _pendingUpdates = Task.CompletedTask; - public override void Dispose() { DisposePipe(); @@ -48,10 +40,6 @@ private void DisposePipe() _pipe = null; } - // for testing - internal Task PendingUpdates - => _pendingUpdates; - // for testing internal string NamedPipeName => _namedPipeName; @@ -225,31 +213,17 @@ public async override Task ApplyStaticAssetUpdatesAsync(ImmutableAr (appliedUpdateCount < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied; } - private async ValueTask SendAndReceiveUpdateAsync(TRequest request, bool isProcessSuspended, CancellationToken cancellationToken) + private ValueTask SendAndReceiveUpdateAsync(TRequest request, bool isProcessSuspended, CancellationToken cancellationToken) where TRequest : IUpdateRequest { // Should not be disposed: Debug.Assert(_pipe != null); - var batchId = _updateBatchId++; - - if (!isProcessSuspended) - { - return await SendAndReceiveAsync(batchId, cancellationToken); - } - - lock (_pendingUpdatesGate) - { - var previous = _pendingUpdates; - - _pendingUpdates = Task.Run(async () => - { - await previous; - await SendAndReceiveAsync(batchId, cancellationToken); - }, cancellationToken); - } - - return true; + return SendAndReceiveUpdateAsync( + send: SendAndReceiveAsync, + isProcessSuspended, + suspendedResult: true, + cancellationToken); async ValueTask SendAndReceiveAsync(int batchId, CancellationToken cancellationToken) { diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs index f57f7c0e6e54..fe14176a8b6e 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClient.cs @@ -24,6 +24,18 @@ internal abstract class HotReloadClient(ILogger logger, ILogger agentLogger) : I public readonly ILogger Logger = logger; public readonly ILogger AgentLogger = agentLogger; + private int _updateBatchId; + + /// + /// Updates that were sent over to the agent while the process has been suspended. + /// + private readonly object _pendingUpdatesGate = new(); + private Task _pendingUpdates = Task.CompletedTask; + + // for testing + internal Task PendingUpdates + => _pendingUpdates; + public abstract void ConfigureLaunchEnvironment(IDictionary environmentBuilder); /// @@ -86,4 +98,34 @@ public async Task> FilterApplicableUpd return applicableUpdates; } + + protected async ValueTask SendAndReceiveUpdateAsync( + Func> send, + bool isProcessSuspended, + TResult suspendedResult, + CancellationToken cancellationToken) + where TResult : struct + { + var batchId = _updateBatchId++; + + Task previous; + lock (_pendingUpdatesGate) + { + previous = _pendingUpdates; + + if (isProcessSuspended) + { + _pendingUpdates = Task.Run(async () => + { + await previous; + _ = await send(batchId, cancellationToken); + }, cancellationToken); + + return suspendedResult; + } + } + + await previous; + return await send(batchId, cancellationToken); + } } diff --git a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs index 71e2a3c583b0..2a45bfb07370 100644 --- a/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs +++ b/src/BuiltInTools/HotReloadClient/Web/WebAssemblyHotReloadClient.cs @@ -37,14 +37,6 @@ internal sealed class WebAssemblyHotReloadClient( private static readonly ImmutableArray s_defaultCapabilities90 = s_defaultCapabilities80; - private int _updateBatchId; - - /// - /// Updates that were sent over to the agent while the process has been suspended. - /// - private readonly object _pendingUpdatesGate = new(); - private Task _pendingUpdates = Task.CompletedTask; - private readonly ImmutableArray _capabilities = GetUpdateCapabilities(logger, projectHotReloadCapabilities, projectTargetFrameworkVersion); private static ImmutableArray GetUpdateCapabilities(ILogger logger, ImmutableArray projectHotReloadCapabilities, Version projectTargetFrameworkVersion) @@ -77,10 +69,6 @@ public override void Dispose() // Do nothing. } - // for testing - internal Task PendingUpdates - => _pendingUpdates; - public override void ConfigureLaunchEnvironment(IDictionary environmentBuilder) { // the environment is configued via browser refesh server @@ -123,7 +111,11 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr var loggingLevel = Logger.IsEnabled(LogLevel.Debug) ? ResponseLoggingLevel.Verbose : ResponseLoggingLevel.WarningsAndErrors; - var (anySuccess, anyFailure) = await SendAndReceiveUpdateAsync(deltas, loggingLevel, isProcessSuspended, cancellationToken); + var (anySuccess, anyFailure) = await SendAndReceiveUpdateAsync( + send: SendAndReceiveAsync, + isProcessSuspended, + suspendedResult: (anySuccess: true, anyFailure: false), + cancellationToken); // If no browser is connected we assume the changes have been applied. // If at least one browser suceeds we consider the changes successfully applied. @@ -132,29 +124,6 @@ public override async Task ApplyManagedCodeUpdatesAsync(ImmutableAr // Currently the changes are remembered on the dev server and sent over there from the browser. // If no browser is connected the changes are not sent though. return (!anySuccess && anyFailure) ? ApplyStatus.Failed : (applicableUpdates.Count < updates.Length) ? ApplyStatus.SomeChangesApplied : ApplyStatus.AllChangesApplied; - } - - private async ValueTask<(bool anySuccess, bool anyFailure)> SendAndReceiveUpdateAsync(JsonDelta[] deltas, ResponseLoggingLevel loggingLevel, bool isProcessSuspended, CancellationToken cancellationToken) - { - var batchId = _updateBatchId++; - - if (!isProcessSuspended) - { - return await SendAndReceiveAsync(batchId, cancellationToken); - } - - lock (_pendingUpdatesGate) - { - var previous = _pendingUpdates; - - _pendingUpdates = Task.Run(async () => - { - await previous; - await SendAndReceiveAsync(batchId, cancellationToken); - }, cancellationToken); - } - - return (anySuccess: true, anyFailure: false); async ValueTask<(bool anySuccess, bool anyFailure)> SendAndReceiveAsync(int batchId, CancellationToken cancellationToken) { From 201c6412ea6faab9db25c5637e5a779e2d538140 Mon Sep 17 00:00:00 2001 From: Tomas Matousek Date: Mon, 8 Sep 2025 10:23:25 -0700 Subject: [PATCH 32/32] Feedback --- eng/Versions.props | 2 +- .../HotReloadClient/Utilities/ArrayBufferWriter.cs | 7 ++----- src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs | 5 +---- src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs | 2 +- .../dotnet-watch/Process/WebServerProcessStateObserver.cs | 3 ++- 5 files changed, 7 insertions(+), 12 deletions(-) diff --git a/eng/Versions.props b/eng/Versions.props index 4e94f2631945..3f5234834a8f 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -91,7 +91,7 @@ 9.0.0 9.0.0 9.0.0 - 9.0.0.0 + 9.0.0 4.5.4 8.0.0 diff --git a/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs index 814668c22c6f..9bbef4f849d2 100644 --- a/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs +++ b/src/BuiltInTools/HotReloadClient/Utilities/ArrayBufferWriter.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +// Based on https://github.com/dotnet/runtime/blob/5d98ad82efb25c1489c6d8f8e8e3daac56f404ec/src/libraries/Common/src/System/Buffers/ArrayBufferWriter.cs + #if !NET #nullable enable @@ -63,11 +65,6 @@ public ArrayBufferWriter(int initialCapacity) /// public ReadOnlySpan WrittenSpan => _buffer.AsSpan(0, _index); - /// - /// Returns the data written to the underlying buffer so far, as a . - /// - public ArraySegment WrittenSegment => new(_buffer, 0, _index); - /// /// Returns the amount of data written to the underlying buffer so far. /// diff --git a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs index d12f8b8b99bf..507595eb89d1 100644 --- a/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs +++ b/src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs @@ -70,11 +70,8 @@ internal async ValueTask TrySendMessageAsync(ReadOnlyMemory messageB internal async ValueTask TryReceiveMessageAsync(ResponseAction receiver, CancellationToken cancellationToken) { -#if NET - var writer = new System.Buffers.ArrayBufferWriter(initialCapacity: 1024); -#else var writer = new ArrayBufferWriter(initialCapacity: 1024); -#endif + while (true) { #if NET diff --git a/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs b/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs index d396caa0a213..6eefc9921582 100644 --- a/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs +++ b/src/BuiltInTools/dotnet-watch/Build/ProjectInstanceId.cs @@ -3,4 +3,4 @@ namespace Microsoft.DotNet.Watch; -internal readonly record struct ProjectInstanceId(string projectPath, string targetFramework); +internal readonly record struct ProjectInstanceId(string ProjectPath, string TargetFramework); diff --git a/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs b/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs index 5449aef65588..684937ae8db5 100644 --- a/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs +++ b/src/BuiltInTools/dotnet-watch/Process/WebServerProcessStateObserver.cs @@ -24,7 +24,8 @@ internal static partial class WebServerProcessStateObserver public static void Observe(ProjectGraphNode serverProject, ProcessSpec serverProcessSpec, Action onServerListening) { // Workaround for Aspire dashboard launching: scan for "Login to the dashboard at " prefix in the output and use the URL. - // TODO: Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. + // TODO: https://github.com/dotnet/sdk/issues/9038 + // Share launch profile processing logic as implemented in VS with dotnet-run and implement browser launching there. bool isAspireHost = serverProject.GetCapabilities().Contains(AspireServiceFactory.AppHostProjectCapability); var _notified = false;