Skip to content

Commit 16438b6

Browse files
authored
[Blazor] Adds support for manually pausing and resuming circuits.
* Two new APIs (`pause()` and `resume()`) allow server components to be paused and resumed. * During graceful pauses the state gets pushed to the browser. * If pushing the state to the browser fails, it gets stored on the server as a fallback. * The reconnection UI is extended to display new elements when a circuit gets paused.
1 parent 2ee09f5 commit 16438b6

34 files changed

+1519
-143
lines changed

src/Components/Server/src/Circuits/CircuitClientProxy.cs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55

66
namespace Microsoft.AspNetCore.Components.Server.Circuits;
77

8-
internal sealed class CircuitClientProxy : IClientProxy
8+
internal sealed class CircuitClientProxy : ISingleClientProxy
99
{
1010
public CircuitClientProxy()
1111
{
1212
Connected = false;
1313
}
1414

15-
public CircuitClientProxy(IClientProxy clientProxy, string connectionId)
15+
public CircuitClientProxy(ISingleClientProxy clientProxy, string connectionId)
1616
{
1717
Transfer(clientProxy, connectionId);
1818
}
@@ -21,9 +21,9 @@ public CircuitClientProxy(IClientProxy clientProxy, string connectionId)
2121

2222
public string ConnectionId { get; private set; }
2323

24-
public IClientProxy Client { get; private set; }
24+
public ISingleClientProxy Client { get; private set; }
2525

26-
public void Transfer(IClientProxy clientProxy, string connectionId)
26+
public void Transfer(ISingleClientProxy clientProxy, string connectionId)
2727
{
2828
Client = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
2929
ConnectionId = connectionId ?? throw new ArgumentNullException(nameof(connectionId));
@@ -44,4 +44,13 @@ public Task SendCoreAsync(string method, object[] args, CancellationToken cancel
4444

4545
return Client.SendCoreAsync(method, args, cancellationToken);
4646
}
47+
48+
public Task<T> InvokeCoreAsync<T>(string method, object[] args, CancellationToken cancellationToken = default)
49+
{
50+
if (Client == null)
51+
{
52+
throw new InvalidOperationException($"{nameof(InvokeCoreAsync)} cannot be invoked with an offline client.");
53+
}
54+
return Client.InvokeCoreAsync<T>(method, args, cancellationToken);
55+
}
4756
}

src/Components/Server/src/Circuits/CircuitHost.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,25 @@ internal PersistedCircuitState TakePersistedCircuitState()
893893
return result;
894894
}
895895

896+
internal async Task<bool> SendPersistedStateToClient(string rootComponents, string applicationState, CancellationToken cancellation)
897+
{
898+
try
899+
{
900+
var succeded = await Client.InvokeAsync<bool>(
901+
"JS.SavePersistedState",
902+
CircuitId.Secret,
903+
rootComponents,
904+
applicationState,
905+
cancellationToken: cancellation);
906+
return succeded;
907+
}
908+
catch (Exception ex)
909+
{
910+
Log.FailedToSaveStateToClient(_logger, CircuitId, ex);
911+
return false;
912+
}
913+
}
914+
896915
private static partial class Log
897916
{
898917
// 100s used for lifecycle stuff
@@ -1048,5 +1067,8 @@ public static void BeginInvokeDotNetFailed(ILogger logger, string callId, string
10481067

10491068
[LoggerMessage(219, LogLevel.Error, "Location change to '{URI}' in circuit '{CircuitId}' failed.", EventName = "LocationChangeFailedInCircuit")]
10501069
public static partial void LocationChangeFailedInCircuit(ILogger logger, string uri, CircuitId circuitId, Exception exception);
1070+
1071+
[LoggerMessage(220, LogLevel.Debug, "Failed to save state to client in circuit '{CircuitId}'.", EventName = "FailedToSaveStateToClient")]
1072+
public static partial void FailedToSaveStateToClient(ILogger logger, CircuitId circuitId, Exception exception);
10511073
}
10521074
}

src/Components/Server/src/Circuits/CircuitPersistenceManager.cs

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Text;
45
using System.Text.Json;
56
using System.Text.Json.Serialization;
67
using Microsoft.AspNetCore.Components.Endpoints;
78
using Microsoft.AspNetCore.Components.Infrastructure;
89
using Microsoft.AspNetCore.Components.Web;
10+
using Microsoft.AspNetCore.DataProtection;
911
using Microsoft.Extensions.DependencyInjection;
1012
using Microsoft.Extensions.Options;
1113

