From e59774bb07796a4587138cacf46f39b18ec98d6d Mon Sep 17 00:00:00 2001 From: Ondrej Roztocil Date: Tue, 15 Jul 2025 18:09:58 +0200 Subject: [PATCH] Add visibility change beacon and circuit cleanup heuristic --- .../ComponentEndpointConventionBuilder.cs | 5 + ...ComponentEndpointRouteBuilderExtensions.cs | 7 +- .../Server/src/CircuitDisconnectMiddleware.cs | 8 +- .../src/CircuitVisibilityChangeMiddleware.cs | 111 ++++++++++++++++++ .../Server/src/Circuits/CircuitHost.cs | 2 + .../Server/src/Circuits/CircuitRegistry.cs | 38 +++++- ...faultInMemoryCircuitPersistenceProvider.cs | 2 +- .../Web.JS/src/Boot.Server.Common.ts | 7 +- .../src/Platform/Circuits/CircuitManager.ts | 19 ++- 9 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/Components/Server/src/CircuitVisibilityChangeMiddleware.cs diff --git a/src/Components/Server/src/Builder/ComponentEndpointConventionBuilder.cs b/src/Components/Server/src/Builder/ComponentEndpointConventionBuilder.cs index dd26550d25f6..737d694cfbe3 100644 --- a/src/Components/Server/src/Builder/ComponentEndpointConventionBuilder.cs +++ b/src/Components/Server/src/Builder/ComponentEndpointConventionBuilder.cs @@ -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; } @@ -30,6 +33,7 @@ public void Add(Action convention) { _hubEndpoint.Add(convention); _disconnectEndpoint.Add(convention); + _visibilityChangeEndpoint.Add(convention); _jsInitializersEndpoint.Add(convention); } @@ -38,6 +42,7 @@ public void Finally(Action finalConvention) { _hubEndpoint.Finally(finalConvention); _disconnectEndpoint.Finally(finalConvention); + _visibilityChangeEndpoint.Finally(finalConvention); _jsInitializersEndpoint.Finally(finalConvention); } } diff --git a/src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs b/src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs index 26cca2322d0f..9b71f1a2a86f 100644 --- a/src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs +++ b/src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs @@ -80,11 +80,16 @@ public static ComponentEndpointConventionBuilder MapBlazorHub( endpoints.CreateApplicationBuilder().UseMiddleware().Build()) .WithDisplayName("Blazor disconnect"); + var visibilityChangeEndpoint = endpoints.Map( + (path.EndsWith('/') ? path : path + "/") + "visibility/", + endpoints.CreateApplicationBuilder().UseMiddleware().Build()) + .WithDisplayName("Blazor visibility change"); + var jsInitializersEndpoint = endpoints.Map( (path.EndsWith('/') ? path : path + "/") + "initializers/", endpoints.CreateApplicationBuilder().UseMiddleware().Build()) .WithDisplayName("Blazor initializers"); - return new ComponentEndpointConventionBuilder(hubEndpoint, disconnectEndpoint, jsInitializersEndpoint); + return new ComponentEndpointConventionBuilder(hubEndpoint, disconnectEndpoint, visibilityChangeEndpoint, jsInitializersEndpoint); } } diff --git a/src/Components/Server/src/CircuitDisconnectMiddleware.cs b/src/Components/Server/src/CircuitDisconnectMiddleware.cs index d770b4fcef67..c3271786ed55 100644 --- a/src/Components/Server/src/CircuitDisconnectMiddleware.cs +++ b/src/Components/Server/src/CircuitDisconnectMiddleware.cs @@ -7,7 +7,13 @@ namespace Microsoft.AspNetCore.Components.Server; -// We use a middleware so that we can use DI. +/// +/// 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. +/// +/// +/// We use a middleware so that we can use DI. +/// internal sealed partial class CircuitDisconnectMiddleware { private const string CircuitIdKey = "circuitId"; diff --git a/src/Components/Server/src/CircuitVisibilityChangeMiddleware.cs b/src/Components/Server/src/CircuitVisibilityChangeMiddleware.cs new file mode 100644 index 000000000000..943f4b48c07c --- /dev/null +++ b/src/Components/Server/src/CircuitVisibilityChangeMiddleware.cs @@ -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; + +/// +/// 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 for more details. +/// +/// +/// We use a middleware so that we can use DI. +/// +internal sealed partial class CircuitVisibilityChangeMiddleware +{ + private const string CircuitIdKey = "circuitId"; + + public CircuitVisibilityChangeMiddleware( + ILogger logger, + CircuitRegistry registry, + CircuitIdFactory circuitIdFactory, + RequestDelegate next) + { + Logger = logger; + Registry = registry; + CircuitIdFactory = circuitIdFactory; + Next = next; + } + + public ILogger 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 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); + } +} diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index ba684e1984cd..5eb5165a9327 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -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 diff --git a/src/Components/Server/src/Circuits/CircuitRegistry.cs b/src/Components/Server/src/Circuits/CircuitRegistry.cs index 6b068e56eae1..601fa8d3aaf0 100644 --- a/src/Components/Server/src/Circuits/CircuitRegistry.cs +++ b/src/Components/Server/src/Circuits/CircuitRegistry.cs @@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits; /// 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. /// #pragma warning disable CA1852 // Seal internal types @@ -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 options, @@ -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) { @@ -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 _)); + } + + /// + /// Provides a heuristic to determine if the disconnected circuit can be fully terminated and disposed, or should be moved to the cache. + /// + /// This optimization is intended mainly for mobile browsers where the client often doesn't send the explicit cleanup beacon processed by . + /// 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). + /// + /// True if the circuit can be terminated, false otherwise. + 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) diff --git a/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs b/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs index 80679549690b..f6b6d32e648f 100644 --- a/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs +++ b/src/Components/Server/src/Circuits/DefaultInMemoryCircuitPersistenceProvider.cs @@ -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(); diff --git a/src/Components/Web.JS/src/Boot.Server.Common.ts b/src/Components/Web.JS/src/Boot.Server.Common.ts index 447ab449444e..44cfe3c3e6f3 100644 --- a/src/Components/Web.JS/src/Boot.Server.Common.ts +++ b/src/Components/Web.JS/src/Boot.Server.Common.ts @@ -119,9 +119,14 @@ async function startServerCore(components: RootComponentManager { + 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.'); diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index 36911f4ce3bb..d847441cd8ad 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -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; } @@ -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 {