Skip to content

Ability to monitor Blazor Server circuit activity #46968

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 17, 2023
65 changes: 51 additions & 14 deletions src/Components/Server/src/Circuits/CircuitHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ internal partial class CircuitHost : IAsyncDisposable
private readonly CircuitHandler[] _circuitHandlers;
private readonly RemoteNavigationManager _navigationManager;
private readonly ILogger _logger;
private readonly Func<Func<Task>, Task> _dispatchInboundActivity;
private bool _initialized;
private bool _disposed;

Expand Down Expand Up @@ -66,6 +67,8 @@ public CircuitHost(
Circuit = new Circuit(this);
Handle = new CircuitHandle() { CircuitHost = this, };

_dispatchInboundActivity = BuildInboundActivityDispatcher(_circuitHandlers, Circuit);

// An unhandled exception from the renderer is always fatal because it came from user code.
Renderer.UnhandledException += ReportAndInvoke_UnhandledException;
Renderer.UnhandledSynchronizationException += SynchronizationContext_UnhandledException;
Expand Down Expand Up @@ -324,7 +327,7 @@ public async Task OnRenderCompletedAsync(long renderId, string errorMessageOrNul

try
{
_ = Renderer.OnRenderCompletedAsync(renderId, errorMessageOrNull);
_ = HandleInboundActivityAsync(() => Renderer.OnRenderCompletedAsync(renderId, errorMessageOrNull));
Copy link
Member

@SteveSandersonMS SteveSandersonMS Mar 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following is nitpicky so feel free to ignore it, especially if you think it makes the code unpleasant!

It may be possible to avoid the extra allocation caused by capturing these locals in the closure by redefining HandleInboundActivityAsync as HandleInboundActivityAsync<T>(T data, Func<T, Task> callback), then passing in the otherwise-captured values as the T data parameter (for example, the caller could pass a tuple containing whatever combination of values they want). The lambda can then be defined as static.

Not sure if that's justified or would make the code hard to read.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in order to make this work, we would have to include data in the CircuitInboundActivityContext class so it can be passed all the way through to the last handler (otherwise a capture would have to happen somewhere else). But then we would probably need to make CircuitInboundActivityContext generic in order to avoid boxing the data parameter. And then we would need to introduce a non-generic base type for CircuitInboundActivityContext to avoid also making IHandleCircuitActivity.HandleInboundActivityAsync generic.

Or maybe there's a way simpler way of doing this that I'm missing, but if it really does require that we do all this ^ then I think it might be best to leave as is. Is that okay?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am ok if we leave it as is for now, and file an issue to revisit. If in the case that nobody registers an IHandleCircuitActivity implementation we don't allocate, I think it is ok to leave it like this for now.

}
catch (Exception e)
{
Expand All @@ -345,12 +348,12 @@ public async Task BeginInvokeDotNetFromJS(string callId, string assemblyName, st

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
{
Log.BeginInvokeDotNet(_logger, callId, assemblyName, methodIdentifier, dotNetObjectId);
var invocationInfo = new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId);
DotNetDispatcher.BeginInvokeDotNet(JSRuntime, invocationInfo, argsJson);
});
}));
}
catch (Exception ex)
{
Expand All @@ -371,7 +374,7 @@ public async Task EndInvokeJSFromDotNet(long asyncCall, bool succeeded, string a

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
{
if (!succeeded)
{
Expand All @@ -384,7 +387,7 @@ await Renderer.Dispatcher.InvokeAsync(() =>
}

DotNetDispatcher.EndInvokeJS(JSRuntime, arguments);
});
}));
}
catch (Exception ex)
{
Expand All @@ -405,11 +408,11 @@ internal async Task ReceiveByteArray(int id, byte[] data)

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
{
Log.ReceiveByteArraySuccess(_logger, id);
DotNetDispatcher.ReceiveByteArray(JSRuntime, id, data);
});
}));
}
catch (Exception ex)
{
Expand All @@ -430,10 +433,10 @@ internal async Task<bool> ReceiveJSDataChunk(long streamId, long chunkId, byte[]

try
{
return await Renderer.Dispatcher.InvokeAsync(() =>
return await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
{
return RemoteJSDataStream.ReceiveData(JSRuntime, streamId, chunkId, chunk, error);
});
}));
}
catch (Exception ex)
{
Expand All @@ -453,7 +456,7 @@ public async Task<int> SendDotNetStreamAsync(DotNetStreamReference dotNetStreamR

try
{
return await Renderer.Dispatcher.InvokeAsync<int>(async () => await dotNetStreamReference.Stream.ReadAsync(buffer));
return await Renderer.Dispatcher.InvokeAsync(async () => await dotNetStreamReference.Stream.ReadAsync(buffer));
}
catch (Exception ex)
{
Expand Down Expand Up @@ -505,12 +508,12 @@ public async Task OnLocationChangedAsync(string uri, string state, bool intercep

try
{
await Renderer.Dispatcher.InvokeAsync(() =>
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
{
Log.LocationChange(_logger, uri, CircuitId);
_navigationManager.NotifyLocationChanged(uri, state, intercepted);
Log.LocationChangeSucceeded(_logger, uri, CircuitId);
});
}));
}

// It's up to the NavigationManager implementation to validate the URI.
Expand Down Expand Up @@ -547,11 +550,11 @@ public async Task OnLocationChangingAsync(int callId, string uri, string? state,

try
{
var shouldContinueNavigation = await Renderer.Dispatcher.InvokeAsync(async () =>
var shouldContinueNavigation = await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(async () =>
{
Log.LocationChanging(_logger, uri, CircuitId);
return await _navigationManager.HandleLocationChangingAsync(uri, state, intercepted);
});
}));

await Client.SendAsync("JS.EndLocationChanging", callId, shouldContinueNavigation);
}
Expand Down Expand Up @@ -589,6 +592,40 @@ public void SendPendingBatches()
_ = Renderer.Dispatcher.InvokeAsync(Renderer.ProcessBufferedRenderBatches);
}

