diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs index 98b2644ab8..0d1890dfac 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -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; } diff --git a/src/Components/Components/src/Rendering/Renderer.Log.cs b/src/Components/Components/src/Rendering/Renderer.Log.cs index 3b70f58973..bd65809632 100644 --- a/src/Components/Components/src/Rendering/Renderer.Log.cs +++ b/src/Components/Components/src/Rendering/Renderer.Log.cs @@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Components.Rendering } } - internal static void DisposingComponent(ILogger logger, ComponentState componentState) + public static void DisposingComponent(ILogger 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 logger, ulong eventHandlerId, EventArgs eventArgs) + public static void HandlingEvent(ILogger logger, ulong eventHandlerId, EventArgs eventArgs) { _handlingEvent(logger, eventHandlerId, eventArgs?.GetType().Name ?? "null", null); } diff --git a/src/Components/Components/src/Rendering/Renderer.cs b/src/Components/Components/src/Rendering/Renderer.cs index 6e0ed1633e..048ba9a868 100644 --- a/src/Components/Components/src/Rendering/Renderer.cs +++ b/src/Components/Components/src/Rendering/Renderer.cs @@ -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 /// /// The ID of the component to render. /// A that will supply the updated UI contents. - 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; + /// + /// Processses pending renders requests from components if there are any. + /// + 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 { diff --git a/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.netcoreapp3.0.cs b/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.netcoreapp3.0.cs index 7a89318f43..ce7b6a1d26 100644 --- a/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.netcoreapp3.0.cs +++ b/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.netcoreapp3.0.cs @@ -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 diff --git a/src/Components/Server/src/CircuitOptions.cs b/src/Components/Server/src/CircuitOptions.cs index 30c1b9c407..68ca25c85a 100644 --- a/src/Components/Server/src/CircuitOptions.cs +++ b/src/Components/Server/src/CircuitOptions.cs @@ -64,5 +64,17 @@ namespace Microsoft.AspNetCore.Components.Server /// Defaults to 1 minute. /// public TimeSpan JSInteropDefaultCallTimeout { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Gets or sets the maximum number of render batches that a circuit will buffer until an acknowledgement for the batch is + /// received. + /// + /// + /// 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. + /// + /// + /// Defaults to 10. + public int MaxBufferedUnacknowledgedRenderBatches { get; set; } = 10; } } diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 563cacbff6..c2a38e25b9 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -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)); } } diff --git a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs index d353d5dc2c..df16c45c4e 100644 --- a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs +++ b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs @@ -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 options) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _loggerFactory = loggerFactory; _logger = _loggerFactory.CreateLogger(); _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, diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs index 8b9ebb341a..096ee33ff0 100644 --- a/src/Components/Server/src/Circuits/RemoteRenderer.cs +++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs @@ -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 _unacknowledgedRenderBatches = new ConcurrentQueue(); 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 UnacknowledgedRenderBatches = new ConcurrentQueue(); - 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(); + } + /// 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 _completingBatchWithError; private static readonly Action _completingBatchWithoutError; private static readonly Action _receivedDuplicateBatchAcknowledgement; + private static readonly Action _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; } + } } diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 4da9a3391e..b559e590c3 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -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) diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index 58803a56b2..a6c78f8683 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -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) { } diff --git a/src/Components/Server/test/Circuits/RemoteRendererTest.cs b/src/Components/Server/test/Circuits/RemoteRendererTest.cs index ffb913544a..b6475ba1fe 100644 --- a/src/Components/Server/test/Circuits/RemoteRendererTest.cs +++ b/src/Components/Server/test/Circuits/RemoteRendererTest.cs @@ -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(); @@ -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(); @@ -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, diff --git a/src/Components/Server/test/Circuits/TestCircuitHost.cs b/src/Components/Server/test/Circuits/TestCircuitHost.cs index f8f5996d95..328120d70c 100644 --- a/src/Components/Server/test/Circuits/TestCircuitHost.cs +++ b/src/Components/Server/test/Circuits/TestCircuitHost.cs @@ -46,6 +46,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits serviceScope.ServiceProvider ?? Mock.Of(), NullLoggerFactory.Instance, new RendererRegistry(), + new CircuitOptions(), jsRuntime, clientProxy, HtmlEncoder.Default, diff --git a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs index 8b46ee7e0a..cf1be22179 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs @@ -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) => diff --git a/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs new file mode 100644 index 0000000000..7377b28a9d --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/RemoteRendererBufferLimitTest.cs @@ -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, 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(); + Sink.MessageLogged += LogMessages; + } + + public BlazorClient Client { get; set; } + + private IList Batches { get; set; } = new List(); + + // We use a stack so that we can search the logs in reverse order + private ConcurrentStack Logs { get; set; } = new ConcurrentStack(); + + 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; + } + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index fe8608105c..e1d08a3d81 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -43,6 +43,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/LimitCounterComponent.razor b/src/Components/test/testassets/BasicTestApp/LimitCounterComponent.razor new file mode 100644 index 0000000000..bb60eb48cd --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/LimitCounterComponent.razor @@ -0,0 +1,25 @@ +

Counter

+ +

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.

+ +

Current count: @currentCount

+

+ + +@code { + int currentCount = 0; + bool handleClicks = true; + + void IncrementCount() + { + currentCount++; + } + + public void Reset() + { + currentCount = 0; + StateHasChanged(); + } +} diff --git a/src/Components/test/testassets/Ignitor/BlazorClient.cs b/src/Components/test/testassets/Ignitor/BlazorClient.cs index c11c2951dd..fa28c7f1b9 100644 --- a/src/Components/test/testassets/Ignitor/BlazorClient.cs +++ b/src/Components/test/testassets/Ignitor/BlazorClient.cs @@ -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