Dispose components on client disconnects (#6693)

* Dispose components on client disconnects
Fixes https://github.com/aspnet/AspNetCore/issues/4047
This commit is contained in:
Pranav K 2019-01-15 14:52:40 -08:00 committed by GitHub
parent 4c956d4767
commit b56c589773
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 247 additions and 31 deletions

View File

@ -25,3 +25,31 @@ indent_size = 2
[*.{xml,csproj,config,*proj,targets,props}]
indent_size = 2
# Dotnet code style settings:
[*.cs]
# Sort using and Import directives with System.* appearing first
dotnet_sort_system_directives_first = true
# Don't use this. qualifier
dotnet_style_qualification_for_field = false:suggestion
dotnet_style_qualification_for_property = false:suggestion
# use int x = .. over Int32
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
# use int.MaxValue over Int32.MaxValue
dotnet_style_predefined_type_for_member_access = true:suggestion
# Require var all the time.
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Newline settings
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true

View File

@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
/// Provides mechanisms for rendering <see cref="IComponent"/> instances in a
/// web browser, dispatching events to them, and refreshing the UI as required.
/// </summary>
public class WebAssemblyRenderer : Renderer, IDisposable
public class WebAssemblyRenderer : Renderer
{
private readonly int _webAssemblyRendererId;
@ -71,11 +71,10 @@ namespace Microsoft.AspNetCore.Blazor.Rendering
RenderRootComponent(componentId);
}
/// <summary>
/// Disposes the instance.
/// </summary>
public void Dispose()
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
RendererRegistry.Current.TryRemove(_webAssemblyRendererId);
}

View File

@ -2,3 +2,5 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
internal class CircuitHost : IDisposable
{
private static AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>();
private static readonly AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>();
/// <summary>
/// Gets the current <see cref="Circuit"/>, if any.
@ -24,23 +24,18 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public static CircuitHost Current => _current.Value;
/// <summary>
/// Sets the current <see cref="Circuit"/>.
/// Sets the current <see cref="Circuits.Circuit"/>.
/// </summary>
/// <param name="circuitHost">The <see cref="Circuit"/>.</param>
/// <param name="circuitHost">The <see cref="Circuits.Circuit"/>.</param>
/// <remarks>
/// Calling <see cref="SetCurrentCircuitHost(CircuitHost)"/> will store the circuit
/// and other related values such as the <see cref="IJSRuntime"/> and <see cref="Renderer"/>
/// in the local execution context. Application code should not need to call this method,
/// it is primarily used by the Server-Side Blazor infrastructure.
/// it is primarily used by the Server-Side Components infrastructure.
/// </remarks>
public static void SetCurrentCircuitHost(CircuitHost circuitHost)
{
if (circuitHost == null)
{
throw new ArgumentNullException(nameof(circuitHost));
}
_current.Value = circuitHost;
_current.Value = circuitHost ?? throw new ArgumentNullException(nameof(circuitHost));
Microsoft.JSInterop.JSRuntime.SetCurrentJSRuntime(circuitHost.JSRuntime);
RendererRegistry.SetCurrentRendererRegistry(circuitHost.RendererRegistry);
@ -134,6 +129,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public void Dispose()
{
Scope.Dispose();
Renderer.Dispose();
}
private void AssertInitialized()

View File

@ -120,11 +120,10 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
}
}
/// <summary>
/// Disposes the instance.
/// </summary>
public void Dispose()
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(true);
_rendererRegistry.TryRemove(_id);
}

View File

@ -12,19 +12,17 @@ namespace Microsoft.AspNetCore.Components.Rendering
/// Provides mechanisms for rendering hierarchies of <see cref="IComponent"/> instances,
/// dispatching events to them, and notifying when the user interface is being updated.
/// </summary>
public abstract class Renderer
public abstract class Renderer : IDisposable
{
private readonly ComponentFactory _componentFactory;
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
private readonly Dictionary<int, ComponentState> _componentStateById
= new Dictionary<int, ComponentState>();
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
private bool _isBatchInProgress;
private int _lastEventHandlerId = 0;
private readonly Dictionary<int, EventHandlerInvoker> _eventBindings = new Dictionary<int, EventHandlerInvoker>();
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
private bool _isBatchInProgress;
private int _lastEventHandlerId = 0;
/// <summary>
/// Constructs an instance of <see cref="Renderer"/>.
/// </summary>
@ -175,7 +173,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
if (frame.AttributeValue is MulticastDelegate @delegate)
{
_eventBindings.Add(id, new EventHandlerInvoker(@delegate));
_eventBindings.Add(id, new EventHandlerInvoker(@delegate));
}
frame = frame.WithAttributeEventHandlerId(id);
@ -295,5 +293,44 @@ namespace Microsoft.AspNetCore.Components.Rendering
RemoveEventHandlerIds(eventHandlerIdsClone, Task.CompletedTask));
}
}
/// <summary>
/// Releases all resources currently used by this <see cref="Renderer"/> instance.
/// </summary>
/// <param name="disposing"><see langword="true"/> if this method is being invoked by <see cref="IDisposable.Dispose"/>, otherwise <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
List<Exception> exceptions = null;
foreach (var componentState in _componentStateById.Values)
{
if (componentState.Component is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch (Exception exception)
{
// Capture exceptions thrown by individual components and rethrow as an aggregate.
exceptions = exceptions ?? new List<Exception>();
exceptions.Add(exception);
}
}
}
if (exceptions != null)
{
throw new AggregateException(exceptions);
}
}
/// <summary>
/// Releases all resources currently used by this <see cref="Renderer"/> instance.
/// </summary>
public void Dispose()
{
Dispose(disposing: true);
}
}
}

