Skip to content

Commit c841b73

Browse files
committed
It works
1 parent cdf354e commit c841b73

20 files changed

+289
-154
lines changed

src/Components/Samples/BlazorUnitedApp/App.razor

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,6 @@
1515
</head>
1616
<body>
1717
<Routes @rendermode="InteractiveServer" />
18-
<script src="@Assets["_framework/blazor.web.js"]" autostart="false"></script>
19-
<script>
20-
Blazor.start();
21-
// Blazor.start({
22-
// circuit: {
23-
// reconnectionHandler: {
24-
// onConnectionDown: () => (),
25-
// onConnectionUp: () => {
26-
// // revertState();
27-
// }
28-
// },
29-
// detailedErrors: true
30-
// }
31-
// });
32-
33-
function disconnect() {
34-
console.log("Disconnecting")
35-
Blazor._internal.forceCloseConnection();
36-
document.getElementById("reconnect-button").style.visibility = "visible";
37-
document.getElementById("close-connection").style.visibility = "hidden";
38-
}
39-
function revertState() {
40-
console.log("Restoring connection")
41-
document.getElementById("reconnect-button").style.visibility = "hidden";
42-
document.getElementById("close-connection").style.visibility = "visible";
43-
}
44-
</script>
18+
<script src="@Assets["_framework/blazor.web.js"]"></script>
4519
</body>
4620
</html>

src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"dotnetRunMessages": true,
1414
"launchBrowser": true,
1515
"applicationUrl": "http://localhost:5265",
16-
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
16+
//"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
1717
"environmentVariables": {
1818
"ASPNETCORE_ENVIRONMENT": "Development"
1919
}

src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,6 @@
2929
<span class="bi bi-list-nested" aria-hidden="true"></span> Web assembly
3030
</NavLink>
3131
</div>
32-
<div class="nav-item px-3 mt-3">
33-
<button id="reconnect-button" style="visibility:hidden" onclick="Blazor.reconnect();" class="btn btn-sm btn-outline-success">
34-
<span class="bi bi-arrow-repeat" aria-hidden="true"></span> Reconnect manually
35-
</button>
36-
</div>
37-
<div class="nav-item px-3 mt-2">
38-
<button id="close-connection" onclick="disconnect();" class="btn btn-sm btn-primary">
39-
<span class="bi bi-x-circle" aria-hidden="true"></span> Close Connection
40-
</button>
41-
</div>
4232
</nav>
4333
</div>
4434

src/Components/Server/src/CircuitOptions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,15 @@ public sealed class CircuitOptions
5252
/// When using a distributed cache like <see cref="Extensions.Caching.Hybrid.HybridCache"/> this value is ignored
5353
/// and the configuration from <see cref="Extensions.DependencyInjection.MemoryCacheServiceCollectionExtensions.AddMemoryCache(Extensions.DependencyInjection.IServiceCollection)"/>
5454
/// is used instead.
55+
/// </remarks>
5556
public int PersistedCircuitMaxRetained { get; set; } = 100;
5657

5758
/// <summary>
5859
/// Gets or sets the duration for which a persisted circuit is retained in memory.
5960
/// </summary>
6061
/// <remarks>This value is used for the default in-memory cache implementation as well as for the
6162
/// duration for which the persisted circuits are retained in the local in memory cache when using a
62-
/// <see cref="Extensions.Caching.Hybrid.HybridCache"/>.</remarks>"/>
63+
/// <see cref="Extensions.Caching.Hybrid.HybridCache"/>.</remarks>
6364
public TimeSpan PersistedCircuitInMemoryRetentionPeriod { get; set; } = TimeSpan.FromHours(1);
6465

6566
/// <summary>

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,14 @@ 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)
49+
{
50+
if (Client == null)
51+
{
52+
throw new InvalidOperationException($"{nameof(SendCoreAsync)} cannot be invoked with an offline client.");
53+
}
54+
55+
return Client.InvokeCoreAsync<T>(method, args, cancellationToken);
56+
}
4757
}

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -891,9 +891,23 @@ internal PersistedCircuitState TakePersistedCircuitState()
891891
return result;
892892
}
893893