@@ -14,9 +16,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;
1416
internal partial class CircuitPersistenceManager(
1517
IOptions<CircuitOptions> circuitOptions,
1618
ServerComponentSerializer serverComponentSerializer,
17-
ICircuitPersistenceProvider circuitPersistenceProvider)
19+
ICircuitPersistenceProvider circuitPersistenceProvider,
20+
IDataProtectionProvider dataProtectionProvider)
1821
{
19-
public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cancellation = default)
22+
public async Task PauseCircuitAsync(CircuitHost circuit, bool saveStateToClient = false, CancellationToken cancellation = default)
2023
{
2124
var renderer = circuit.Renderer;
2225
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
@@ -27,10 +30,67 @@ public async Task PauseCircuitAsync(CircuitHost circuit, CancellationToken cance
2730

2831
await persistenceManager.PersistStateAsync(collector, renderer);
2932

30-
await circuitPersistenceProvider.PersistCircuitAsync(
31-
circuit.CircuitId,
32-
collector.PersistedCircuitState,
33-
cancellation);
33+
if (saveStateToClient)
34+
{
35+
await SaveStateToClient(circuit, collector.PersistedCircuitState, cancellation);
36+
}
37+
else
38+
{
39+
await circuitPersistenceProvider.PersistCircuitAsync(
40+
circuit.CircuitId,
41+
collector.PersistedCircuitState,
42+
cancellation);
43+
}
44+
}
45+
46+
internal async Task SaveStateToClient(CircuitHost circuit, PersistedCircuitState state, CancellationToken cancellation = default)
47+
{
48+
var (rootComponents, applicationState) = await ToProtectedStateAsync(state);
49+
if (!await circuit.SendPersistedStateToClient(rootComponents, applicationState, cancellation))
50+
{
51+
try
52+
{
53+
await circuitPersistenceProvider.PersistCircuitAsync(
54+
circuit.CircuitId,
55+
state,
56+
cancellation);
57+
}
58+
catch (Exception)
59+
{
60+
// At this point, we give up as we haven't been able to save the state to the client nor the server.
61+
return;
62+
}
63+
}
64+
}
65+
66+
internal async Task<(string rootComponents, string applicationState)> ToProtectedStateAsync(PersistedCircuitState state)
67+
{
68+
// Root components descriptors are already protected and serialized as JSON, we just convert the bytes to a string.
69+
var rootComponents = Encoding.UTF8.GetString(state.RootComponents);
70+
71+
// The application state we protect in the same way we do for prerendering.
72+
var store = new ProtectedPrerenderComponentApplicationStore(dataProtectionProvider);
73+
await store.PersistStateAsync(state.ApplicationState);
74+
75+
return (rootComponents, store.PersistedState);
76+
}
77+
78+
internal PersistedCircuitState FromProtectedState(string rootComponents, string applicationState)
79+
{
80+
var rootComponentsBytes = Encoding.UTF8.GetBytes(rootComponents);
81+
var prerenderedState = new ProtectedPrerenderComponentApplicationStore(applicationState, dataProtectionProvider);
82+
var state = new PersistedCircuitState
83+
{
84+
RootComponents = rootComponentsBytes,
85+
ApplicationState = prerenderedState.ExistingState
86+
};
87+
88+
return state;
89+
}
90+
91+
internal ProtectedPrerenderComponentApplicationStore ToComponentApplicationStore(Dictionary<string, byte[]> applicationState)
92+
{
93+
return new ProtectedPrerenderComponentApplicationStore(applicationState, dataProtectionProvider);
3494
}
3595

3696
public async Task<PersistedCircuitState> ResumeCircuitAsync(CircuitId circuitId, CancellationToken cancellation = default)

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

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ public void RegisterDisconnectedCircuit(CircuitHost circuitHost)
169169
// 1. If the circuit is not found return null
170170
// 2. If the circuit is found, but fails to connect, we need to dispose it here and return null
171171
// 3. If everything goes well, return the circuit.
172-
public virtual async Task<CircuitHost> ConnectAsync(CircuitId circuitId, IClientProxy clientProxy, string connectionId, CancellationToken cancellationToken)
172+
public virtual async Task<CircuitHost> ConnectAsync(CircuitId circuitId, ISingleClientProxy clientProxy, string connectionId, CancellationToken cancellationToken)
173173
{
174174
Log.CircuitConnectStarted(_logger, circuitId);
175175

@@ -228,7 +228,7 @@ public virtual async Task<CircuitHost> ConnectAsync(CircuitId circuitId, IClient
228228
}
229229
}
230230

231-
protected virtual (CircuitHost circuitHost, bool previouslyConnected) ConnectCore(CircuitId circuitId, IClientProxy clientProxy, string connectionId)
231+
protected virtual (CircuitHost circuitHost, bool previouslyConnected) ConnectCore(CircuitId circuitId, ISingleClientProxy clientProxy, string connectionId)
232232
{
233233
if (ConnectedCircuits.TryGetValue(circuitId, out var connectedCircuitHost))
234234
{
@@ -281,7 +281,7 @@ protected virtual void OnEntryEvicted(object key, object value, EvictionReason r
281281
}
282282
}
283283

284-
private async Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry)
284+
private Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry)
285285
{
286286
DisposeTokenSource(entry);
287287

@@ -291,20 +291,76 @@ private async Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry)
291291
{
292292
// Only pause and persist the circuit state if it has been active at some point,
293293
// meaning that the client called UpdateRootComponents on it.
294-
await _circuitPersistenceManager.PauseCircuitAsync(entry.CircuitHost);
294+
var circuitHost = entry.CircuitHost;
295+
return PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: false);
295296
}
296297
else
297298
{
298299
Log.PersistedCircuitStateDiscarded(_logger, entry.CircuitHost.CircuitId);
299300
}
300-
301-
entry.CircuitHost.UnhandledException -= CircuitHost_UnhandledException;
302-
await entry.CircuitHost.DisposeAsync();
303301
}
304302
catch (Exception ex)
305303
{
306304
Log.UnhandledExceptionDisposingCircuitHost(_logger, ex);
307305
}
306+
307+
return Task.CompletedTask;
308+
}
309+
310+
private async Task PauseAndDisposeCircuitHost(CircuitHost circuitHost, bool saveStateToClient)
311+
{
312+
await _circuitPersistenceManager.PauseCircuitAsync(circuitHost, saveStateToClient);
313+
circuitHost.UnhandledException -= CircuitHost_UnhandledException;
314+
await circuitHost.DisposeAsync();
315+
}
316+
317+
internal async Task PauseCircuitAsync(
318+
CircuitHost circuitHost,
319+
string connectionId)
320+
{
321+
try
322+
{
323+
Log.CircuitPauseStarted(_logger, circuitHost.CircuitId, connectionId);
324+
325+
Task pauseTask;
326+
lock (CircuitRegistryLock)
327+
{
328+
pauseTask = PauseCore(circuitHost, connectionId);
329+
}
330+
await pauseTask;
331+
}
332+
catch (Exception)
333+
{
334+
Log.CircuitPauseFailed(_logger, circuitHost.CircuitId, connectionId);
335+
}
336+
}
337+
338+
internal virtual Task PauseCore(CircuitHost circuitHost, string connectionId)
339+
{
340+
var circuitId = circuitHost.CircuitId;
341+
if (!ConnectedCircuits.TryGetValue(circuitId, out circuitHost))
342+
{
343+
Log.CircuitNotActive(_logger, circuitId);
344+
345+
// Circuit should be in the connected state for pausing.
346+
return Task.CompletedTask;
347+
}
348+
349+
if (!string.Equals(circuitHost.Client.ConnectionId, connectionId, StringComparison.Ordinal))
350+
{
351+
// Circuit should be connected to the same connection for pausing.
352+
Log.CircuitConnectedToDifferentConnection(_logger, circuitId, circuitHost.Client.ConnectionId);
353+
354+
// The circuit is associated with a different connection. One way this could happen is when
355+
// the client reconnects with a new connection before the OnDisconnect for the older
356+
// connection is executed. Do nothing
357+
return Task.CompletedTask;
358+
}
359+
360+
var removeResult = ConnectedCircuits.TryRemove(circuitId, out _);
361+
Debug.Assert(removeResult, "This operation operates inside of a lock. We expect the previously inspected value to be still here.");
362+
363+
return PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
308364
}
309365

