Skip to content
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
5 changes: 3 additions & 2 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal partial class CircuitHost : IAsyncDisposable
private bool _onConnectionDownFired;
private bool _disposed;
private long _startTime;
private PersistedCircuitState _persistedCircuitState;
private PersistedCircuitState? _persistedCircuitState;

// This event is fired when there's an unrecoverable exception coming from the circuit, and
// it need so be torn down. The registry listens to this even so that the circuit can
Expand Down Expand Up @@ -944,7 +944,7 @@ internal PersistedCircuitState TakePersistedCircuitState()
return result;
}

internal async Task<bool> SendPersistedStateToClient(string rootComponents, string applicationState, CancellationToken cancellation)
internal async Task<bool> SendPersistedStateToClient(string rootComponents, string applicationState, DateTimeOffset expiration, CancellationToken cancellation)
{
try
{
Expand All @@ -953,6 +953,7 @@ internal async Task<bool> SendPersistedStateToClient(string rootComponents, stri
CircuitId.Secret,
rootComponents,
applicationState,
expiration.ToUnixTimeMilliseconds(),
cancellationToken: cancellation);
return succeded;
}
Expand Down
98 changes: 68 additions & 30 deletions src/Components/Server/src/Circuits/CircuitPersistenceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,16 @@ await circuit.Renderer.Dispatcher.InvokeAsync(async () =>
{
var renderer = circuit.Renderer;
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
var collector = new CircuitPersistenceManagerCollector(circuitOptions, serverComponentSerializer, circuit.Renderer);

// TODO (OR): Select solution variant
// Variant B: Client-side check
var distributedRetention = circuitOptions.Value.PersistedCircuitDistributedRetentionPeriod;
var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
var maxRetention = (distributedRetention > localRetention ? distributedRetention : localRetention) ?? ServerComponentSerializationSettings.DataExpiration;
var expiration = DateTimeOffset.UtcNow.Add(maxRetention);

var collector = new CircuitPersistenceManagerCollector(serverComponentSerializer, circuit.Renderer, maxRetention);

using var subscription = persistenceManager.State.RegisterOnPersisting(
collector.PersistRootComponents,
RenderMode.InteractiveServer);
Expand All @@ -34,7 +43,7 @@ await circuit.Renderer.Dispatcher.InvokeAsync(async () =>

if (saveStateToClient)
{
await SaveStateToClient(circuit, collector.PersistedCircuitState, cancellation);
await SaveStateToClient(circuit, collector.PersistedCircuitState, expiration, cancellation);
}
else
{
Expand All @@ -46,10 +55,10 @@ await circuitPersistenceProvider.PersistCircuitAsync(
});
}

internal async Task SaveStateToClient(CircuitHost circuit, PersistedCircuitState state, CancellationToken cancellation = default)
internal async Task SaveStateToClient(CircuitHost circuit, PersistedCircuitState state, DateTimeOffset expiration, CancellationToken cancellation = default)
{
var (rootComponents, applicationState) = await ToProtectedStateAsync(state);
if (!await circuit.SendPersistedStateToClient(rootComponents, applicationState, cancellation))
if (!await circuit.SendPersistedStateToClient(rootComponents, applicationState, expiration, cancellation))
{
try
{
Expand Down Expand Up @@ -101,6 +110,27 @@ public async Task<PersistedCircuitState> ResumeCircuitAsync(CircuitId circuitId,
return await circuitPersistenceProvider.RestoreCircuitAsync(circuitId, cancellation);
}

internal static bool CheckRootComponentMarkers(IServerComponentDeserializer serverComponentDeserializer, byte[] rootComponents)
{
var persistedMarkers = TryDeserializeMarkers(rootComponents);

if (persistedMarkers == null)
{
return false;
}

foreach (var marker in persistedMarkers)
{
if (!serverComponentDeserializer.TryDeserializeWebRootComponentDescriptor(marker.Value, out var _))
{
// OR: Expired state
return false;
}
}

return true;
}

// We are going to construct a RootComponentOperationBatch but we are going to replace the descriptors from the client with the
// descriptors that we have persisted when pausing the circuit.
// The way pausing and resuming works is that when the client starts the resume process, it 'simulates' that an SSR has happened and
Expand Down Expand Up @@ -152,55 +182,63 @@ internal static RootComponentOperationBatch ToRootComponentOperationBatch(

if (!serverComponentDeserializer.TryDeserializeWebRootComponentDescriptor(operation.Marker.Value, out var descriptor))
{
// OR: Expired state
return null;
}

operation.Descriptor = descriptor;
}

return batch;
}

static Dictionary<int, ComponentMarker> TryDeserializeMarkers(byte[] rootComponents)
private static Dictionary<int, ComponentMarker> TryDeserializeMarkers(byte[] rootComponents)
{
if (rootComponents == null || rootComponents.Length == 0)
{
if (rootComponents == null || rootComponents.Length == 0)
{
return null;
}
return null;
}

try
{
return JsonSerializer.Deserialize<Dictionary<int, ComponentMarker>>(
rootComponents,
JsonSerializerOptionsProvider.Options);
}
catch
{
return null;
}
try
{
return JsonSerializer.Deserialize<Dictionary<int, ComponentMarker>>(
rootComponents,
JsonSerializerOptionsProvider.Options);
}
catch
{
return null;
}
}

private class CircuitPersistenceManagerCollector(
IOptions<CircuitOptions> circuitOptions,
ServerComponentSerializer serverComponentSerializer,
RemoteRenderer renderer)
: IPersistentComponentStateStore
private class CircuitPersistenceManagerCollector : IPersistentComponentStateStore
{
private readonly ServerComponentSerializer _serverComponentSerializer;
private readonly RemoteRenderer _renderer;
private readonly TimeSpan _maxRetention;

public CircuitPersistenceManagerCollector(
ServerComponentSerializer serverComponentSerializer,
RemoteRenderer renderer,
TimeSpan maxRetention)
{
_serverComponentSerializer = serverComponentSerializer;
_renderer = renderer;
_maxRetention = maxRetention;
}

internal PersistedCircuitState PersistedCircuitState { get; private set; }

public Task PersistRootComponents()
{
var persistedComponents = new Dictionary<int, ComponentMarker>();
var components = renderer.GetOrCreateWebRootComponentManager().GetRootComponents();
var components = _renderer.GetOrCreateWebRootComponentManager().GetRootComponents();
var invocation = new ServerComponentInvocationSequence();

foreach (var (id, componentKey, (componentType, parameters)) in components)
{
var distributedRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
var localRetention = circuitOptions.Value.PersistedCircuitInMemoryRetentionPeriod;
var maxRetention = distributedRetention > localRetention ? distributedRetention : localRetention;

var marker = ComponentMarker.Create(ComponentMarker.ServerMarkerType, prerendered: false, componentKey);
serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters, maxRetention);
_serverComponentSerializer.SerializeInvocation(ref marker, invocation, componentType, parameters, _maxRetention);
persistedComponents.Add(id, marker);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ private bool TryDeserializeServerComponent(ComponentMarker record, out ServerCom
}
catch (Exception e)
{
// OR: Expired state
Log.FailedToUnprotectDescriptor(_logger, e);
result = default;
return false;
Expand Down
19 changes: 19 additions & 0 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ public async Task UpdateRootComponents(string serializedComponentOperations, str
persistedState.RootComponents,
serializedComponentOperations);

if (operations == null)
{
// OR: Expired state
// There was an error, so kill the circuit.
await _circuitRegistry.TerminateAsync(circuitHost.CircuitId);
await NotifyClientError(Clients.Caller, "The persisted circuit state is invalid or expired.");
Context.Abort();

return;
}

store = new ProtectedPrerenderComponentApplicationStore(persistedState.ApplicationState, _dataProtectionProvider);
}
else
Expand Down Expand Up @@ -334,6 +345,14 @@ public async ValueTask<string> ResumeCircuit(
Context.Abort();
return null;
}

// TODO (OR): Select solution variant
// Variant A: Server-side check in ResumeCircuit
if (!CircuitPersistenceManager.CheckRootComponentMarkers(_serverComponentSerializer, persistedCircuitState.RootComponents))
{
Log.InvalidInputData(_logger);
return null;
}
}
else
{
Expand Down
12 changes: 8 additions & 4 deletions src/Components/Server/test/Circuits/CircuitHostTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,11 @@ public async Task SendPersistedStateToClient_WithSuccessfulInvocation_ReturnsTru

var rootComponents = "mock-root-components";
var applicationState = "mock-application-state";
var expiration = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(5));
var cancellationToken = new CancellationToken();

// Act
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, cancellationToken);
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, expiration, cancellationToken);

// Assert
Assert.True(result);
Expand Down Expand Up @@ -463,10 +464,11 @@ public async Task SendPersistedStateToClient_WithFailedInvocation_ReturnsFalse()

var rootComponents = "mock-root-components";
var applicationState = "mock-application-state";
var expiration = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(5));
var cancellationToken = new CancellationToken();

// Act
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, cancellationToken);
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, expiration, cancellationToken);

// Assert
Assert.False(result);
Expand All @@ -490,10 +492,11 @@ public async Task SendPersistedStateToClient_WithException_LogsAndReturnsFalse()

var rootComponents = "mock-root-components";
var applicationState = "mock-application-state";
var expiration = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(5));
var cancellationToken = new CancellationToken();

