Skip to content

Add visibility change beacon and circuit cleanup heuristic for Blazor Server #62789

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ public sealed class ComponentEndpointConventionBuilder : IHubEndpointConventionB
{
private readonly IEndpointConventionBuilder _hubEndpoint;
private readonly IEndpointConventionBuilder _disconnectEndpoint;
private readonly IEndpointConventionBuilder _visibilityChangeEndpoint;
private readonly IEndpointConventionBuilder _jsInitializersEndpoint;

internal ComponentEndpointConventionBuilder(
IEndpointConventionBuilder hubEndpoint,
IEndpointConventionBuilder disconnectEndpoint,
IEndpointConventionBuilder visibilityChangeEndpoint,
IEndpointConventionBuilder jsInitializersEndpoint)
{
_hubEndpoint = hubEndpoint;
_disconnectEndpoint = disconnectEndpoint;
_visibilityChangeEndpoint = visibilityChangeEndpoint;
_jsInitializersEndpoint = jsInitializersEndpoint;
}

Expand All @@ -30,6 +33,7 @@ public void Add(Action<EndpointBuilder> convention)
{
_hubEndpoint.Add(convention);
_disconnectEndpoint.Add(convention);
_visibilityChangeEndpoint.Add(convention);
_jsInitializersEndpoint.Add(convention);
}

Expand All @@ -38,6 +42,7 @@ public void Finally(Action<EndpointBuilder> finalConvention)
{
_hubEndpoint.Finally(finalConvention);
_disconnectEndpoint.Finally(finalConvention);
_visibilityChangeEndpoint.Finally(finalConvention);
_jsInitializersEndpoint.Finally(finalConvention);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,16 @@ public static ComponentEndpointConventionBuilder MapBlazorHub(
endpoints.CreateApplicationBuilder().UseMiddleware<CircuitDisconnectMiddleware>().Build())
.WithDisplayName("Blazor disconnect");

var visibilityChangeEndpoint = endpoints.Map(
(path.EndsWith('/') ? path : path + "/") + "visibility/",
endpoints.CreateApplicationBuilder().UseMiddleware<CircuitVisibilityChangeMiddleware>().Build())
.WithDisplayName("Blazor visibility change");

var jsInitializersEndpoint = endpoints.Map(
(path.EndsWith('/') ? path : path + "/") + "initializers/",
endpoints.CreateApplicationBuilder().UseMiddleware<CircuitJavaScriptInitializationMiddleware>().Build())
.WithDisplayName("Blazor initializers");

return new ComponentEndpointConventionBuilder(hubEndpoint, disconnectEndpoint, jsInitializersEndpoint);
return new ComponentEndpointConventionBuilder(hubEndpoint, disconnectEndpoint, visibilityChangeEndpoint, jsInitializersEndpoint);
}
}
8 changes: 7 additions & 1 deletion src/Components/Server/src/CircuitDisconnectMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

namespace Microsoft.AspNetCore.Components.Server;

