[Blazor][Fixes #11964] Limit the amount of pending renders (#12763)

[Blazor][Fixes #11964] Limit the amount of pending renders
* Adds a default limit of 10 queued pending renders per application.
* Stops producing new render batches after that limit is hit.
* Resumes producing render batches as soon as the client acknowledges a batch.
This commit is contained in:
Javier Calvarro Nelson 2019-08-06 16:22:07 +02:00 committed by GitHub
parent 43350b57b9
commit 31cfa2e305
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 384 additions and 47 deletions

View File

@ -535,13 +535,13 @@ namespace Microsoft.AspNetCore.Components.Rendering
public Renderer(System.IServiceProvider serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { }
public abstract Microsoft.AspNetCore.Components.Dispatcher Dispatcher { get; }
public event System.UnhandledExceptionEventHandler UnhandledSynchronizationException { add { } remove { } }
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { }
protected internal int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
public virtual System.Threading.Tasks.Task DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.Rendering.EventFieldInfo fieldInfo, System.EventArgs eventArgs) { throw null; }
public void Dispose() { }
protected virtual void Dispose(bool disposing) { }
protected abstract void HandleException(System.Exception exception);
protected Microsoft.AspNetCore.Components.IComponent InstantiateComponent(System.Type componentType) { throw null; }
protected virtual void ProcessPendingRender() { }
protected System.Threading.Tasks.Task RenderRootComponentAsync(int componentId) { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
protected System.Threading.Tasks.Task RenderRootComponentAsync(int componentId, Microsoft.AspNetCore.Components.ParameterView initialParameters) { throw null; }

View File

@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
}
}
internal static void DisposingComponent(ILogger<Renderer> logger, ComponentState componentState)
public static void DisposingComponent(ILogger<Renderer> logger, ComponentState componentState)
{
if (logger.IsEnabled(LogLevel.Debug)) // This is almost always false, so skip the evaluations
{
@ -56,7 +56,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
}
}
internal static void HandlingEvent(ILogger<Renderer> logger, ulong eventHandlerId, EventArgs eventArgs)
public static void HandlingEvent(ILogger<Renderer> logger, ulong eventHandlerId, EventArgs eventArgs)
{
_handlingEvent(logger, eventHandlerId, eventArgs?.GetType().Name ?? "null", null);
}

View File

@ -243,7 +243,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
// Since the task has yielded - process any queued rendering work before we return control
// to the caller.
ProcessRenderQueue();
ProcessPendingRender();
}
// Task completed synchronously or is still running. We already processed all of the rendering
@ -334,7 +334,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
/// </summary>
/// <param name="componentId">The ID of the component to render.</param>
/// <param name="renderFragment">A <see cref="RenderFragment"/> that will supply the updated UI contents.</param>
protected internal virtual void AddToRenderQueue(int componentId, RenderFragment renderFragment)
internal void AddToRenderQueue(int componentId, RenderFragment renderFragment)
{
EnsureSynchronizationContext();
@ -351,7 +351,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
if (!_isBatchInProgress)
{
ProcessRenderQueue();
ProcessPendingRender();
}
}
@ -398,13 +398,27 @@ namespace Microsoft.AspNetCore.Components.Rendering
? componentState
: null;
/// <summary>
/// Processses pending renders requests from components if there are any.
/// </summary>
protected virtual void ProcessPendingRender()
{
ProcessRenderQueue();
}
private void ProcessRenderQueue()
{
EnsureSynchronizationContext();
_isBatchInProgress = true;
var updateDisplayTask = Task.CompletedTask;
try
{
if (_batchBuilder.ComponentRenderQueue.Count == 0)
{
return;
}
// Process render queue until empty
while (_batchBuilder.ComponentRenderQueue.Count > 0)
{
@ -423,6 +437,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
{
// Ensure we catch errors while running the render functions of the components.
HandleException(e);
return;
}
finally
{

View File

@ -33,6 +33,7 @@ namespace Microsoft.AspNetCore.Components.Server
public int DisconnectedCircuitMaxRetained { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public System.TimeSpan DisconnectedCircuitRetentionPeriod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public System.TimeSpan JSInteropDefaultCallTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public int MaxBufferedUnacknowledgedRenderBatches { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
}
}
namespace Microsoft.AspNetCore.Components.Server.Circuits

View File

@ -64,5 +64,17 @@ namespace Microsoft.AspNetCore.Components.Server
/// Defaults to <c>1 minute</c>.
/// </value>
public TimeSpan JSInteropDefaultCallTimeout { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Gets or sets the maximum number of render batches that a circuit will buffer until an acknowledgement for the batch is
/// received.
/// </summary>
/// <remarks>
/// When the limit of buffered render batches is reached components will stop rendering and will wait until either the
/// circuit is disconnected and disposed or at least one batch gets acknowledged.
/// </remarks>
/// <value>
/// Defaults to <c>10</c>.</value>
public int MaxBufferedUnacknowledgedRenderBatches { get; set; } = 10;
}
}

View File

@ -230,7 +230,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
catch (Exception ex)
{
// We don't expect any of this code to actually throw, because DotNetDispatcher.BeginInvoke doesn't throw
// however, we still want this to get logged if we do.
// however, we still want this to get logged if we do.
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
}
}

View File

@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.Rendering;
using Microsoft.AspNetCore.Components.Routing;
@ -13,8 +14,8 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
@ -24,16 +25,19 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly CircuitIdFactory _circuitIdFactory;
private readonly CircuitOptions _options;
public DefaultCircuitFactory(
IServiceScopeFactory scopeFactory,
ILoggerFactory loggerFactory,
CircuitIdFactory circuitIdFactory)
CircuitIdFactory circuitIdFactory,
IOptions<CircuitOptions> options)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_loggerFactory = loggerFactory;
_logger = _loggerFactory.CreateLogger<CircuitFactory>();
_circuitIdFactory = circuitIdFactory ?? throw new ArgumentNullException(nameof(circuitIdFactory));
_options = options.Value;
}
public override CircuitHost CreateCircuitHost(
@ -80,6 +84,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
scope.ServiceProvider,
_loggerFactory,
rendererRegistry,
_options,
jsRuntime,
client,
encoder,

View File

@ -3,12 +3,12 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Internal;
@ -23,8 +23,10 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
private readonly IJSRuntime _jsRuntime;
private readonly CircuitClientProxy _client;
private readonly CircuitOptions _options;
private readonly RendererRegistry _rendererRegistry;
private readonly ILogger _logger;
internal readonly ConcurrentQueue<UnacknowledgedRenderBatch> _unacknowledgedRenderBatches = new ConcurrentQueue<UnacknowledgedRenderBatch>();
private long _nextRenderId = 1;
private bool _disposing = false;
@ -40,6 +42,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory,
RendererRegistry rendererRegistry,
CircuitOptions options,
IJSRuntime jsRuntime,
CircuitClientProxy client,
HtmlEncoder encoder,
@ -49,13 +52,12 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
_rendererRegistry = rendererRegistry;
_jsRuntime = jsRuntime;
_client = client;
_options = options;
Id = _rendererRegistry.Add(this);
_logger = logger;
}
internal ConcurrentQueue<UnacknowledgedRenderBatch> UnacknowledgedRenderBatches = new ConcurrentQueue<UnacknowledgedRenderBatch>();
public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
public int Id { get; }
@ -81,6 +83,34 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
return RenderRootComponentAsync(componentId);
}
protected override void ProcessPendingRender()
{
if (_unacknowledgedRenderBatches.Count >= _options.MaxBufferedUnacknowledgedRenderBatches)
{
// If we got here it means we are at max capacity, so we don't want to actually process the queue,
// as we have a client that is not acknowledging render batches fast enough (something we consider needs
// to be fast).
// The result is something as follows:
// Lets imagine an extreme case where the server produces a new batch every milisecond.
// Lets say the client is able to ACK a batch every 100 miliseconds.
// When the app starts the client might see the sequence 0->(MaxUnacknowledgedRenderBatches-1) and then
// after 100 miliseconds it sees it jump to 1xx, then to 2xx where xx is something between {0..99} the
// reason for this is that the server slows down rendering new batches to as fast as the client can consume
// them.
// Similarly, if a client were to send events at a faster pace than the server can consume them, the server
// would still proces the events, but would not produce new renders until it gets an ack that frees up space
// for a new render.
// We should never see UnacknowledgedRenderBatches.Count > _options.MaxBufferedUnacknowledgedRenderBatches
// But if we do, it's safer to simply disable the rendering in that case too instead of allowing batches to
Log.FullUnacknowledgedRenderBatchesQueue(_logger);
return;
}
base.ProcessPendingRender();
}
/// <inheritdoc />
protected override void HandleException(Exception exception)
{
@ -104,7 +134,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
{
_disposing = true;
_rendererRegistry.TryRemove(Id);
while (UnacknowledgedRenderBatches.TryDequeue(out var entry))
while (_unacknowledgedRenderBatches.TryDequeue(out var entry))
{
entry.CompletionSource.TrySetCanceled();
entry.Data.Dispose();
@ -146,7 +176,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
// Buffer the rendered batches no matter what. We'll send it down immediately when the client
// is connected or right after the client reconnects.
UnacknowledgedRenderBatches.Enqueue(pendingRender);
_unacknowledgedRenderBatches.Enqueue(pendingRender);
}
catch
{
@ -167,7 +197,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
// All the batches are sent in order based on the fact that SignalR
// provides ordering for the underlying messages and that the batches
// are always in order.
return Task.WhenAll(UnacknowledgedRenderBatches.Select(b => WriteBatchBytesAsync(b)));
return Task.WhenAll(_unacknowledgedRenderBatches.Select(b => WriteBatchBytesAsync(b)));
}
private async Task WriteBatchBytesAsync(UnacknowledgedRenderBatch pending)
@ -203,12 +233,12 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
// disposed.
}
public void OnRenderCompleted(long incomingBatchId, string errorMessageOrNull)
public Task OnRenderCompleted(long incomingBatchId, string errorMessageOrNull)
{
if (_disposing)
{
// Disposing so don't do work.
return;
return Task.CompletedTask;
}
// When clients send acks we know for sure they received and applied the batch.
@ -234,18 +264,21 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
// synchronizes calls to hub methods. That is, it won't issue more than one call to this method from the same hub
// at the same time on different threads.
if (!UnacknowledgedRenderBatches.TryPeek(out var nextUnacknowledgedBatch) || incomingBatchId < nextUnacknowledgedBatch.BatchId)
if (!_unacknowledgedRenderBatches.TryPeek(out var nextUnacknowledgedBatch) || incomingBatchId < nextUnacknowledgedBatch.BatchId)
{
Log.ReceivedDuplicateBatchAck(_logger, incomingBatchId);
return Task.CompletedTask;
}
else
{
var lastBatchId = nextUnacknowledgedBatch.BatchId;
// Order is important here so that we don't prematurely dequeue the last nextUnacknowledgedBatch
while (UnacknowledgedRenderBatches.TryPeek(out nextUnacknowledgedBatch) && nextUnacknowledgedBatch.BatchId <= incomingBatchId)
while (_unacknowledgedRenderBatches.TryPeek(out nextUnacknowledgedBatch) && nextUnacknowledgedBatch.BatchId <= incomingBatchId)
{
lastBatchId = nextUnacknowledgedBatch.BatchId;
UnacknowledgedRenderBatches.TryDequeue(out _);
// At this point the queue is definitely not full, we have at least emptied one slot, so we allow a further
// full queue log entry the next time it fills up.
_unacknowledgedRenderBatches.TryDequeue(out _);
ProcessPendingBatch(errorMessageOrNull, nextUnacknowledgedBatch);
}
@ -253,7 +286,16 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
{
HandleException(
new InvalidOperationException($"Received an acknowledgement for batch with id '{incomingBatchId}' when the last batch produced was '{lastBatchId}'."));
return Task.CompletedTask;
}
// Normally we will not have pending renders, but it might happen that we reached the limit of
// available buffered renders and new renders got queued.
// Invoke ProcessBufferedRenderRequests so that we might produce any additional batch that is
// missing.
// We return the task in here, but the caller doesn't await it.
return Dispatcher.InvokeAsync(() => ProcessPendingRender());
}
}
@ -321,6 +363,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
private static readonly Action<ILogger, long, string, double, Exception> _completingBatchWithError;
private static readonly Action<ILogger, long, double, Exception> _completingBatchWithoutError;
private static readonly Action<ILogger, long, Exception> _receivedDuplicateBatchAcknowledgement;
private static readonly Action<ILogger, Exception> _fullUnacknowledgedRenderBatchesQueue;
private static class EventIds
{
@ -331,6 +374,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
public static readonly EventId CompletingBatchWithError = new EventId(104, "CompletingBatchWithError");
public static readonly EventId CompletingBatchWithoutError = new EventId(105, "CompletingBatchWithoutError");
public static readonly EventId ReceivedDuplicateBatchAcknowledgement = new EventId(106, "ReceivedDuplicateBatchAcknowledgement");
public static readonly EventId FullUnacknowledgedRenderBatchesQueue = new EventId(107, "FullUnacknowledgedRenderBatchesQueue");
}
static Log()
@ -369,6 +413,11 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
LogLevel.Debug,
EventIds.ReceivedDuplicateBatchAcknowledgement,
"Received a duplicate ACK for batch id '{IncomingBatchId}'.");
_fullUnacknowledgedRenderBatchesQueue = LoggerMessage.Define(
LogLevel.Debug,
EventIds.FullUnacknowledgedRenderBatchesQueue,
"The queue of unacknowledged render batches is full.");
}
public static void SendBatchDataFailed(ILogger logger, Exception exception)
@ -421,10 +470,27 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
null);
}
internal static void ReceivedDuplicateBatchAck(ILogger logger, long incomingBatchId)
public static void ReceivedDuplicateBatchAck(ILogger logger, long incomingBatchId)
{
_receivedDuplicateBatchAcknowledgement(logger, incomingBatchId, null);
}
public static void FullUnacknowledgedRenderBatchesQueue(ILogger logger)
{
_fullUnacknowledgedRenderBatchesQueue(logger, null);
}
}
}
internal readonly struct PendingRender
{
public PendingRender(int componentId, RenderFragment renderFragment)
{
ComponentId = componentId;
RenderFragment = renderFragment;
}
public int ComponentId { get; }
public RenderFragment RenderFragment { get; }
}
}