894-
internal async Task<bool> SendPersistedStateToClient(string rootComponents, string applicationState)
894+
internal async Task<bool> SendPersistedStateToClient(string rootComponents, string applicationState, CancellationToken cancellation)
895895
{
896-
return Client.SendAsync("JS.SavePersistedState", rootComponents, applicationState);
896+
try
897+
{
898+
var succeded = await Client.InvokeAsync<bool>(
899+
"JS.SavePersistedState",
900+
CircuitId.Secret,
901+
rootComponents,
902+
applicationState,
903+
cancellationToken: cancellation);
904+
return succeded;
905+
}
906+
catch (Exception ex)
907+
{
908+
Log.FailedToSaveStateToClient(_logger, CircuitId, ex);
909+
return false;
910+
}
897911
}
898912

899913
private static partial class Log
@@ -1051,5 +1065,8 @@ public static void BeginInvokeDotNetFailed(ILogger logger, string callId, string
10511065

10521066
[LoggerMessage(219, LogLevel.Error, "Location change to '{URI}' in circuit '{CircuitId}' failed.", EventName = "LocationChangeFailedInCircuit")]
10531067
public static partial void LocationChangeFailedInCircuit(ILogger logger, string uri, CircuitId circuitId, Exception exception);
1068+
1069+
[LoggerMessage(220, LogLevel.Debug, "Failed to save state to client in circuit '{CircuitId}'.", EventName = "FailedToSaveStateToClient")]
1070+
public static partial void FailedToSaveStateToClient(ILogger logger, CircuitId circuitId, Exception exception);
10541071
}
10551072
}

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

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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;
@@ -31,46 +32,34 @@ public async Task PauseCircuitAsync(CircuitHost circuit, bool saveStateToClient,
3132

3233
if (saveStateToClient)
3334
{
34-
_ = SaveStateToClient(circuit, store.PersistedCircuitState, cancellation);
35+
await SaveStateToClient(circuit, store.PersistedCircuitState, cancellation);
36+
}
37+
else
38+
{
39+
await circuitPersistenceProvider.PersistCircuitAsync(
40+
circuit.CircuitId,
41+
store.PersistedCircuitState,
42+
cancellation);
3543
}
36-
37-
await circuitPersistenceProvider.PersistCircuitAsync(
38-
circuit.CircuitId,
39-
store.PersistedCircuitState,
40-
cancellation);
4144
}
4245

4346
private async Task SaveStateToClient(CircuitHost circuit, PersistedCircuitState state, CancellationToken cancellation = default)
4447
{
45-
var (rootComponents, applicationState) = ToProtectedState(state);
46-
try
48+
var (rootComponents, applicationState) = await ToProtectedStateAsync(state);
49+
if (!await circuit.SendPersistedStateToClient(rootComponents, applicationState, cancellation))
4750
{
48-
// Try to push the state to the client first, if that fails, we will persist it in the server-side store.
49-
bool succeded = await circuit.SendPersistedStateToClient(rootComponents, applicationState);
50-
if (!succeded)
51+
try
5152
{
52-
try
53-
{
54-
await circuitPersistenceProvider.PersistCircuitAsync(
55-
circuit.CircuitId,
56-
state,
57-
cancellation);
58-
}
59-
catch (Exception)
60-
{
61-
// At this point, we give up as we haven't been able to save the state to the client nor the server.
62-
return;
63-
}
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;
6462
}
65-
}
66-
catch (Exception)
67-
{
68-
// The call to save the state to the client failed, so fallback to saving it on the server.
69-
// The client can still try to resume later without the state.
70-
await circuitPersistenceProvider.PersistCircuitAsync(
71-
circuit.CircuitId,
72-
state,
73-
cancellation);
7463
}
7564
}
7665

@@ -100,7 +89,18 @@ public async Task<PersistedCircuitState> ResumeCircuitAsync(CircuitId circuitId,
10089
return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation);
10190
}
10291

