Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reuse browser when restarting project #46381

Merged
merged 2 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace Microsoft.DotNet.Watch
{
internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAsyncDisposable, IStaticAssetChangeApplierProvider
{
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;

Expand All @@ -23,11 +25,11 @@ internal sealed partial class BrowserConnector(DotNetWatchContext context) : IAs
[GeneratedRegex(@"Login to the dashboard at (?<url>.*)\s*$", RegexOptions.Compiled)]
private static partial Regex GetAspireDashboardUrlRegex();

private readonly object _serversGuard = new();
private readonly Dictionary<ProjectGraphNode, BrowserRefreshServer?> _servers = [];
private readonly Lock _serversGuard = new();
private readonly Dictionary<ProjectKey, BrowserRefreshServer?> _servers = [];

// interlocked
private ImmutableHashSet<ProjectGraphNode> _browserLaunchAttempted = [];
private ImmutableHashSet<ProjectKey> _browserLaunchAttempted = [];

public async ValueTask DisposeAsync()
{
Expand All @@ -48,6 +50,9 @@ await Task.WhenAll(serversToDispose.Select(async server =>
}));
}

private static ProjectKey GetProjectKey(ProjectGraphNode projectNode)
=> new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework());

/// <summary>
/// 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.
Expand All @@ -63,13 +68,15 @@ await Task.WhenAll(serversToDispose.Select(async server =>
BrowserRefreshServer? server;
bool hasExistingServer;

var key = GetProjectKey(projectNode);

lock (_serversGuard)
{
hasExistingServer = _servers.TryGetValue(projectNode, out server);
hasExistingServer = _servers.TryGetValue(key, out server);
if (!hasExistingServer)
{
server = IsServerSupported(projectNode) ? new BrowserRefreshServer(context.EnvironmentOptions, context.Reporter) : null;
_servers.Add(projectNode, server);
_servers.Add(key, server);
}
}

Expand Down Expand Up @@ -108,9 +115,11 @@ bool IStaticAssetChangeApplierProvider.TryGetApplier(ProjectGraphNode projectNod

public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server)
{
var key = GetProjectKey(projectNode);

lock (_serversGuard)
{
return _servers.TryGetValue(projectNode, out server) && server != null;
return _servers.TryGetValue(key, out server) && server != null;
}
}

Expand Down Expand Up @@ -156,7 +165,7 @@ void handler(OutputLine line)
matchFound = true;

if (projectOptions.IsRootProject &&
ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, projectNode) => set.Add(projectNode), projectNode))
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);
Expand Down
16 changes: 14 additions & 2 deletions src/BuiltInTools/dotnet-watch/Browser/BrowserRefreshServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ namespace Microsoft.DotNet.Watch
{
/// <summary>
/// Communicates with aspnetcore-browser-refresh.js loaded in the browser.
/// Associated with a project instance.
/// </summary>
internal sealed class BrowserRefreshServer : IAsyncDisposable, IStaticAssetChangeApplier
{
private static readonly ReadOnlyMemory<byte> s_reloadMessage = Encoding.UTF8.GetBytes("Reload");
private static readonly ReadOnlyMemory<byte> s_waitMessage = Encoding.UTF8.GetBytes("Wait");
private static readonly JsonSerializerOptions s_jsonSerializerOptions = new(JsonSerializerDefaults.Web);

private static bool? s_lazyTlsSupported;

private readonly List<BrowserConnection> _activeConnections = [];
private readonly RSA _rsa;
private readonly IReporter _reporter;
Expand Down Expand Up @@ -326,16 +329,25 @@ public async ValueTask SendAndReceiveAsync<TRequest>(

private async Task<bool> SupportsTlsAsync()
{
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().WaitAsync(TimeSpan.FromSeconds(10));
return process.ExitCode == 0;
result = process.ExitCode == 0;
}
catch
{
return false;
result = false;
}

s_lazyTlsSupported = result;
return result.Value;
}

public ValueTask RefreshBrowserAsync(CancellationToken cancellationToken)
Expand Down
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
Context.Reporter.Output(hotReloadEnabledMessage, emoji: "🔥");
}

await using var browserConnector = new BrowserConnector(Context);
using var fileWatcher = new FileWatcher(Context.Reporter);

for (var iteration = 0; !shutdownCancellationToken.IsCancellationRequested; iteration++)
Expand Down Expand Up @@ -98,7 +99,6 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
Context.Reporter.Verbose("Using Aspire process launcher.");
}

await using var browserConnector = new BrowserConnector(Context);
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter);
compilationHandler = new CompilationHandler(Context.Reporter, Context.EnvironmentOptions, shutdownCancellationToken);
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
Expand Down
2 changes: 2 additions & 0 deletions test/dotnet-watch.Tests/Browser/BrowserConnectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ public class BrowserConnectorTests
{
[Theory]
[InlineData(null, "https://localhost:1234", "https://localhost:1234")]
[InlineData(null, "https://localhost:1234/", "https://localhost:1234/")]
[InlineData("", "https://localhost:1234", "https://localhost:1234")]
[InlineData(" ", "https://localhost:1234", "https://localhost:1234")]
[InlineData("", "a/b", "a/b")]
[InlineData("x/y", "a/b", "a/b")]
[InlineData("a/b?X=1", "https://localhost:1234", "https://localhost:1234/a/b?X=1")]
[InlineData("https://localhost:1000/", "https://localhost:1234", "https://localhost:1000/")]
[InlineData("https://localhost:1000/a/b", "https://localhost:1234", "https://localhost:1000/a/b")]
[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)
Expand Down
22 changes: 22 additions & 0 deletions test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,28 @@ public async Task BlazorWasm_MSBuildWarning()
await App.AssertWaitingForChanges();
}

[Fact]
public async Task BlazorWasm_Restart()
{
var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm")
.WithSource();

var port = TestOptions.GetTestPort();
App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.ReadKeyFromStdin | TestFlags.MockBrowser);

await App.AssertWaitingForChanges();

App.AssertOutputContains(MessageDescriptor.ConfiguredToUseBrowserRefresh);
App.AssertOutputContains(MessageDescriptor.ConfiguredToLaunchBrowser);

// Browser is launched based on blazor-devserver output "Now listening on: ...".
await App.WaitUntilOutputContains($"dotnet watch ⌚ Launching browser: http://localhost:{port}");

App.SendControlR();

await App.WaitUntilOutputContains($"dotnet watch ⌚ Reloading browser.");
}

[Fact]
public async Task Razor_Component_ScopedCssAndStaticAssets()
{
Expand Down
Loading