Skip to content

Commit 97a21fd

Browse files
committed
Graceful reconnection alternative
1 parent 521cabc commit 97a21fd

File tree

8 files changed

+141
-41
lines changed

8 files changed

+141
-41
lines changed

src/Components/Server/src/Builder/ComponentEndpointConventionBuilder.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ namespace Microsoft.AspNetCore.Builder
1010
/// </summary>
1111
public sealed class ComponentEndpointConventionBuilder : IHubEndpointConventionBuilder
1212
{
13-
private readonly IEndpointConventionBuilder _endpointConventionBuilder;
13+
private readonly IEndpointConventionBuilder [] _endpointConventionBuilders;
1414

15-
internal ComponentEndpointConventionBuilder(IEndpointConventionBuilder endpointConventionBuilder)
15+
internal ComponentEndpointConventionBuilder(params IEndpointConventionBuilder [] endpointConventionBuilder)
1616
{
17-
_endpointConventionBuilder = endpointConventionBuilder;
17+
_endpointConventionBuilders = endpointConventionBuilder;
1818
}
1919

2020
/// <summary>
@@ -23,7 +23,10 @@ internal ComponentEndpointConventionBuilder(IEndpointConventionBuilder endpointC
2323
/// <param name="convention">The convention to add to the builder.</param>
2424
public void Add(Action<EndpointBuilder> convention)
2525
{
26-
_endpointConventionBuilder.Add(convention);
26+
foreach (var endpoint in _endpointConventionBuilders)
27+
{
28+
endpoint.Add(convention);
29+
}
2730
}
2831
}
2932
}

src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,17 @@ public static ComponentEndpointConventionBuilder MapBlazorHub(
292292
throw new ArgumentNullException(nameof(configureOptions));
293293
}
294294

295-
return new ComponentEndpointConventionBuilder(endpoints.MapHub<ComponentHub>(path, configureOptions)).AddComponent(componentType, selector);
295+
var hubEndpoint = endpoints.MapHub<ComponentHub>(path, configureOptions);
296+
297+
var disconnectEndpoint = endpoints.Map(
298+
(path.EndsWith("/") ? path : path + "/") + "disconnect/",
299+
endpoints.CreateApplicationBuilder().UseMiddleware<CircuitDisconnectMiddleware>().Build())
300+
.WithDisplayName("Blazor disconnect");
301+
302+
return new ComponentEndpointConventionBuilder(
303+
disconnectEndpoint,
304+
hubEndpoint)
305+
.AddComponent(componentType, selector);
296306
}
297307
}
298308
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.Components.Server.Circuits;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Microsoft.AspNetCore.Components.Server
11+
{
12+
internal class CircuitDisconnectMiddleware
13+
{
14+
private const string CircuitIdKey = "circuitId";
15+
16+
public CircuitDisconnectMiddleware(
17+
ILogger<CircuitDisconnectMiddleware> logger,
18+
CircuitRegistry registry,
19+
CircuitIdFactory circuitIdFactory,
20+
RequestDelegate next)
21+
{
22+
Logger = logger;
23+
Registry = registry;
24+
CircuitIdFactory = circuitIdFactory;
25+
Next = next;
26+
}
27+
28+
public ILogger<CircuitDisconnectMiddleware> Logger { get; }
29+
public CircuitRegistry Registry { get; }
30+
public CircuitIdFactory CircuitIdFactory { get; }
31+
public RequestDelegate Next { get; }
32+
33+
public async Task Invoke(HttpContext context)
34+
{
35+
if (!HttpMethods.IsPost(context.Request.Method))
36+
{
37+
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
38+
return;
39+
}
40+
41+
var form = await context.Request.ReadFormAsync();
42+
if(!form.TryGetValue(CircuitIdKey, out var circuitId) || !CircuitIdFactory.ValidateCircuitId(circuitId))
43+
{
44+
context.Response.StatusCode = StatusCodes.Status404NotFound;
45+
return;
46+
}
47+
48+
await TerminateCircuitGracefully(circuitId);
49+
50+
context.Response.StatusCode = StatusCodes.Status200OK;
51+
return;
52+
}
53+
54+
private async Task TerminateCircuitGracefully(string circuitId)
55+
{
56+
try
57+
{
58+
Log.CircuitTerminatedGracefully(Logger, circuitId);
59+
await Registry.Terminate(circuitId);
60+
}
61+
catch (Exception e)
62+
{
63+
Log.UnhandledExceptionInCircuit(Logger, circuitId, e);
64+
}
65+
}
66+
67+
private class Log
68+
{
69+
private static readonly Action<ILogger, string, Exception> _circuitTerminatedGracefully =
70+
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(1, "CircuitTerminatedGracefully"), "Circuit '{CircuitId}' terminated gracefully");
71+
72+
private static readonly Action<ILogger, string, Exception> _unhandledExceptionInCircuit =
73+
LoggerMessage.Define<string>(LogLevel.Warning, new EventId(2, "UnhandledExceptionInCircuit"), "Unhandled exception in circuit {CircuitId} while terminating gracefully.");
74+
75+
public static void CircuitTerminatedGracefully(ILogger logger, string circuitId) => _circuitTerminatedGracefully(logger, circuitId, null);
76+
77+
public static void UnhandledExceptionInCircuit(ILogger logger, string circuitId, Exception exception) => _unhandledExceptionInCircuit(logger, circuitId, exception);
78+
}
79+
}
80+
}