103-
internal PersistedCircuitState FromProtectedState(string rootComponents, string applicationState) => throw new NotImplementedException();
92+
internal PersistedCircuitState FromProtectedState(string rootComponents, string applicationState)
93+
{
94+
var rootComponentsBytes = Encoding.UTF8.GetBytes(rootComponents);
95+
var prerenderedState = new ProtectedPrerenderComponentApplicationStore(applicationState, dataProtectionProvider);
96+
var state = new PersistedCircuitState
97+
{
98+
RootComponents = rootComponentsBytes,
99+
ApplicationState = prerenderedState.ExistingState
100+
};
101+
102+
return state;
103+
}
104104

105105
// We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the
106106
// descriptors that we have persisted when pausing the circuit.
@@ -154,7 +154,17 @@ internal static RootComponentOperationBatch ToRootComponentOperationBatch(
154154
return result;
155155
}
156156

157-
internal (string rootComponents, string applicationState) ToProtectedState(PersistedCircuitState state) => throw new NotImplementedException();
157+
internal async Task<(string rootComponents, string applicationState)> ToProtectedStateAsync(PersistedCircuitState state)
158+
{
159+
// Root components descriptors are already protected and serialized as JSON, we just convert the bytes to a string.
160+
var rootComponents = Encoding.UTF8.GetString(state.RootComponents);
161+
162+
// The application state we protect in the same way we do for prerendering.
163+
var store = new ProtectedPrerenderComponentApplicationStore(dataProtectionProvider);
164+
await store.PersistStateAsync(state.ApplicationState);
165+
166+
return (rootComponents, store.PersistedState);
167+
}
158168

159169
internal ProtectedPrerenderComponentApplicationStore ToComponentApplicationStore(Dictionary<string, byte[]> applicationState)
160170
{
@@ -198,7 +208,7 @@ Task IPersistentComponentStateStore.PersistStateAsync(IReadOnlyDictionary<string
198208
}
199209
}
200210

201-
[JsonSerializable(typeof(Dictionary<int, ComponentMarker>))]
211+
[JsonSerializable(typeof(IDictionary<string, byte[]>))]
202212
internal partial class CircuitPersistenceManagerSerializerContext : JsonSerializerContext
203213
{
204214
}

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

Lines changed: 19 additions & 24 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
{
@@ -363,37 +363,31 @@ internal Task PauseCircuitAsync(
363363
CircuitHost circuitHost,
364364
string connectionId)
365365
{
366-
Log.CircuitPauseStarted(_logger, circuitHost.CircuitId, connectionId);
367-
368-
Task circuitHandlerTask;
369-
lock (CircuitRegistryLock)
366+
try
370367
{
371-
if (PauseCore(circuitHost, connectionId))
372-
{
373-
circuitHandlerTask = circuitHost.Renderer.Dispatcher.InvokeAsync(() => circuitHost.OnConnectionDownAsync(default));
374-
}
375-
else
376-
{
377-
// DisconnectCore may fail to disconnect the circuit if it was previously marked inactive or
378-
// has been transferred to a new connection. Do not invoke the circuit handlers in this instance.
368+
Log.CircuitPauseStarted(_logger, circuitHost.CircuitId, connectionId);
379369

380-
// We have to do in this instance.
381-
return Task.CompletedTask;
370+
lock (CircuitRegistryLock)
371+
{
372+
return PauseCore(circuitHost, connectionId);
382373
}
383374
}
384-
385-
return circuitHandlerTask;
375+
catch (Exception)
376+
{
377+
Log.CircuitPauseFailed(_logger, circuitHost.CircuitId, connectionId);
378+
return Task.CompletedTask;
379+
}
386380
}
387381

388-
internal bool PauseCore(CircuitHost circuitHost, string connectionId)
382+
internal Task PauseCore(CircuitHost circuitHost, string connectionId)
389383
{
390384
var circuitId = circuitHost.CircuitId;
391385
if (!ConnectedCircuits.TryGetValue(circuitId, out circuitHost))
392386
{
393387
Log.CircuitNotActive(_logger, circuitId);
394388

395389
// Circuit should be in the connected state for pausing.
396-
return false;
390+
return Task.CompletedTask;
397391
}
398392

399393
if (!string.Equals(circuitHost.Client.ConnectionId, connectionId, StringComparison.Ordinal))
@@ -404,15 +398,13 @@ internal bool PauseCore(CircuitHost circuitHost, string connectionId)
404398
// The circuit is associated with a different connection. One way this could happen is when
405399
// the client reconnects with a new connection before the OnDisconnect for the older
406400
// connection is executed. Do nothing
407-
return false;
401+
return Task.CompletedTask;
408402
}
409403

410404
var removeResult = ConnectedCircuits.TryRemove(circuitId, out _);
411405
Debug.Assert(removeResult, "This operation operates inside of a lock. We expect the previously inspected value to be still here.");
412406

413-
_ = PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
414-
415-
return removeResult;
407+
return PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
416408
}
417409

418410
private readonly struct DisconnectedCircuitEntry
@@ -482,5 +474,8 @@ public static void ExceptionDisposingTokenSource(ILogger logger, Exception excep
482474

483475
[LoggerMessage(116, LogLevel.Debug, "Pausing circuit with id {CircuitId} from connection {ConnectionId}.", EventName = "CircuitPauseStarted")]
484476
public static partial void CircuitPauseStarted(ILogger logger, CircuitId circuitId, string connectionId);
477+
478+
[LoggerMessage(117, LogLevel.Debug, "Failed to pause circuit with id {CircuitId} from connection {ConnectionId}.", EventName = "CircuitPauseFailed")]
479+
public static partial void CircuitPauseFailed(ILogger logger, CircuitId circuitId, string connectionId);
485480
}
486481
}