View File

@ -242,7 +242,7 @@ namespace Microsoft.AspNetCore.Components.Server
}
Log.ReceivedConfirmationForBatch(_logger, renderId);
CircuitHost.Renderer.OnRenderCompleted(renderId, errorMessageOrNull);
_ = CircuitHost.Renderer.OnRenderCompleted(renderId, errorMessageOrNull);
}
public void OnLocationChanged(string uri, bool intercepted)

View File

@ -240,7 +240,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
private class TestRemoteRenderer : RemoteRenderer
{
public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IJSRuntime jsRuntime, IClientProxy client)
: base(serviceProvider, NullLoggerFactory.Instance, rendererRegistry, jsRuntime, new CircuitClientProxy(client, "connection"), HtmlEncoder.Default, NullLogger.Instance)
: base(serviceProvider, NullLoggerFactory.Instance, rendererRegistry, new CircuitOptions(), jsRuntime, new CircuitClientProxy(client, "connection"), HtmlEncoder.Default, NullLogger.Instance)
{
}

View File

@ -8,9 +8,11 @@ using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
using Moq;
@ -48,7 +50,82 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
component.TriggerRender();
// Assert
Assert.Equal(2, renderer.UnacknowledgedRenderBatches.Count);
Assert.Equal(2, renderer._unacknowledgedRenderBatches.Count);
}
[Fact]
public void NotAcknowledgingRenders_ProducesBatches_UpToTheLimit()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var renderer = (RemoteRenderer)GetHtmlRenderer(serviceProvider);
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "my element");
builder.AddContent(1, "some text");
builder.CloseElement();
});
// Act
var componentId = renderer.AssignRootComponentId(component);
for (int i = 0; i < 20; i++)
{
component.TriggerRender();
}
// Assert
Assert.Equal(10, renderer._unacknowledgedRenderBatches.Count);
}
[Fact]
public async Task NoNewBatchesAreCreated_WhenThereAreNoPendingRenderRequestsFromComponents()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var renderer = (RemoteRenderer)GetHtmlRenderer(serviceProvider);
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "my element");
builder.AddContent(1, "some text");
builder.CloseElement();
});
// Act
var componentId = renderer.AssignRootComponentId(component);
for (var i = 0; i < 10; i++)
{
component.TriggerRender();
}
await renderer.OnRenderCompleted(2, null);
// Assert
Assert.Equal(9, renderer._unacknowledgedRenderBatches.Count);
}
[Fact]
public async Task ProducesNewBatch_WhenABatchGetsAcknowledged()
{
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var renderer = (RemoteRenderer)GetHtmlRenderer(serviceProvider);
var i = 0;
var component = new TestComponent(builder =>
{
builder.AddContent(0, $"Value {i}");
});
// Act
var componentId = renderer.AssignRootComponentId(component);
for (i = 0; i < 20; i++)
{
component.TriggerRender();
}
Assert.Equal(10, renderer._unacknowledgedRenderBatches.Count);
await renderer.OnRenderCompleted(2, null);
// Assert
Assert.Equal(10, renderer._unacknowledgedRenderBatches.Count);
}
[Fact]
@ -83,7 +160,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
renderer.OnRenderCompleted(2, null);
_ = renderer.OnRenderCompleted(2, null);
@event.Reset();
firstBatchTCS.SetResult(null);
@ -101,7 +178,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
foreach (var id in renderIds.ToArray())
{
renderer.OnRenderCompleted(id, null);
_ = renderer.OnRenderCompleted(id, null);
}
secondBatchTCS.SetResult(null);
@ -152,7 +229,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// This produces an additional batch (id = 3)
trigger.TriggerRender();
var originallyQueuedBatches = renderer.UnacknowledgedRenderBatches.Count;
var originallyQueuedBatches = renderer._unacknowledgedRenderBatches.Count;
// Act
offlineClient.Transfer(onlineClient.Object, "new-connection");
@ -164,14 +241,14 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// Receive the ack for the intial batch
renderer.OnRenderCompleted(2, null);
_ = renderer.OnRenderCompleted(2, null);
// Receive the ack for the second batch
renderer.OnRenderCompleted(3, null);
_ = renderer.OnRenderCompleted(3, null);
firstBatchTCS.SetResult(null);
secondBatchTCS.SetResult(null);
// Repeat the ack for the third batch
renderer.OnRenderCompleted(3, null);
_ = renderer.OnRenderCompleted(3, null);
// Assert
Assert.Empty(exceptions);
@ -215,7 +292,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// This produces an additional batch (id = 3)
trigger.TriggerRender();
var originallyQueuedBatches = renderer.UnacknowledgedRenderBatches.Count;
var originallyQueuedBatches = renderer._unacknowledgedRenderBatches.Count;
// Act
offlineClient.Transfer(onlineClient.Object, "new-connection");
@ -227,14 +304,14 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// Receive the ack for the intial batch
renderer.OnRenderCompleted(2, null);
_ = renderer.OnRenderCompleted(2, null);
// Receive the ack for the second batch
renderer.OnRenderCompleted(2, null);
_ = renderer.OnRenderCompleted(2, null);
firstBatchTCS.SetResult(null);
secondBatchTCS.SetResult(null);
// Repeat the ack for the third batch
renderer.OnRenderCompleted(3, null);
_ = renderer.OnRenderCompleted(3, null);
// Assert
Assert.Empty(exceptions);
@ -278,7 +355,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// This produces an additional batch (id = 3)
trigger.TriggerRender();
var originallyQueuedBatches = renderer.UnacknowledgedRenderBatches.Count;
var originallyQueuedBatches = renderer._unacknowledgedRenderBatches.Count;
// Act
var exceptions = new List<Exception>();
@ -288,13 +365,13 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// Pretend that we missed the ack for the initial batch
renderer.OnRenderCompleted(3, null);
_ = renderer.OnRenderCompleted(3, null);
firstBatchTCS.SetResult(null);
secondBatchTCS.SetResult(null);
// Assert
Assert.Empty(exceptions);
Assert.Empty(renderer.UnacknowledgedRenderBatches);
Assert.Empty(renderer._unacknowledgedRenderBatches);
}
[Fact]
@ -335,7 +412,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
};
// This produces an additional batch (id = 3)
trigger.TriggerRender();
var originallyQueuedBatches = renderer.UnacknowledgedRenderBatches.Count;
var originallyQueuedBatches = renderer._unacknowledgedRenderBatches.Count;
// Act
var exceptions = new List<Exception>();
@ -344,7 +421,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
exceptions.Add(e);
};
renderer.OnRenderCompleted(4, null);
_ = renderer.OnRenderCompleted(4, null);
firstBatchTCS.SetResult(null);
secondBatchTCS.SetResult(null);
@ -372,7 +449,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
// Assert
Assert.Equal(0, first.ComponentId);
Assert.Equal(1, second.ComponentId);
Assert.Equal(2, renderer.UnacknowledgedRenderBatches.Count);
Assert.Equal(2, renderer._unacknowledgedRenderBatches.Count);
}
private RemoteRenderer GetRemoteRenderer(IServiceProvider serviceProvider, CircuitClientProxy circuitClientProxy)
@ -389,6 +466,7 @@ namespace Microsoft.AspNetCore.Components.Web.Rendering
serviceProvider,
NullLoggerFactory.Instance,
new RendererRegistry(),
new CircuitOptions(),
jsRuntime.Object,
circuitClientProxy,
HtmlEncoder.Default,