View File

@ -0,0 +1,61 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Threading;
using Microsoft.AspNetCore.Components.Browser;
using Microsoft.AspNetCore.Components.Browser.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
public class CircuitHostTest
{
[Fact]
public void Dispose_DisposesResources()
{
// Arrange
var serviceScope = new Mock<IServiceScope>();
var clientProxy = Mock.Of<IClientProxy>();
var renderRegistry = new RendererRegistry();
var jsRuntime = Mock.Of<IJSRuntime>();
var syncContext = new CircuitSynchronizationContext();
var remoteRenderer = new TestRemoteRenderer(
Mock.Of<IServiceProvider>(),
renderRegistry,
jsRuntime,
clientProxy,
syncContext);
var circuitHost = new CircuitHost(serviceScope.Object, clientProxy, renderRegistry, remoteRenderer, configure: _ => { }, jsRuntime: jsRuntime, synchronizationContext: syncContext);
// Act
circuitHost.Dispose();
// Assert
serviceScope.Verify(s => s.Dispose(), Times.Once());
Assert.True(remoteRenderer.Disposed);
}
private class TestRemoteRenderer : RemoteRenderer
{
public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IJSRuntime jsRuntime, IClientProxy client, SynchronizationContext syncContext)
: base(serviceProvider, rendererRegistry, jsRuntime, client, syncContext)
{
}
public bool Disposed { get; set; }
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Disposed = true;
}
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
<PackageReference Include="Moq" Version="4.10.0" />
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
</ItemGroup>

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Test.Helpers;
@ -1139,6 +1138,78 @@ namespace Microsoft.AspNetCore.Components.Test
Assert.Equal(2, numEventsFired);
}
[Fact]
public void DisposingRenderer_DisposesTopLevelComponents()
{
// Arrange
var renderer = new TestRenderer();
var component = new DisposableComponent();
renderer.AssignRootComponentId(component);
// Act
renderer.Dispose();
// Assert
Assert.True(component.Disposed);
}
[Fact]
public void DisposingRenderer_DisposesNestedComponents()
{
// Arrange
var renderer = new TestRenderer();
var component = new TestComponent(builder =>
{
builder.AddContent(0, "Hello");
builder.OpenComponent<DisposableComponent>(1);
builder.CloseComponent();
});
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
var batch = renderer.Batches.Single();
var componentFrame = batch.ReferenceFrames
.Single(frame => frame.FrameType == RenderTreeFrameType.Component);
var nestedComponent = Assert.IsType<DisposableComponent>(componentFrame.Component);
// Act
renderer.Dispose();
// Assert
Assert.True(component.Disposed);
Assert.True(nestedComponent.Disposed);
}
[Fact]
public void DisposingRenderer_CapturesExceptionsFromAllRegisteredComponents()
{
// Arrange
var renderer = new TestRenderer();
var exception1 = new Exception();
var exception2 = new Exception();
var component = new TestComponent(builder =>
{
builder.AddContent(0, "Hello");
builder.OpenComponent<DisposableComponent>(1);
builder.AddAttribute(1, nameof(DisposableComponent.DisposeAction), (Action)(() => throw exception1));
builder.CloseComponent();
builder.OpenComponent<DisposableComponent>(2);
builder.AddAttribute(1, nameof(DisposableComponent.DisposeAction), (Action)(() => throw exception2));
builder.CloseComponent();
});
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
// Act &A Assert
var aggregate = Assert.Throws<AggregateException>(renderer.Dispose);
// All components must be disposed even if some throw as part of being diposed.
Assert.True(component.Disposed);
Assert.Equal(2, aggregate.InnerExceptions.Count);
Assert.Contains(exception1, aggregate.InnerExceptions);
Assert.Contains(exception2, aggregate.InnerExceptions);
}
private class NoOpRenderer : Renderer
{
public NoOpRenderer() : base(new TestServiceProvider())
@ -1152,7 +1223,7 @@ namespace Microsoft.AspNetCore.Components.Test
=> Task.CompletedTask;
}
private class TestComponent : IComponent
private class TestComponent : IComponent, IDisposable
{
private RenderHandle _renderHandle;
private RenderFragment _renderFragment;
@ -1172,6 +1243,10 @@ namespace Microsoft.AspNetCore.Components.Test
public void TriggerRender()
=> _renderHandle.Render(_renderFragment);
public bool Disposed { get; private set; }
void IDisposable.Dispose() => Disposed = true;
}
private class MessageComponent : AutoRenderComponent
@ -1398,6 +1473,24 @@ namespace Microsoft.AspNetCore.Components.Test
}
}
private class DisposableComponent : AutoRenderComponent, IDisposable
{
public bool Disposed { get; private set; }
[Parameter]
public Action DisposeAction { get; private set; }
public void Dispose()
{
Disposed = true;
DisposeAction?.Invoke();
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
}
}
class TestAsyncRenderer : TestRenderer
{
public Task NextUpdateDisplayReturnTask { get; set; }