Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
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 @@ -687,6 +687,8 @@ private void AssertInitialized()
}
}

internal bool IsDisposed() => _disposed;

private void AssertNotDisposed()
{
#pragma warning disable CA1513 // Use ObjectDisposedException throw helper
Expand Down
44 changes: 26 additions & 18 deletions src/Components/Server/src/Circuits/CircuitPersistenceManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,34 @@ internal partial class CircuitPersistenceManager(
{
public async Task PauseCircuitAsync(CircuitHost circuit, bool saveStateToClient = false, CancellationToken cancellation = default)
{
var renderer = circuit.Renderer;
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
var collector = new CircuitPersistenceManagerCollector(circuitOptions, serverComponentSerializer, circuit.Renderer);
using var subscription = persistenceManager.State.RegisterOnPersisting(
collector.PersistRootComponents,
RenderMode.InteractiveServer);
await circuit.Renderer.Dispatcher.InvokeAsync(async () =>
{
if (circuit.IsDisposed())
{
return;
}

await persistenceManager.PersistStateAsync(collector, renderer);
var renderer = circuit.Renderer;
var persistenceManager = circuit.Services.GetRequiredService<ComponentStatePersistenceManager>();
var collector = new CircuitPersistenceManagerCollector(circuitOptions, serverComponentSerializer, circuit.Renderer);
using var subscription = persistenceManager.State.RegisterOnPersisting(
collector.PersistRootComponents,
RenderMode.InteractiveServer);

if (saveStateToClient)
{
await SaveStateToClient(circuit, collector.PersistedCircuitState, cancellation);
}
else
{
await circuitPersistenceProvider.PersistCircuitAsync(
circuit.CircuitId,
collector.PersistedCircuitState,
cancellation);
}
await persistenceManager.PersistStateAsync(collector, renderer);

if (saveStateToClient)
{
await SaveStateToClient(circuit, collector.PersistedCircuitState, cancellation);
}
else
{
await circuitPersistenceProvider.PersistCircuitAsync(
circuit.CircuitId,
collector.PersistedCircuitState,
cancellation);
}
});
}

internal async Task SaveStateToClient(CircuitHost circuit, PersistedCircuitState state, CancellationToken cancellation = default)
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Server/src/Circuits/CircuitRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ private Task PauseAndDisposeCircuitEntry(DisconnectedCircuitEntry entry)
return Task.CompletedTask;
}

private async Task PauseAndDisposeCircuitHost(CircuitHost circuitHost, bool saveStateToClient)
internal async Task PauseAndDisposeCircuitHost(CircuitHost circuitHost, bool saveStateToClient)
{
await _circuitPersistenceManager.PauseCircuitAsync(circuitHost, saveStateToClient);
circuitHost.UnhandledException -= CircuitHost_UnhandledException;
Expand Down
155 changes: 155 additions & 0 deletions src/Components/Server/test/Circuits/CircuitRegistryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,17 @@ protected override void OnEntryEvicted(object key, object value, EvictionReason
base.OnEntryEvicted(key, value, reason, state);
OnAfterEntryEvicted?.Invoke();
}

public void TriggerEviction(object key, object value, EvictionReason reason)
{
OnEntryEvicted(key, value, reason, null);
}

public async Task SimulateEvictionAndDispose(CircuitHost circuitHost)
{
// Directly call PauseAndDisposeCircuitHost which is what eviction does
await PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: false);
}
}

private class TestCircuitPersistenceProvider : ICircuitPersistenceProvider
Expand Down Expand Up @@ -678,4 +689,148 @@ private static (CircuitRegistry Registry, TestCircuitPersistenceProvider Provide
persistenceManager);
return (registry, provider);
}

[Fact]
public async Task PauseAfterTermination_DoesNotThrow()
{
var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory();
var options = new CircuitOptions();

var circuitHost = new TestCircuitHostForRaceConditions(
circuitIdFactory.CreateCircuitId(),
CreateServiceScope(),
options);

var persistenceProvider = new TestCircuitPersistenceProvider();
var registry = new TestCircuitRegistry(circuitIdFactory, options, persistenceProvider);
registry.Register(circuitHost);

// First terminate the circuit - it calls circuitHost.DisposeAsync()
await registry.TerminateAsync(circuitHost.CircuitId);

// Then try to pause - it will try to resolve services from the DI scope that is already disposed
await registry.PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
}

