Skip to content

Commit 25c240b

Browse files
authored
[Blazor] Graceful disconnection
* Adds a new API endpoint to trigger graceful disconnection from blazor clients. * Uses the sendBeacon API on the Blazor client to trigger graceful disconnections on the client when the document gets unloaded, which happens when closing the window, navigating away from the page or refreshing the page.
1 parent 92869c6 commit 25c240b

10 files changed

+404
-46
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,13 @@ namespace Microsoft.AspNetCore.Builder
1010
/// </summary>
1111
public sealed class ComponentEndpointConventionBuilder : IHubEndpointConventionBuilder
1212
{
13-
private readonly IEndpointConventionBuilder _endpointConventionBuilder;
13+
private readonly IEndpointConventionBuilder _hubEndpoint;
14+
private readonly IEndpointConventionBuilder _disconnectEndpoint;
1415

15-
internal ComponentEndpointConventionBuilder(IEndpointConventionBuilder endpointConventionBuilder)
16+
internal ComponentEndpointConventionBuilder(IEndpointConventionBuilder hubEndpoint, IEndpointConventionBuilder disconnectEndpoint)
1617
{
17-
_endpointConventionBuilder = endpointConventionBuilder;
18+
_hubEndpoint = hubEndpoint;
19+
_disconnectEndpoint = disconnectEndpoint;
1820
}
1921

2022
/// <summary>
@@ -23,7 +25,8 @@ internal ComponentEndpointConventionBuilder(IEndpointConventionBuilder endpointC
2325
/// <param name="convention">The convention to add to the builder.</param>
2426
public void Add(Action<EndpointBuilder> convention)
2527
{
26-
_endpointConventionBuilder.Add(convention);
28+
_hubEndpoint.Add(convention);
29+
_disconnectEndpoint.Add(convention);
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+
hubEndpoint,
304+
disconnectEndpoint)
305+
.AddComponent(componentType, selector);
296306
}
297307
}
298308
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
// We use a middlware so that we can use DI.
13+
internal class CircuitDisconnectMiddleware
14+
{
15+
private const string CircuitIdKey = "circuitId";
16+
17+
public CircuitDisconnectMiddleware(
18+
ILogger<CircuitDisconnectMiddleware> logger,
19+
CircuitRegistry registry,
20+
CircuitIdFactory circuitIdFactory,
21+
RequestDelegate next)
22+
{
23+
Logger = logger;
24+
Registry = registry;
25+
CircuitIdFactory = circuitIdFactory;
26+
Next = next;
27+
}
28+
29+
public ILogger<CircuitDisconnectMiddleware> Logger { get; }
30+
public CircuitRegistry Registry { get; }
31+
public CircuitIdFactory CircuitIdFactory { get; }
32+
public RequestDelegate Next { get; }
33+
34+
public async Task Invoke(HttpContext context)
35+
{
36+
if (!HttpMethods.IsPost(context.Request.Method))
37+
{
38+
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
39+
return;
40+
}
41+
42+
var (hasCircuitId, circuitId) = await TryGetCircuitIdAsync(context);
43+
if (!hasCircuitId)
44+
{
45+
context.Response.StatusCode = StatusCodes.Status400BadRequest;
46+
return;
47+
}
48+
49+
await TerminateCircuitGracefully(circuitId);
50+
51+
context.Response.StatusCode = StatusCodes.Status200OK;
52+
}
53+
54+
private async Task<(bool, string)> TryGetCircuitIdAsync(HttpContext context)
55+
{
56+
try
57+
{
58+
if (!context.Request.HasFormContentType)
59+
{
60+
return (false, null);
61+
}
62+
63+
var form = await context.Request.ReadFormAsync();
64+
if (!form.TryGetValue(CircuitIdKey, out var circuitId) || !CircuitIdFactory.ValidateCircuitId(circuitId))
65+
{
66+
return (false, null);
67+
}
68+
69+
return (true, circuitId);
70+
}
71+
catch
72+
{
73+
return (false, null);
74+
}
75+
}
76+
77+
private async Task TerminateCircuitGracefully(string circuitId)
78+
{
79+
try
80+
{
81+
await Registry.Terminate(circuitId);
82+
Log.CircuitTerminatedGracefully(Logger, circuitId);
83+
}
84+
catch (Exception e)
85+
{
86+
Log.UnhandledExceptionInCircuit(Logger, circuitId, e);
87+
}
88+
}
89+
90+
private class Log
91+
{
92+
private static readonly Action<ILogger, string, Exception> _circuitTerminatedGracefully =
93+
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(1, "CircuitTerminatedGracefully"), "Circuit '{CircuitId}' terminated gracefully");
94+
95+
private static readonly Action<ILogger, string, Exception> _unhandledExceptionInCircuit =
96+
LoggerMessage.Define<string>(LogLevel.Warning, new EventId(2, "UnhandledExceptionInCircuit"), "Unhandled exception in circuit {CircuitId} while terminating gracefully.");
97+
98+
public static void CircuitTerminatedGracefully(ILogger logger, string circuitId) => _circuitTerminatedGracefully(logger, circuitId, null);
99+
100+
public static void UnhandledExceptionInCircuit(ILogger logger, string circuitId, Exception exception) => _unhandledExceptionInCircuit(logger, circuitId, exception);
101+
}
102+
}
103+
}

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,6 @@ public void Register(CircuitHost circuitHost)
8181
}
8282
}
8383

84-
public void PermanentDisconnect(CircuitHost circuitHost)
85-
{
86-
if (ConnectedCircuits.TryRemove(circuitHost.CircuitId, out _))
87-
{
88-
Log.CircuitDisconnectedPermanently(_logger, circuitHost.CircuitId);
89-
circuitHost.Client.SetDisconnected();
90-
}
91-
}
92-
9384
public virtual Task DisconnectAsync(CircuitHost circuitHost, string connectionId)
9485
{
9586
Log.CircuitDisconnectStarted(_logger, circuitHost.CircuitId, connectionId);
@@ -297,6 +288,29 @@ private void DisposeTokenSource(DisconnectedCircuitEntry entry)
297288
}
298289
}
299290

291+
public ValueTask Terminate(string circuitId)
292+
{
293+
CircuitHost circuitHost;
294+
DisconnectedCircuitEntry entry = default;
295+
lock (CircuitRegistryLock)
296+
{
297+
if (ConnectedCircuits.TryGetValue(circuitId, out circuitHost) || DisconnectedCircuits.TryGetValue(circuitId, out entry))
298+
{
299+
circuitHost ??= entry.CircuitHost;
300+
DisconnectedCircuits.Remove(circuitHost.CircuitId);
301+
ConnectedCircuits.TryRemove(circuitHost.CircuitId, out _);
302+
Log.CircuitDisconnectedPermanently(_logger, circuitHost.CircuitId);
303+
circuitHost.Client.SetDisconnected();
304+
}
305+
else
306+
{
307+
return default;
308+
}
309+
}
310+
311+
return circuitHost?.DisposeAsync() ?? default;
312+
}
313+
300314
private readonly struct DisconnectedCircuitEntry
301315
{
302316
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>

0 commit comments

Comments
 (0)