// Internal for testing.
internal Task HandleInboundActivityAsync(Func<Task> handler)
=> _dispatchInboundActivity(handler);

// Internal for testing.
internal async Task<TResult> HandleInboundActivityAsync<TResult>(Func<Task<TResult>> handler)
{
TResult result = default;
await _dispatchInboundActivity(async () => result = await handler());
return result;
}

private static Func<Func<Task>, Task> BuildInboundActivityDispatcher(IReadOnlyList<CircuitHandler> circuitHandlers, Circuit circuit)
{
Func<CircuitInboundActivityContext, Task>? result = null;

for (var i = circuitHandlers.Count - 1; i >= 0; i--)
{
if (circuitHandlers[i] is IHandleCircuitActivity inboundActivityHandler)
{
var next = result ?? (static (context) => context.Handler());
result = (context) => inboundActivityHandler.HandleInboundActivityAsync(context, next);
}
}

if (result is null)
{
// If there are no registered handlers, there is no need to allocate a context on each call.
return static (handler) => handler();
}

return (handler) => result(new(handler, circuit));
}

private void AssertInitialized()
{
if (!_initialized)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Server.Circuits;

/// <summary>
/// Contains information about inbound <see cref="Circuits.Circuit"/> activity.
/// </summary>
public sealed class CircuitInboundActivityContext
{
internal Func<Task> Handler { get; }

/// <summary>
/// Gets the <see cref="Circuits.Circuit"/> associated with the activity.
/// </summary>
public Circuit Circuit { get; }

internal CircuitInboundActivityContext(Func<Task> handler, Circuit circuit)
{
Handler = handler;
Circuit = circuit;
}
}
18 changes: 18 additions & 0 deletions src/Components/Server/src/Circuits/IHandleCircuitActivity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Server.Circuits;

/// <summary>
/// A handler to process inbound circuit activity.
/// </summary>
public interface IHandleCircuitActivity
{
/// <summary>
/// Invoked when inbound activity on the circuit causes an asynchronous task to be dispatched on the server.
/// </summary>
/// <param name="context">The <see cref="CircuitInboundActivityContext"/>.</param>
/// <param name="next">The next handler to invoke.</param>
/// <returns>A <see cref="Task"/> that completes when the activity has finished.</returns>
Task HandleInboundActivityAsync(CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next);
}
4 changes: 4 additions & 0 deletions src/Components/Server/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext
Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext.Circuit.get -> Microsoft.AspNetCore.Components.Server.Circuits.Circuit!
Microsoft.AspNetCore.Components.Server.Circuits.IHandleCircuitActivity
Microsoft.AspNetCore.Components.Server.Circuits.IHandleCircuitActivity.HandleInboundActivityAsync(Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext! context, System.Func<Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext!, System.Threading.Tasks.Task!>! next) -> System.Threading.Tasks.Task!
77 changes: 77 additions & 0 deletions src/Components/Server/test/Circuits/CircuitHostTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,83 @@ public async Task DisposeAsync_InvokesCircuitHandler()
handler2.VerifyAll();
}