src/Components/Server/src/Circuits/CircuitRegistry.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,10 @@ public void Register(CircuitHost circuitHost)
8383

8484
public void PermanentDisconnect(CircuitHost circuitHost)
8585
{
86-
if (ConnectedCircuits.TryRemove(circuitHost.CircuitId, out _))
87-
{
88-
Log.CircuitDisconnectedPermanently(_logger, circuitHost.CircuitId);
89-
circuitHost.Client.SetDisconnected();
90-
}
86+
DisconnectedCircuits.Remove(circuitHost.CircuitId);
87+
ConnectedCircuits.TryRemove(circuitHost.CircuitId, out _);
88+
Log.CircuitDisconnectedPermanently(_logger, circuitHost.CircuitId);
89+
circuitHost.Client.SetDisconnected();
9190
}
9291

9392
public virtual Task DisconnectAsync(CircuitHost circuitHost, string connectionId)
@@ -297,6 +296,31 @@ private void DisposeTokenSource(DisconnectedCircuitEntry entry)
297296
}
298297
}
299298

299+
internal ValueTask Terminate(string circuitId)
300+
{
301+
CircuitHost circuitHost;
302+
DisconnectedCircuitEntry entry = default;
303+
lock (CircuitRegistryLock)
304+
{
305+
if (ConnectedCircuits.TryGetValue(circuitId, out circuitHost) || DisconnectedCircuits.TryGetValue(circuitId, out entry))
306+
{
307+
PermanentDisconnect(circuitHost ?? entry.CircuitHost);
308+
}
309+
else
310+
{
311+
return default;
312+
}
313+
}
314+
if (circuitHost != null)
315+
{
316+
return circuitHost.DisposeAsync();
317+
}
318+
else
319+
{
320+
return default;
321+
}
322+
}
323+
300324
private readonly struct DisconnectedCircuitEntry
301325
{
302326
public DisconnectedCircuitEntry(CircuitHost circuitHost, CancellationTokenSource tokenSource)

src/Components/Server/src/ComponentHub.cs

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -68,34 +68,7 @@ public override Task OnDisconnectedAsync(Exception exception)
6868
return Task.CompletedTask;
6969
}
7070

71-
if (exception != null)
72-
{
73-
return _circuitRegistry.DisconnectAsync(circuitHost, Context.ConnectionId);
74-
}
75-
else
76-
{
77-
// The client will gracefully disconnect when using websockets by correctly closing the TCP connection.
78-
// This happens when the user closes a tab, navigates away from the page or reloads the page.
79-
// In these situations we know the user is done with the circuit, so we can get rid of it at that point.
80-
// This is important to be able to more efficiently manage resources, specially memory.
81-
return TerminateCircuitGracefully(circuitHost);
82-
}
83-
}
84-
85-
private async Task TerminateCircuitGracefully(CircuitHost circuitHost)
86-
{
87-
try
88-
{
89-
Log.CircuitTerminatedGracefully(_logger, circuitHost.CircuitId);
90-
_circuitRegistry.PermanentDisconnect(circuitHost);
91-
await circuitHost.DisposeAsync();
92-
}
93-
catch (Exception e)
94-
{
95-
Log.UnhandledExceptionInCircuit(_logger, circuitHost.CircuitId, e);
96-
}
97-
98-
await _circuitRegistry.DisconnectAsync(circuitHost, Context.ConnectionId);
71+
return _circuitRegistry.DisconnectAsync(circuitHost, Context.ConnectionId);
9972
}
10073

10174
/// <summary>

src/Components/Server/test/ComponentEndpointRouteBuilderExtensionsTest.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics;
55
using System.Threading.Tasks;
66
using Microsoft.AspNetCore.Builder;
7+
using Microsoft.Extensions.Configuration;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.Extensions.Hosting;
910
using Moq;
@@ -63,6 +64,7 @@ private IApplicationBuilder CreateAppBuilder()
6364
services.AddRouting();
6465
services.AddSignalR();
6566
services.AddServerSideBlazor();
67+
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
6668

6769
var serviceProvder = services.BuildServiceProvider();
6870

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Boot.Server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,17 @@ async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
4747
return true;
4848
};
4949

50+
window.addEventListener('unload', sendDisconnect, false);
51+
5052
window['Blazor'].reconnect = reconnect;
5153

5254
logger.log(LogLevel.Information, 'Blazor server-side application started.');
55+
56+
function sendDisconnect(_: Event): void {
57+
const data = new FormData();
58+
data.set('circuitId', circuit.circuitId);
59+
navigator.sendBeacon(`${document.baseURI}_blazor/disconnect`, data);
60+
}
5361
}
5462

5563
async function initializeConnection(options: BlazorOptions, logger: Logger): Promise<signalR.HubConnection> {

0 commit comments

Comments
 (0)