View File

@ -46,6 +46,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
serviceScope.ServiceProvider ?? Mock.Of<IServiceProvider>(),
NullLoggerFactory.Instance,
new RendererRegistry(),
new CircuitOptions(),
jsRuntime,
clientProxy,
HtmlEncoder.Default,

View File

@ -507,15 +507,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
sink.MessageLogged += (wc) => logEvents.Add((wc.LogLevel, wc.EventId.Name, wc.Exception));
// Act
await Client.ClickAsync("event-handler-throw-sync");
await Client.ClickAsync("event-handler-throw-sync", expectRenderBatch: false);
Assert.Contains(
logEvents,
e => LogLevel.Warning == e.logLevel &&
"UnhandledExceptionInCircuit" == e.eventIdName &&
"Handler threw an exception" == e.exception.Message);
await ValidateClientKeepsWorking(Client, batches);
}
private Task ValidateClientKeepsWorking(BlazorClient Client, List<(int, int, byte[])> batches) =>

View File

@ -0,0 +1,124 @@
// 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.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Ignitor;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
{
public class RemoteRendererBufferLimitTest : IClassFixture<AspNetSiteServerFixture>, IDisposable
{
private static readonly TimeSpan DefaultLatencyTimeout = Debugger.IsAttached ? TimeSpan.FromSeconds(60) : TimeSpan.FromMilliseconds(500);
private AspNetSiteServerFixture _serverFixture;
public RemoteRendererBufferLimitTest(AspNetSiteServerFixture serverFixture)
{
serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost;
_serverFixture = serverFixture;
// Needed here for side-effects
_ = _serverFixture.RootUri;
Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout };
Client.RenderBatchReceived += (rendererId, id, data) => Batches.Add(new Batch(rendererId, id, data));
Sink = _serverFixture.Host.Services.GetRequiredService<TestSink>();
Sink.MessageLogged += LogMessages;
}
public BlazorClient Client { get; set; }
private IList<Batch> Batches { get; set; } = new List<Batch>();
// We use a stack so that we can search the logs in reverse order
private ConcurrentStack<LogMessage> Logs { get; set; } = new ConcurrentStack<LogMessage>();
public TestSink Sink { get; private set; }
[Fact]
public async Task DispatchedEventsWillKeepBeingProcessed_ButUpdatedWillBeDelayedUntilARenderIsAcknowledged()
{
// Arrange
var baseUri = new Uri(_serverFixture.RootUri, "/subdir");
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app");
Assert.Single(Batches);
await Client.SelectAsync("test-selector-select", "BasicTestApp.LimitCounterComponent");
Client.ConfirmRenderBatch = false;
for (int i = 0; i < 10; i++)
{
await Client.ClickAsync("increment");
}
await Client.ClickAsync("increment", expectRenderBatch: false);
Assert.Single(Logs, l => (LogLevel.Debug, "The queue of unacknowledged render batches is full.") == (l.LogLevel, l.Message));
Assert.Equal("10", ((TextNode)Client.FindElementById("the-count").Children.Single()).TextContent);
var fullCount = Batches.Count;
// Act
await Client.ClickAsync("increment", expectRenderBatch: false);
Assert.Contains(Logs, l => (LogLevel.Debug, "The queue of unacknowledged render batches is full.") == (l.LogLevel, l.Message));
Assert.Equal(fullCount, Batches.Count);
Client.ConfirmRenderBatch = true;
// This will resume the render batches.
await Client.ExpectRenderBatch(() => Client.ConfirmBatch(Batches[^1].Id));
// Assert
Assert.Equal("12", ((TextNode)Client.FindElementById("the-count").Children.Single()).TextContent);
Assert.Equal(fullCount + 1, Batches.Count);
}
private void LogMessages(WriteContext context) => Logs.Push(new LogMessage(context.LogLevel, context.Message, context.Exception));
[DebuggerDisplay("{Message,nq}")]
private class LogMessage
{
public LogMessage(LogLevel logLevel, string message, Exception exception)
{
LogLevel = logLevel;
Message = message;
Exception = exception;
}
public LogLevel LogLevel { get; set; }
public string Message { get; set; }
public Exception Exception { get; set; }
}
private class Batch
{
public Batch(int rendererId, int id, byte[] data)
{
Id = id;
RendererId = rendererId;
Data = data;
}
public int Id { get; }
public int RendererId { get; }
public byte[] Data { get; }
}
public void Dispose()
{
if (Sink != null)
{
Sink.MessageLogged -= LogMessages;
}
}
}
}

