* [Blazor][Fixes #12054] ComponentHub reliability improvements. * Validates StartCircuit is called once per circuit. * NOOPs when other hub methods are called before start circuit and returns an error to the client.
This commit is contained in:
parent
fb64c8f2fb
commit
4bbfd4dd0a
|
|
@ -2,6 +2,7 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
|
@ -73,6 +74,13 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
/// </summary>
|
||||
public string StartCircuit(string uriAbsolute, string baseUriAbsolute)
|
||||
{
|
||||
if (CircuitHost != null)
|
||||
{
|
||||
Log.CircuitAlreadyInitialized(_logger, CircuitHost.CircuitId);
|
||||
NotifyClientError(Clients.Caller, $"The circuit host '{CircuitHost.CircuitId}' has already been initialized.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var circuitClient = new CircuitClientProxy(Clients.Caller, Context.ConnectionId);
|
||||
if (DefaultCircuitFactory.ResolveComponentMetadata(Context.GetHttpContext(), circuitClient).Count == 0)
|
||||
{
|
||||
|
|
@ -129,17 +137,44 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
/// </summary>
|
||||
public void BeginInvokeDotNetFromJS(string callId, string assemblyName, string methodIdentifier, long dotNetObjectId, string argsJson)
|
||||
{
|
||||
_ = EnsureCircuitHost().BeginInvokeDotNetFromJS(callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson);
|
||||
if (CircuitHost == null)
|
||||
{
|
||||
Log.CircuitHostNotInitialized(_logger);
|
||||
_ = NotifyClientError(Clients.Caller, "Circuit not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
_ = CircuitHost.BeginInvokeDotNetFromJS(callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intended for framework use only. Applications should not call this method directly.
|
||||
/// </summary>
|
||||
public void EndInvokeJSFromDotNet(long asyncHandle, bool succeeded, string arguments)
|
||||
{
|
||||
_ = EnsureCircuitHost().EndInvokeJSFromDotNet(asyncHandle, succeeded, arguments);
|
||||
if (CircuitHost == null)
|
||||
{
|
||||
Log.CircuitHostNotInitialized(_logger);
|
||||
_ = NotifyClientError(Clients.Caller, "Circuit not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
_ = CircuitHost.EndInvokeJSFromDotNet(asyncHandle, succeeded, arguments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intended for framework use only. Applications should not call this method directly.
|
||||
/// </summary>
|
||||
public void DispatchBrowserEvent(string eventDescriptor, string eventArgs)
|
||||
{
|
||||
_ = EnsureCircuitHost().DispatchEvent(eventDescriptor, eventArgs);
|
||||
if (CircuitHost == null)
|
||||
{
|
||||
Log.CircuitHostNotInitialized(_logger);
|
||||
_ = NotifyClientError(Clients.Caller, "Circuit not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
_ = CircuitHost.DispatchEvent(eventDescriptor, eventArgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -147,8 +182,15 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
/// </summary>
|
||||
public void OnRenderCompleted(long renderId, string errorMessageOrNull)
|
||||
{
|
||||
if (CircuitHost == null)
|
||||
{
|
||||
Log.CircuitHostNotInitialized(_logger);
|
||||
NotifyClientError(Clients.Caller, "Circuit not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.ReceivedConfirmationForBatch(_logger, renderId);
|
||||
EnsureCircuitHost().Renderer.OnRenderCompleted(renderId, errorMessageOrNull);
|
||||
CircuitHost.Renderer.OnRenderCompleted(renderId, errorMessageOrNull);
|
||||
}
|
||||
|
||||
private async void CircuitHost_UnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
|
|
@ -161,14 +203,14 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
Log.UnhandledExceptionInCircuit(_logger, circuitId, (Exception)e.ExceptionObject);
|
||||
if (_options.DetailedErrors)
|
||||
{
|
||||
await circuitHost.Client.SendAsync("JS.Error", e.ExceptionObject.ToString());
|
||||
await NotifyClientError(circuitHost.Client, e.ExceptionObject.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
var message = $"There was an unhandled exception on the current circuit, so this circuit will be terminated. For more details turn on " +
|
||||
$"detailed exceptions in '{typeof(CircuitOptions).Name}.{nameof(CircuitOptions.DetailedErrors)}'";
|
||||
|
||||
await circuitHost.Client.SendAsync("JS.Error", message);
|
||||
await NotifyClientError(circuitHost.Client, message);
|
||||
}
|
||||
|
||||
// We generally can't abort the connection here since this is an async
|
||||
|
|
@ -181,17 +223,8 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
}
|
||||
}
|
||||
|
||||
private CircuitHost EnsureCircuitHost()
|
||||
{
|
||||
var circuitHost = CircuitHost;
|
||||
if (circuitHost == null)
|
||||
{
|
||||
var message = $"The {nameof(CircuitHost)} is null. This is due to an exception thrown during initialization.";
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
return circuitHost;
|
||||
}
|
||||
private static Task NotifyClientError(IClientProxy client, string error) =>
|
||||
client.SendAsync("JS.Error", error);
|
||||
|
||||
private static class Log
|
||||
{
|
||||
|
|
@ -207,6 +240,13 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
private static readonly Action<ILogger, string, Exception> _failedToTransmitException =
|
||||
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(4, "FailedToTransmitException"), "Failed to transmit exception to client in circuit {CircuitId}");
|
||||
|
||||
private static readonly Action<ILogger, string, Exception> _circuitAlreadyInitialized =
|
||||
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(5, "CircuitAlreadyInitialized"), "The circuit host '{CircuitId}' has already been initialized");
|
||||
|
||||
private static readonly Action<ILogger, string, Exception> _circuitHostNotInitialized =
|
||||
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(6, "CircuitHostNotInitialized"), "Call to '{CallSite}' received before the circuit host initialization.");
|
||||
|
||||
|
||||
public static void NoComponentsRegisteredInEndpoint(ILogger logger, string endpointDisplayName)
|
||||
{
|
||||
_noComponentsRegisteredInEndpoint(logger, endpointDisplayName, null);
|
||||
|
|
@ -226,6 +266,10 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
{
|
||||
_failedToTransmitException(logger, circuitId, transmissionException);
|
||||
}
|
||||
|
||||
public static void CircuitAlreadyInitialized(ILogger logger, string circuitId) => _circuitAlreadyInitialized(logger, circuitId, null);
|
||||
|
||||
public static void CircuitHostNotInitialized(ILogger logger, [CallerMemberName] string callSite = "") => _circuitHostNotInitialized(logger, callSite, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
// 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.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Ignitor;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
|
||||
{
|
||||
public class ComponentHubReliabilityTest : IClassFixture<AspNetSiteServerFixture>, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan DefaultLatencyTimeout = TimeSpan.FromMilliseconds(500);
|
||||
private readonly AspNetSiteServerFixture _serverFixture;
|
||||
|
||||
public ComponentHubReliabilityTest(AspNetSiteServerFixture serverFixture)
|
||||
{
|
||||
serverFixture.BuildWebHostMethod = TestServer.Program.BuildWebHost;
|
||||
_serverFixture = serverFixture;
|
||||
CreateDefaultConfiguration();
|
||||
}
|
||||
|
||||
public BlazorClient Client { get; set; }
|
||||
|
||||
private IList<Batch> Batches { get; set; } = new List<Batch>();
|
||||
private IList<string> Errors { get; set; } = new List<string>();
|
||||
private IList<LogMessage> Logs { get; set; } = new List<LogMessage>();
|
||||
|
||||
public TestSink TestSink { get; set; }
|
||||
|
||||
private void CreateDefaultConfiguration()
|
||||
{
|
||||
Client = new BlazorClient() { DefaultLatencyTimeout = DefaultLatencyTimeout };
|
||||
Client.RenderBatchReceived += (id, rendererId, data) => Batches.Add(new Batch(id, rendererId, data));
|
||||
Client.OnCircuitError += (error) => Errors.Add(error);
|
||||
|
||||
_ = _serverFixture.RootUri; // this is needed for the side-effects of getting the URI.
|
||||
TestSink = _serverFixture.Host.Services.GetRequiredService<TestSink>();
|
||||
TestSink.MessageLogged += LogMessages;
|
||||
}
|
||||
|
||||
private void LogMessages(WriteContext context) => Logs.Add(new LogMessage(context.LogLevel, context.Message, context.Exception));
|
||||
|
||||
[Fact]
|
||||
public async Task CannotStartMultipleCircuits()
|
||||
{
|
||||
// Arrange
|
||||
var expectedError = "The circuit host '.*?' has already been initialized.";
|
||||
var rootUri = _serverFixture.RootUri;
|
||||
var baseUri = new Uri(rootUri, "/subdir");
|
||||
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false), "Couldn't connect to the app");
|
||||
Assert.Single(Batches);
|
||||
|
||||
// Act
|
||||
await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync(
|
||||
"StartCircuit",
|
||||
baseUri.GetLeftPart(UriPartial.Authority),
|
||||
baseUri));
|
||||
|
||||
// Assert
|
||||
var actualError = Assert.Single(Errors);
|
||||
Assert.Matches(expectedError, actualError);
|
||||
Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotInvokeJSInteropBeforeInitialization()
|
||||
{
|
||||
// Arrange
|
||||
var expectedError = "Circuit not initialized.";
|
||||
var rootUri = _serverFixture.RootUri;
|
||||
var baseUri = new Uri(rootUri, "/subdir");
|
||||
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false));
|
||||
Assert.Empty(Batches);
|
||||
|
||||
// Act
|
||||
await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync(
|
||||
"BeginInvokeDotNetFromJS",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
""));
|
||||
|
||||
// Assert
|
||||
var actualError = Assert.Single(Errors);
|
||||
Assert.Equal(expectedError, actualError);
|
||||
Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information);
|
||||
Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'BeginInvokeDotNetFromJS' received before the circuit host initialization."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotInvokeJSInteropCallbackCompletionsBeforeInitialization()
|
||||
{
|
||||
// Arrange
|
||||
var expectedError = "Circuit not initialized.";
|
||||
var rootUri = _serverFixture.RootUri;
|
||||
var baseUri = new Uri(rootUri, "/subdir");
|
||||
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false));
|
||||
Assert.Empty(Batches);
|
||||
|
||||
// Act
|
||||
await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync(
|
||||
"EndInvokeJSFromDotNet",
|
||||
3,
|
||||
true,
|
||||
"[]"));
|
||||
|
||||
// Assert
|
||||
var actualError = Assert.Single(Errors);
|
||||
Assert.Equal(expectedError, actualError);
|
||||
Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information);
|
||||
Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'EndInvokeJSFromDotNet' received before the circuit host initialization."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotDispatchBrowserEventsBeforeInitialization()
|
||||
{
|
||||
// Arrange
|
||||
var expectedError = "Circuit not initialized.";
|
||||
var rootUri = _serverFixture.RootUri;
|
||||
var baseUri = new Uri(rootUri, "/subdir");
|
||||
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false));
|
||||
Assert.Empty(Batches);
|
||||
|
||||
// Act
|
||||
await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync(
|
||||
"DispatchBrowserEvent",
|
||||
"",
|
||||
""));
|
||||
|
||||
// Assert
|
||||
var actualError = Assert.Single(Errors);
|
||||
Assert.Equal(expectedError, actualError);
|
||||
Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information);
|
||||
Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'DispatchBrowserEvent' received before the circuit host initialization."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CannotInvokeOnRenderCompletedInitialization()
|
||||
{
|
||||
// Arrange
|
||||
var expectedError = "Circuit not initialized.";
|
||||
var rootUri = _serverFixture.RootUri;
|
||||
var baseUri = new Uri(rootUri, "/subdir");
|
||||
Assert.True(await Client.ConnectAsync(baseUri, prerendered: false, connectAutomatically: false));
|
||||
Assert.Empty(Batches);
|
||||
|
||||
// Act
|
||||
await Client.ExpectCircuitError(() => Client.HubConnection.SendAsync(
|
||||
"OnRenderCompleted",
|
||||
5,
|
||||
null));
|
||||
|
||||
// Assert
|
||||
var actualError = Assert.Single(Errors);
|
||||
Assert.Equal(expectedError, actualError);
|
||||
Assert.DoesNotContain(Logs, l => l.LogLevel > LogLevel.Information);
|
||||
Assert.Contains(Logs, l => (l.LogLevel, l.Message) == (LogLevel.Debug, "Call to 'OnRenderCompleted' received before the circuit host initialization."));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
TestSink.MessageLogged -= LogMessages;
|
||||
}
|
||||
|
||||
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 id, int rendererId, byte [] data)
|
||||
{
|
||||
Id = id;
|
||||
RendererId = rendererId;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public int Id { get; }
|
||||
public int RendererId { get; }
|
||||
public byte[] Data { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -445,7 +445,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
|
|||
Assert.Contains(
|
||||
logEvents,
|
||||
e => e.eventIdName == "DispatchEventFailedToDispatchEvent" && e.logLevel == LogLevel.Debug &&
|
||||
e.exception is ArgumentException ae && ae.Message.Contains("There is no event handler with ID -1"));
|
||||
e.exception is ArgumentException ae && ae.Message.Contains("There is no event handler with ID 1"));
|
||||
|
||||
await ValidateClientKeepsWorking(Client, batches);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ namespace Ignitor
|
|||
|
||||
private CancellableOperation NextBatchReceived { get; set; }
|
||||
|
||||
private CancellableOperation NextErrorReceived { get; set; }
|
||||
|
||||
private CancellableOperation NextJSInteropReceived { get; set; }
|
||||
|
||||
private CancellableOperation NextDotNetInteropCompletionReceived { get; set; }
|
||||
|
|
@ -52,7 +54,7 @@ namespace Ignitor
|
|||
|
||||
public event Action<string> DotNetInteropCompletion;
|
||||
|
||||
public event Action<Error> OnCircuitError;
|
||||
public event Action<string> OnCircuitError;
|
||||
|
||||
public string CircuitId { get; set; }
|
||||
|
||||
|
|
@ -98,6 +100,18 @@ namespace Ignitor
|
|||
return NextDotNetInteropCompletionReceived.Completion.Task;
|
||||
}
|
||||
|
||||
public Task PrepareForNextCircuitError()
|
||||
{
|
||||
if (NextErrorReceived?.Completion != null)
|
||||
{
|
||||
throw new InvalidOperationException("Invalid state previous task not completed");
|
||||
}
|
||||
|
||||
NextErrorReceived = new CancellableOperation(DefaultLatencyTimeout);
|
||||
|
||||
return NextErrorReceived.Completion.Task;
|
||||
}
|
||||
|
||||
public async Task ClickAsync(string elementId)
|
||||
{
|
||||
if (!Hive.TryFindElementById(elementId, out var elementNode))
|
||||
|
|
@ -139,6 +153,13 @@ namespace Ignitor
|
|||
await task;
|
||||
}
|
||||
|
||||
public async Task ExpectCircuitError(Func<Task> action)
|
||||
{
|
||||
var task = WaitForCircuitError();
|
||||
await action();
|
||||
await task;
|
||||
}
|
||||
|
||||
private Task WaitForRenderBatch(TimeSpan? timeout = null)
|
||||
{
|
||||
if (ImplicitWait)
|
||||
|
|
@ -180,7 +201,20 @@ namespace Ignitor
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync(Uri uri, bool prerendered)
|
||||
private async Task WaitForCircuitError()
|
||||
{
|
||||
if (ImplicitWait)
|
||||
{
|
||||
if (DefaultLatencyTimeout == null)
|
||||
{
|
||||
throw new InvalidOperationException("Implicit wait without DefaultLatencyTimeout is not allowed.");
|
||||
}
|
||||
|
||||
await PrepareForNextCircuitError();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync(Uri uri, bool prerendered, bool connectAutomatically = true)
|
||||
{
|
||||
var builder = new HubConnectionBuilder();
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IHubProtocol, IgnitorMessagePackHubProtocol>());
|
||||
|
|
@ -193,9 +227,14 @@ namespace Ignitor
|
|||
HubConnection.On<int, string, string>("JS.BeginInvokeJS", OnBeginInvokeJS);
|
||||
HubConnection.On<string>("JS.EndInvokeDotNet", OnEndInvokeDotNet);
|
||||
HubConnection.On<int, int, byte[]>("JS.RenderBatch", OnRenderBatch);
|
||||
HubConnection.On<Error>("JS.OnError", OnError);
|
||||
HubConnection.On<string>("JS.Error", OnError);
|
||||
HubConnection.Closed += OnClosedAsync;
|
||||
|
||||
if (!connectAutomatically)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Now everything is registered so we can start the circuit.
|
||||
if (prerendered)
|
||||
{
|
||||
|
|
@ -264,9 +303,18 @@ namespace Ignitor
|
|||
}
|
||||
}
|
||||
|
||||
private void OnError(Error error)
|
||||
private void OnError(string error)
|
||||
{
|
||||
OnCircuitError?.Invoke(error);
|
||||
try
|
||||
{
|
||||
OnCircuitError?.Invoke(error);
|
||||
|
||||
NextErrorReceived?.Completion?.TrySetResult(null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
NextErrorReceived?.Completion?.TrySetResult(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Task OnClosedAsync(Exception ex)
|
||||
|
|
|
|||
Loading…
Reference in New Issue