src/Components/Server/src/ComponentHub.cs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -238,25 +238,38 @@ public async ValueTask<bool> ConnectCircuit(string circuitIdSecret)
238238
return false;
239239
}
240240

241-
public async ValueTask<string> PauseCircuit()
241+
// Client initiated pauses work as follows:
242+
// * The client calls PauseCircuit, we dissasociate the circuit from the connection.
243+
// * We trigger the circuit pause to collect the current root components and dispose the current circuit.
244+
// * We push the current root components and application state to the client.
245+
// * If that succeeds, the client receives the state and we are done.
246+
// * If that fails, we will fall back to the server-side cache storage.
247+
// * The client will disconnect after receiving the state or after a 30s timeout.
248+
// * From that point on, it can choose to resume the circuit by calling ResumeCircuit with or without the state
249+
// depending on whether the transfer was successful.
250+
// * Most of the time we expect the state push to succeed, if that fails, the possibilites are:
251+
// * Client tries to resume before the state has been saved to the server-side cache storage.
252+
// * Resumption fails as the state is not there.
253+
// * The state eventually makes it to the server-side cache storage, but the client will have already given up and
254+
// the state will eventually go away by virtue of the cache expiration policy on it.
255+
// * The state has been saved to the server-side cache storage. This is what we expect to happen most of the time in the
256+
// rare event that the client push fails.
257+
// * This case becomes equivalent to the "ungraceful pause" case, where the client has no state and the server has the state.
258+
public async ValueTask<bool> PauseCircuit()
242259
{
243260
var circuitHost = await GetActiveCircuitAsync();
244261
if (circuitHost == null)
245262
{
246-
return null;
263+
return false;
247264
}
248265

249-
// What needs to happen
250-
// Disconnect the circuit
251-
// Evict the circuit from the registry
252-
// Capture the state during eviction
253-
// Return the state to the client
254-
// Close the connection (or have the client do it)
255-
256-
// Dissociate the circuit from the connection. From this point any new calls to the hub will fail to find the circuit.
257-
_circuitHandleRegistry.SetCircuit(Context.Items, CircuitKey, circuitHost: null);
266+
// This is guaranteed to not throw.
258267
_ = _circuitRegistry.PauseCircuitAsync(circuitHost, Context.ConnectionId);
259-
return null;
268+
269+
// This only signals that pausing the circuit has started.
270+
// The client will receive the root components and application state in a separate message
271+
// from the server.
272+
return true;
260273
}
261274

262275
// This method drives the resumption of a circuit that has been previously paused and ejected out of memory.

0 commit comments

Comments
 (0)