View File

@ -43,6 +43,7 @@
<option value="BasicTestApp.KeyCasesComponent">Key cases</option>
<option value="BasicTestApp.KeyPressEventComponent">Key press event</option>
<option value="BasicTestApp.LaggyTypingComponent">Laggy typing</option>
<option value="BasicTestApp.LimitCounterComponent">Limit counter component</option>
<option value="BasicTestApp.LocalizedText">Localized Text</option>
<option value="BasicTestApp.LogicalElementInsertionCases">Logical element insertion cases</option>
<option value="BasicTestApp.LongRunningInterop">Long running interop</option>

View File

@ -0,0 +1,25 @@
<h1>Counter</h1>
<p>This counter component is used in the server-side limit tests to showcase that we keep dispatching and processing
events when the unacknowledged render batches queue is full and that we produce the update UI once we receive an
acknowledgement.</p>
<p>Current count: <span id="the-count">@currentCount</span></p>
<p><button id="increment" @onclick="@(handleClicks ? (Action)IncrementCount : null)">Click me</button></p>
@code {
int currentCount = 0;
bool handleClicks = true;
void IncrementCount()
{
currentCount++;
}
public void Reset()
{
currentCount = 0;
StateHasChanged();
}
}

View File

@ -112,14 +112,20 @@ namespace Ignitor
return NextErrorReceived.Completion.Task;
}
public async Task ClickAsync(string elementId)
public Task ClickAsync(string elementId, bool expectRenderBatch = true)
{
if (!Hive.TryFindElementById(elementId, out var elementNode))
{
throw new InvalidOperationException($"Could not find element with id {elementId}.");
}
await ExpectRenderBatch(() => elementNode.ClickAsync(HubConnection));
if (expectRenderBatch)
{
return ExpectRenderBatch(() => elementNode.ClickAsync(HubConnection));
}
else
{
return elementNode.ClickAsync(HubConnection);
}
}
public async Task SelectAsync(string elementId, string value)
@ -292,7 +298,7 @@ namespace Ignitor
if (ConfirmRenderBatch)
{
HubConnection.InvokeAsync("OnRenderCompleted", batchId, /* error */ null);
_ = ConfirmBatch(batchId);
}
NextBatchReceived?.Completion?.TrySetResult(null);
@ -303,6 +309,11 @@ namespace Ignitor
}
}
public Task ConfirmBatch(int batchId, string error = null)
{
return HubConnection.InvokeAsync("OnRenderCompleted", batchId, error);
}
private void OnError(string error)
{
try