[Fact]
public async Task MultiplePause_DoesNotThrow()
{
var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory();
var options = new CircuitOptions();

var circuitHost = new TestCircuitHostForRaceConditions(
circuitIdFactory.CreateCircuitId(),
CreateServiceScope(),
options);

var persistenceProvider = new TestCircuitPersistenceProvider();
var registry = new TestCircuitRegistry(circuitIdFactory, options, persistenceProvider);
registry.Register(circuitHost);

// First pause should be successful
await registry.PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);

// Second pause - it will try to resolve services from the DI scope that is already disposed
await registry.PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
}

[Fact]
public async Task PauseAfterEviction_DoesNotThrow()
{
var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory();
var options = new CircuitOptions();

var circuitHost = new TestCircuitHostForRaceConditions(
circuitIdFactory.CreateCircuitId(),
CreateServiceScope(),
options);

var persistenceProvider = new TestCircuitPersistenceProvider();
var registry = new TestCircuitRegistry(circuitIdFactory, options, persistenceProvider);
registry.Register(circuitHost);

// First simulate eviction by calling the same method that eviction calls
await registry.SimulateEvictionAndDispose(circuitHost);

// Then try to pause - it will try to resolve services from the DI scope that is already disposed
await registry.PauseAndDisposeCircuitHost(circuitHost, saveStateToClient: true);
}

[Fact]
public async Task EvictionAndTermination_DoesNotThrow()
{
var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory();
var options = new CircuitOptions();

var circuitHost = new TestCircuitHostForRaceConditions(
circuitIdFactory.CreateCircuitId(),
CreateServiceScope(),
options);

var persistenceProvider = new TestCircuitPersistenceProvider();
var registry = new TestCircuitRegistry(circuitIdFactory, options, persistenceProvider);
registry.Register(circuitHost);

// We never observed any issues with eviction and termination
await registry.SimulateEvictionAndDispose(circuitHost);
await registry.TerminateAsync(circuitHost.CircuitId);
}

private static AsyncServiceScope CreateServiceScope()
{
var serviceCollection = new ServiceCollection();
serviceCollection.AddSingleton(sp => new ComponentStatePersistenceManager(
NullLoggerFactory.Instance.CreateLogger<ComponentStatePersistenceManager>(), sp));
serviceCollection.AddSingleton(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
var serviceProvider = serviceCollection.BuildServiceProvider();
return serviceProvider.CreateAsyncScope();
}

private class TestCircuitHostForRaceConditions : CircuitHost
{
public TestCircuitHostForRaceConditions(
CircuitId circuitId,
AsyncServiceScope scope,
CircuitOptions options)
: base(
circuitId,
scope,
options,
new CircuitClientProxy(Mock.Of<ISingleClientProxy>(), Guid.NewGuid().ToString()),
CreateRemoteRenderer(),
Array.Empty<ComponentDescriptor>(),
new RemoteJSRuntime(Options.Create(new CircuitOptions()), Options.Create(new HubOptions<ComponentHub>()), Mock.Of<ILogger<RemoteJSRuntime>>()),
new RemoteNavigationManager(Mock.Of<ILogger<RemoteNavigationManager>>()),
Array.Empty<CircuitHandler>(),
new CircuitMetrics(new TestMeterFactory()),
new CircuitActivitySource(),
NullLogger<CircuitHost>.Instance)
{
}

private static RemoteRenderer CreateRemoteRenderer()
{
var clientProxy = new CircuitClientProxy(Mock.Of<ISingleClientProxy>(), Guid.NewGuid().ToString());
var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Options.Create(new HubOptions<ComponentHub>()), Mock.Of<ILogger<RemoteJSRuntime>>());
var componentsActivitySource = new ComponentsActivitySource();
var serviceProvider = new Mock<IServiceProvider>();
serviceProvider
.Setup(services => services.GetService(typeof(IJSRuntime)))
.Returns(jsRuntime);
serviceProvider
.Setup(services => services.GetService(typeof(ComponentsActivitySource)))
.Returns(componentsActivitySource);
var serverComponentDeserializer = Mock.Of<IServerComponentDeserializer>();

return new RemoteRenderer(
serviceProvider.Object,
NullLoggerFactory.Instance,
new CircuitOptions(),
clientProxy,
serverComponentDeserializer,
NullLogger.Instance,
jsRuntime,
new CircuitJSComponentInterop(new CircuitOptions()));
}
}
}
Loading