// We use a middleware so that we can use DI.
/// <summary>
/// Handles the beacon message that the client attempts to send when unloading the page (i.e. when the "pagehide" event occurs).
/// When we receive this beacon, we know we can dispose the circuit and not hold any of its data for later reconnection.
/// </summary>
/// <remarks>
/// We use a middleware so that we can use DI.
/// </remarks>
internal sealed partial class CircuitDisconnectMiddleware
{
private const string CircuitIdKey = "circuitId";
Expand Down
111 changes: 111 additions & 0 deletions src/Components/Server/src/CircuitVisibilityChangeMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Components.Server;

/// <summary>
/// Handles the beacon message that indicates that the visibility of the page has changed.
/// This information is used as a heuristic for determining whether the circuit should be kept around when disconnected.
/// See <see cref="CircuitRegistry.CircuitQualifiesForTermination(CircuitHost)"/> for more details.
/// </summary>
/// <remarks>
/// We use a middleware so that we can use DI.
/// </remarks>
internal sealed partial class CircuitVisibilityChangeMiddleware
{
private const string CircuitIdKey = "circuitId";

public CircuitVisibilityChangeMiddleware(
ILogger<CircuitVisibilityChangeMiddleware> logger,
CircuitRegistry registry,
CircuitIdFactory circuitIdFactory,
RequestDelegate next)
{
Logger = logger;
Registry = registry;
CircuitIdFactory = circuitIdFactory;
Next = next;
}

public ILogger<CircuitVisibilityChangeMiddleware> Logger { get; }
public CircuitRegistry Registry { get; }
public CircuitIdFactory CircuitIdFactory { get; }
public RequestDelegate Next { get; }

public async Task Invoke(HttpContext context)
{
if (!HttpMethods.IsPost(context.Request.Method))
{
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
return;
}

var beaconData = await GetBeaconDataAsync(context);
if (beaconData is null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}

Registry.UpdatePageHiddenTimestamp(beaconData.Value.CircuitId, beaconData.Value.IsVisible);

context.Response.StatusCode = StatusCodes.Status200OK;
}

private async Task<VisibilityChangeBeacon?> GetBeaconDataAsync(HttpContext context)
{
try
{
if (!context.Request.HasFormContentType)
{
return default;
}

var form = await context.Request.ReadFormAsync();

if (!form.TryGetValue(CircuitIdKey, out var circuitIdText))
{
return default;
}

if (!CircuitIdFactory.TryParseCircuitId(circuitIdText, out var circuitId))
{
Log.InvalidCircuitId(Logger, circuitIdText);
return default;
}

if (!form.TryGetValue("isVisible", out var isVisibleText)
|| !bool.TryParse(isVisibleText, out var isVisible))
{
return default;
}

return new VisibilityChangeBeacon
{
CircuitId = circuitId,
IsVisible = isVisible
};
}
catch
{
return default;
}
}

private readonly struct VisibilityChangeBeacon
{
public required CircuitId CircuitId { get; init; }

public required bool IsVisible { get; init; }
}

private static partial class Log
{
[LoggerMessage(1, LogLevel.Debug, "CircuitDisconnectMiddleware received an invalid circuit id '{CircuitIdSecret}'.", EventName = "InvalidCircuitId")]
public static partial void InvalidCircuitId(ILogger logger, string circuitIdSecret);
}
}
2 changes: 2 additions & 0 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ public CircuitHost(

public IServiceProvider Services { get; }

public DateTime? PageHiddenAt { get; set; }

internal bool HasPendingPersistedCircuitState => _persistedCircuitState != null;

// InitializeAsync is used in a fire-and-forget context, so it's responsible for its own
Expand Down
38 changes: 37 additions & 1 deletion src/Components/Server/src/Circuits/CircuitRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;
/// <see cref="DisconnectedCircuits"/> should ensure we no longer have to concern ourselves with entry expiration.
///
/// Knowing when a client disconnected is not an exact science. There's a fair possibility that a client may reconnect before the server realizes.
/// Consequently, we have to account for reconnects and disconnects occuring simultaneously as well as appearing out of order.
/// Consequently, we have to account for reconnects and disconnects occurring simultaneously as well as appearing out of order.
/// To manage this, we use a critical section to manage all state transitions.
/// </remarks>
#pragma warning disable CA1852 // Seal internal types
Expand All @@ -43,6 +43,7 @@ internal partial class CircuitRegistry
private readonly CircuitIdFactory _circuitIdFactory;
private readonly CircuitPersistenceManager _circuitPersistenceManager;
private readonly PostEvictionCallbackRegistration _postEvictionCallback;
private static readonly TimeSpan _terminateOnHiddenPeriod = TimeSpan.FromMinutes(3);

public CircuitRegistry(
IOptions<CircuitOptions> options,
Expand Down Expand Up @@ -91,6 +92,11 @@ public virtual Task DisconnectAsync(CircuitHost circuitHost, string connectionId
{
Log.CircuitDisconnectStarted(_logger, circuitHost.CircuitId, connectionId);

if (CircuitQualifiesForTermination(circuitHost))
{
return TerminateAsync(circuitHost.CircuitId).AsTask();
}

Task circuitHandlerTask;
lock (CircuitRegistryLock)
{
Expand Down Expand Up @@ -401,6 +407,36 @@ public ValueTask TerminateAsync(CircuitId circuitId)
return default;
}

public void UpdatePageHiddenTimestamp(CircuitId circuitId, bool isVisible)
{
if (ConnectedCircuits.TryGetValue(circuitId, out var circuitHost))
{
circuitHost.PageHiddenAt = isVisible ? null : DateTime.UtcNow;
}

Debug.Assert(isVisible || !DisconnectedCircuits.TryGetValue(circuitId.Secret, out _));
}

/// <summary>
/// Provides a heuristic to determine if the disconnected circuit can be fully terminated and disposed, or should be moved to the <see cref="DisconnectedCircuits"/> cache.
///
/// This optimization is intended mainly for mobile browsers where the client often doesn't send the explicit cleanup beacon processed by <see cref="CircuitDisconnectMiddleware"/>.
/// However, we check how recently the page has been hidden against a threshold because we don't want to dispose circuits that have been disconnected due to other client-side optimizations (e.g. tab freezing or throttling).
/// </summary>
/// <returns>True if the circuit can be terminated, false otherwise.</returns>
private static bool CircuitQualifiesForTermination(CircuitHost circuitHost)
{
if (!circuitHost.PageHiddenAt.HasValue)
{
// If the server doesn't know that the page has been hidden, assume that the client might want to restore the circuit.
return false;
}

// If the page has been hidden recently, assume that the client will not want to restore the circuit.
var timeSinceHidden = DateTime.UtcNow - circuitHost.PageHiddenAt.Value;
return timeSinceHidden <= _terminateOnHiddenPeriod;
}

// We don't need to do anything with the exception here, logging and sending exceptions to the client
// is done inside the circuit host.
private async void CircuitHost_UnhandledException(object sender, UnhandledExceptionEventArgs e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

namespace Microsoft.AspNetCore.Components.Server.Circuits;

// Default implmentation of ICircuitPersistenceProvider that uses an in-memory cache
// Default implementation of ICircuitPersistenceProvider that uses an in-memory cache
internal sealed partial class DefaultInMemoryCircuitPersistenceProvider : ICircuitPersistenceProvider
{
private readonly Lock _lock = new();
Expand Down
7 changes: 6 additions & 1 deletion src/Components/Web.JS/src/Boot.Server.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,14 @@ async function startServerCore(components: RootComponentManager<ServerComponentD
circuit.sendDisconnectBeacon();
};

const onVisibilityChange = () => {
circuit.sendVisibilityChangeBeacon(document.visibilityState === 'visible');
};

Blazor.disconnect = cleanup;

window.addEventListener('unload', cleanup, { capture: false, once: true });
window.addEventListener('pagehide', cleanup, { capture: false, once: true });
document.addEventListener('visibilitychange', onVisibilityChange, { capture: false, once: false });

logger.log(LogLevel.Information, 'Blazor server-side application started.');

Expand Down
19 changes: 18 additions & 1 deletion src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,14 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
return data;
}

private getVisibilityChangeFormData(isVisible: boolean): FormData {
const data = new FormData();
const circuitId = this._circuitId!;
data.append('circuitId', circuitId);
data.append('isVisible', isVisible.toString());
return data;
}

public didRenderingFail(): boolean {
return this._renderingFailed;
}
Expand All @@ -502,7 +510,16 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
}

const data = this.getDisconnectFormData();
this._disposed = navigator.sendBeacon('_blazor/disconnect', data);
this._disposed = navigator.sendBeacon('_blazor/disconnect', data);
}

public sendVisibilityChangeBeacon(isVisible: boolean) {
if (this._disposed) {
return;
}

const data = this.getVisibilityChangeFormData(isVisible);
navigator.sendBeacon('_blazor/visibility', data);
}

public dispose(): Promise<void> {
Expand Down
Loading