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)