310366
private void DisposeTokenSource(DisconnectedCircuitEntry entry)
@@ -430,5 +486,11 @@ public static void ExceptionDisposingTokenSource(ILogger logger, Exception excep
430486

431487
[LoggerMessage(116, LogLevel.Debug, "Circuit {CircuitId} was not resumed. Persisted circuit state for {CircuitId} discarded.", EventName = "PersistedCircuitStateDiscarded")]
432488
public static partial void PersistedCircuitStateDiscarded(ILogger logger, CircuitId circuitId);
489+
490+
[LoggerMessage(117, LogLevel.Debug, "Pausing circuit with id {CircuitId} from connection {ConnectionId}.", EventName = "CircuitPauseStarted")]
491+
public static partial void CircuitPauseStarted(ILogger logger, CircuitId circuitId, string connectionId);
492+
493+
[LoggerMessage(118, LogLevel.Debug, "Failed to pause circuit with id {CircuitId} from connection {ConnectionId}.", EventName = "CircuitPauseFailed")]
494+
public static partial void CircuitPauseFailed(ILogger logger, CircuitId circuitId, string connectionId);
433495
}
434496
}

src/Components/Server/src/ComponentHub.cs

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,19 @@ public async ValueTask<string> ResumeCircuit(
323323
return null;
324324
}
325325
}
326-
else if (!RootComponentIsEmpty(rootComponents) || !string.IsNullOrEmpty(applicationState))
326+
else if (!RootComponentIsEmpty(rootComponents) && !string.IsNullOrEmpty(applicationState))
327+
{
328+
persistedCircuitState = _circuitPersistenceManager.FromProtectedState(rootComponents, applicationState);
329+
if (persistedCircuitState == null)
330+
{
331+
// If we couldn't deserialize the persisted state, signal that.
332+
Log.InvalidInputData(_logger);
333+
await NotifyClientError(Clients.Caller, "The root components or application state provided are invalid.");
334+
Context.Abort();
335+
return null;
336+
}
337+
}
338+
else
327339
{
328340
Log.InvalidInputData(_logger);
329341
await NotifyClientError(
@@ -335,12 +347,6 @@ await NotifyClientError(
335347
Context.Abort();
336348
return null;
337349
}
338-
else
339-
{
340-
// For now abort, since we currently don't support resuming circuits persisted to the client.
341-
Context.Abort();
342-
return null;
343-
}
344350

345351
try
346352
{
@@ -389,6 +395,39 @@ static bool RootComponentIsEmpty(string rootComponents) =>
389395
string.IsNullOrEmpty(rootComponents) || rootComponents == "[]";
390396
}
391397

398+
// Client initiated pauses work as follows:
399+
// * The client calls PauseCircuit, we dissasociate the circuit from the connection.
400+
// * We trigger the circuit pause to collect the current root components and dispose the current circuit.
401+
// * We push the current root components and application state to the client.
402+
// * If that succeeds, the client receives the state and we are done.
403+
// * If that fails, we will fall back to the server-side cache storage.
404+
// * The client will disconnect after receiving the state or after a 30s timeout.
405+
// * From that point on, it can choose to resume the circuit by calling ResumeCircuit with or without the state
406+
// depending on whether the transfer was successful.
407+
// * Most of the time we expect the state push to succeed, if that fails, the possibilites are:
408+
// * Client tries to resume before the state has been saved to the server-side cache storage.
409+
// * Resumption fails as the state is not there.
410+
// * The state eventually makes it to the server-side cache storage, but the client will have already given up and
411+
// the state will eventually go away by virtue of the cache expiration policy on it.
412+
// * The state has been saved to the server-side cache storage. This is what we expect to happen most of the time in the
413+
// rare event that the client push fails.
414+
// * This case becomes equivalent to the "ungraceful pause" case, where the client has no state and the server has the state.
415+
public async ValueTask<bool> PauseCircuit()
416+
{
417+
var circuitHost = await GetActiveCircuitAsync();
418+
if (circuitHost == null)
419+
{
420+
return false;
421+
}
422+
423+
_ = _circuitRegistry.PauseCircuitAsync(circuitHost, Context.ConnectionId);
424+
425+
// This only signals that pausing the circuit has started.
426+
// The client will receive the root components and application state in a separate message
427+
// from the server.
428+
return true;
429+
}
430+
392431
public async ValueTask BeginInvokeDotNetFromJS(string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson)
393432
{
394433
var circuitHost = await GetActiveCircuitAsync();

src/Components/Server/test/CircuitDisconnectMiddlewareTest.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ private static CircuitPersistenceManager CreatePersistenceManager()
251251
var circuitPersistenceManager = new CircuitPersistenceManager(
252252
Options.Create(new CircuitOptions()),
253253
new Endpoints.ServerComponentSerializer(new EphemeralDataProtectionProvider()),
254-
Mock.Of<ICircuitPersistenceProvider>());
254+
Mock.Of<ICircuitPersistenceProvider>(),
255+
new EphemeralDataProtectionProvider());
255256
return circuitPersistenceManager;
256257
}
257258
}

0 commit comments

Comments
 (0)