Skip to content

Commit

Permalink
Reuse browser when restarting project (#46381)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmat authored Feb 3, 2025
1 parent 1975b3c commit 6618627
Show file tree
Hide file tree
Showing 5 changed files with 55 additions and 10 deletions.
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

0 comments on commit 6618627

Please sign in to comment.