diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs
index 3ac533d06b..a3dddbbc1c 100644
--- a/src/Components/Server/src/ComponentHub.cs
+++ b/src/Components/Server/src/ComponentHub.cs
@@ -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
///
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
///
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);
}
+ ///
+ /// Intended for framework use only. Applications should not call this method directly.
+ ///
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);
}
+ ///
+ /// Intended for framework use only. Applications should not call this method directly.
+ ///
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);
}
///
@@ -147,8 +182,15 @@ namespace Microsoft.AspNetCore.Components.Server
///
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 _failedToTransmitException =
LoggerMessage.Define(LogLevel.Debug, new EventId(4, "FailedToTransmitException"), "Failed to transmit exception to client in circuit {CircuitId}");
+ private static readonly Action _circuitAlreadyInitialized =
+ LoggerMessage.Define(LogLevel.Debug, new EventId(5, "CircuitAlreadyInitialized"), "The circuit host '{CircuitId}' has already been initialized");
+
+ private static readonly Action _circuitHostNotInitialized =
+ LoggerMessage.Define(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);
}
}
}
diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs
new file mode 100644
index 0000000000..5ee3a8c6d1
--- /dev/null
+++ b/src/Components/test/E2ETest/ServerExecutionTests/ComponentHubReliabilityTest.cs
@@ -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, 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 Batches { get; set; } = new List();
+ private IList Errors { get; set; } = new List();
+ private IList Logs { get; set; } = new List();
+
+ 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.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; }
+ }
+ }
+}
diff --git a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs
index d7423bb19d..a4dce84268 100644
--- a/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs
+++ b/src/Components/test/E2ETest/ServerExecutionTests/InteropReliabilityTests.cs
@@ -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);
}
diff --git a/src/Components/test/testassets/Ignitor/BlazorClient.cs b/src/Components/test/testassets/Ignitor/BlazorClient.cs
index 3e11515fa9..a8051a7e00 100644
--- a/src/Components/test/testassets/Ignitor/BlazorClient.cs
+++ b/src/Components/test/testassets/Ignitor/BlazorClient.cs
@@ -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 DotNetInteropCompletion;
- public event Action OnCircuitError;
+ public event Action 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 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 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 ConnectAsync(Uri uri, bool prerendered, bool connectAutomatically = true)
{
var builder = new HubConnectionBuilder();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton());
@@ -193,9 +227,14 @@ namespace Ignitor
HubConnection.On("JS.BeginInvokeJS", OnBeginInvokeJS);
HubConnection.On("JS.EndInvokeDotNet", OnEndInvokeDotNet);
HubConnection.On("JS.RenderBatch", OnRenderBatch);
- HubConnection.On("JS.OnError", OnError);
+ HubConnection.On("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)