[Fact]
public async Task HandleInboundActivityAsync_InvokesCircuitActivityHandlers()
{
// Arrange
var handler1 = new Mock<CircuitHandler>(MockBehavior.Strict);
var handler2 = new Mock<CircuitHandler>(MockBehavior.Strict);
var handler3 = new Mock<CircuitHandler>(MockBehavior.Strict);
var sequence = new MockSequence();

// We deliberately avoid making handler2 an inbound activity handler
var activityHandler1 = handler1.As<IHandleCircuitActivity>();
var activityHandler3 = handler3.As<IHandleCircuitActivity>();

var asyncLocal1 = new AsyncLocal<bool>();
var asyncLocal3 = new AsyncLocal<bool>();

activityHandler1
.InSequence(sequence)
.Setup(h => h.HandleInboundActivityAsync(It.IsAny<CircuitInboundActivityContext>(), It.IsAny<Func<CircuitInboundActivityContext, Task>>()))
.Returns(async (CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next) =>
{
asyncLocal1.Value = true;
await next(context);
})
.Verifiable();

activityHandler3
.InSequence(sequence)
.Setup(h => h.HandleInboundActivityAsync(It.IsAny<CircuitInboundActivityContext>(), It.IsAny<Func<CircuitInboundActivityContext, Task>>()))
.Returns(async (CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next) =>
{
asyncLocal3.Value = true;
await next(context);
})
.Verifiable();

var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object, handler3.Object });
var asyncLocal1ValueInHandler = false;
var asyncLocal3ValueInHandler = false;

// Act
await circuitHost.HandleInboundActivityAsync(() =>
{
asyncLocal1ValueInHandler = asyncLocal1.Value;
asyncLocal3ValueInHandler = asyncLocal3.Value;
return Task.CompletedTask;
});

// Assert
activityHandler1.VerifyAll();
activityHandler3.VerifyAll();

Assert.False(asyncLocal1.Value);
Assert.False(asyncLocal3.Value);

Assert.True(asyncLocal1ValueInHandler);
Assert.True(asyncLocal3ValueInHandler);
}

[Fact]
public async Task HandleInboundActivityAsync_InvokesHandlerFunc_WhenNoCircuitActivityHandlersAreRegistered()
{
// Arrange
var circuitHost = TestCircuitHost.Create();
var wasHandlerFuncInvoked = false;

// Act
await circuitHost.HandleInboundActivityAsync(() =>
{
wasHandlerFuncInvoked = true;
return Task.CompletedTask;
});

// Assert
Assert.True(wasHandlerFuncInvoked);
}

private static TestRemoteRenderer GetRemoteRenderer()
{
var serviceCollection = new ServiceCollection();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Components.TestServer;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using TestServer;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;

public class CircuitContextTest : ServerTestBase<BasicTestAppServerSiteFixture<ServerStartup>>
{
public CircuitContextTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<ServerStartup> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: false);
Browser.MountTestComponent<CircuitContextComponent>();
Browser.Equal("Circuit Context", () => Browser.Exists(By.TagName("h1")).Text);
}

[Fact]
public void ComponentMethods_HaveCircuitContext()
{
Browser.Click(By.Id("trigger-click-event-button"));

Browser.True(() => HasCircuitContext("SetParametersAsync"));
Browser.True(() => HasCircuitContext("OnInitializedAsync"));
Browser.True(() => HasCircuitContext("OnParametersSetAsync"));
Browser.True(() => HasCircuitContext("OnAfterRenderAsync"));
Browser.True(() => HasCircuitContext("InvokeDotNet"));
Browser.True(() => HasCircuitContext("OnClickEvent"));

bool HasCircuitContext(string eventName)
{
var resultText = Browser.FindElement(By.Id($"circuit-context-result-{eventName}")).Text;
var result = bool.Parse(resultText);
return result;
}
}
}
1 change: 1 addition & 0 deletions src/Components/test/testassets/BasicTestApp/Index.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
<option value="BasicTestApp.BindCasesComponent">bind cases</option>
<option value="BasicTestApp.CascadingValueTest.CascadingValueSupplier">Cascading values</option>
<option value="@GetTestServerProjectComponent("Components.TestServer.CircuitContextComponent")">Circuit context</option>
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
<option value="BasicTestApp.ConcurrentRenderParent">Concurrent rendering</option>
<option value="BasicTestApp.ConfigurationComponent">Configuration</option>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
</div>

<!-- Used for specific test cases -->
<script src="js/circuitContextTest.js"></script>
<script src="js/jsinteroptests.js"></script>
<script src="js/renderattributestest.js"></script>
<script src="js/webComponentPerformingJsInterop.js"></script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
window.circuitContextTest = {
invokeDotNetMethod: async (dotNetObject) => {
await dotNetObject.invokeMethodAsync('InvokeDotNet');
},
};
Loading