// Act
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, cancellationToken);
var result = await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, expiration, cancellationToken);

// Assert
Assert.False(result);
Expand All @@ -514,10 +517,11 @@ public async Task SendPersistedStateToClient_WithDisconnectedClient_ReturnsFalse

var rootComponents = "mock-root-components";
var applicationState = "mock-application-state";
var expiration = DateTimeOffset.UtcNow.Add(TimeSpan.FromMinutes(5));
var cancellationToken = new CancellationToken();

// Act & Assert
Assert.False(await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, cancellationToken));
Assert.False(await circuitHost.SendPersistedStateToClient(rootComponents, applicationState, expiration, cancellationToken));
}

[Fact]
Expand Down
29 changes: 24 additions & 5 deletions src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import { showErrorNotification } from '../../BootErrors';
import { attachWebRendererInterop, detachWebRendererInterop } from '../../Rendering/WebRendererInteropMethods';
import { sendJSDataStream } from './CircuitStreamingInterop';

interface PersistedCircuitState {
components: string;
applicationState: string;
expiration: number;
}

export class CircuitManager implements DotNet.DotNetCallDispatcher {

private readonly _componentManager: RootComponentManager<ServerComponentDescriptor>;
Expand Down Expand Up @@ -53,7 +59,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {

private _disconnectingState = new CircuitState<void>('disconnecting');

private _persistedCircuitState?: { components: string, applicationState: string };
private _persistedCircuitState?: PersistedCircuitState;

private _isFirstRender = true;

Expand All @@ -72,6 +78,20 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
this._dispatcher = DotNet.attachDispatcher(this);
}

private tryTakePersistedState(): PersistedCircuitState | undefined {
// TODO (OR): Select solution variant
// Variant B: Client-side check
if (this._persistedCircuitState && this._persistedCircuitState.expiration <= Date.now()) {
this._logger.log(LogLevel.Debug, 'Persisted circuit state has expired and will not be used.');
this._persistedCircuitState = undefined;
return undefined;
} else {
const state = this._persistedCircuitState;
this._persistedCircuitState = undefined;
return state;
}
}

public start(): Promise<boolean> {
if (this.isDisposedOrDisposing()) {
throw new Error('Cannot start a disposed circuit.');
Expand Down Expand Up @@ -139,14 +159,14 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
connection.on('JS.EndInvokeDotNet', this._dispatcher.endInvokeDotNetFromJS.bind(this._dispatcher));
connection.on('JS.ReceiveByteArray', this._dispatcher.receiveByteArray.bind(this._dispatcher));

connection.on('JS.SavePersistedState', (circuitId: string, components: string, applicationState: string) => {
connection.on('JS.SavePersistedState', (circuitId: string, components: string, applicationState: string, expiration: number) => {
if (!this._circuitId) {
throw new Error('Circuit host not initialized.');
}
if (circuitId !== this._circuitId) {
throw new Error(`Received persisted state for circuit ID '${circuitId}', but the current circuit ID is '${this._circuitId}'.`);
}
this._persistedCircuitState = { components, applicationState };
this._persistedCircuitState = { components, applicationState, expiration };
return true;
});

Expand Down Expand Up @@ -378,8 +398,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
}
}

const persistedCircuitState = this._persistedCircuitState;
this._persistedCircuitState = undefined;
const persistedCircuitState = this.tryTakePersistedState();

const newCircuitId = await this._connection!.invoke<string>(
'ResumeCircuit',
Expand Down
Loading