Skip to content

Commit f876ff1

Browse files
Ability to monitor Blazor Server circuit activity (#46968)
1 parent f7e68a8 commit f876ff1

14 files changed

+337
-14
lines changed

src/Components/Server/src/Circuits/CircuitHost.cs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal partial class CircuitHost : IAsyncDisposable
2121
private readonly CircuitHandler[] _circuitHandlers;
2222
private readonly RemoteNavigationManager _navigationManager;
2323
private readonly ILogger _logger;
24+
private readonly Func<Func<Task>, Task> _dispatchInboundActivity;
2425
private bool _initialized;
2526
private bool _disposed;
2627

@@ -66,6 +67,8 @@ public CircuitHost(
6667
Circuit = new Circuit(this);
6768
Handle = new CircuitHandle() { CircuitHost = this, };
6869

70+
_dispatchInboundActivity = BuildInboundActivityDispatcher(_circuitHandlers, Circuit);
71+
6972
// An unhandled exception from the renderer is always fatal because it came from user code.
7073
Renderer.UnhandledException += ReportAndInvoke_UnhandledException;
7174
Renderer.UnhandledSynchronizationException += SynchronizationContext_UnhandledException;
@@ -324,7 +327,7 @@ public async Task OnRenderCompletedAsync(long renderId, string errorMessageOrNul
324327

325328
try
326329
{
327-
_ = Renderer.OnRenderCompletedAsync(renderId, errorMessageOrNull);
330+
_ = HandleInboundActivityAsync(() => Renderer.OnRenderCompletedAsync(renderId, errorMessageOrNull));
328331
}
329332
catch (Exception e)
330333
{
@@ -345,12 +348,12 @@ public async Task BeginInvokeDotNetFromJS(string callId, string assemblyName, st
345348

346349
try
347350
{
348-
await Renderer.Dispatcher.InvokeAsync(() =>
351+
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
349352
{
350353
Log.BeginInvokeDotNet(_logger, callId, assemblyName, methodIdentifier, dotNetObjectId);
351354
var invocationInfo = new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId);
352355
DotNetDispatcher.BeginInvokeDotNet(JSRuntime, invocationInfo, argsJson);
353-
});
356+
}));
354357
}
355358
catch (Exception ex)
356359
{
@@ -371,7 +374,7 @@ public async Task EndInvokeJSFromDotNet(long asyncCall, bool succeeded, string a
371374

372375
try
373376
{
374-
await Renderer.Dispatcher.InvokeAsync(() =>
377+
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
375378
{
376379
if (!succeeded)
377380
{
@@ -384,7 +387,7 @@ await Renderer.Dispatcher.InvokeAsync(() =>
384387
}
385388

386389
DotNetDispatcher.EndInvokeJS(JSRuntime, arguments);
387-
});
390+
}));
388391
}
389392
catch (Exception ex)
390393
{
@@ -405,11 +408,11 @@ internal async Task ReceiveByteArray(int id, byte[] data)
405408

406409
try
407410
{
408-
await Renderer.Dispatcher.InvokeAsync(() =>
411+
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
409412
{
410413
Log.ReceiveByteArraySuccess(_logger, id);
411414
DotNetDispatcher.ReceiveByteArray(JSRuntime, id, data);
412-
});
415+
}));
413416
}
414417
catch (Exception ex)
415418
{
@@ -430,10 +433,10 @@ internal async Task<bool> ReceiveJSDataChunk(long streamId, long chunkId, byte[]
430433

431434
try
432435
{
433-
return await Renderer.Dispatcher.InvokeAsync(() =>
436+
return await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
434437
{
435438
return RemoteJSDataStream.ReceiveData(JSRuntime, streamId, chunkId, chunk, error);
436-
});
439+
}));
437440
}
438441
catch (Exception ex)
439442
{
@@ -453,7 +456,7 @@ public async Task<int> SendDotNetStreamAsync(DotNetStreamReference dotNetStreamR
453456

454457
try
455458
{
456-
return await Renderer.Dispatcher.InvokeAsync<int>(async () => await dotNetStreamReference.Stream.ReadAsync(buffer));
459+
return await Renderer.Dispatcher.InvokeAsync(async () => await dotNetStreamReference.Stream.ReadAsync(buffer));
457460
}
458461
catch (Exception ex)
459462
{
@@ -505,12 +508,12 @@ public async Task OnLocationChangedAsync(string uri, string state, bool intercep
505508

506509
try
507510
{
508-
await Renderer.Dispatcher.InvokeAsync(() =>
511+
await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(() =>
509512
{
510513
Log.LocationChange(_logger, uri, CircuitId);
511514
_navigationManager.NotifyLocationChanged(uri, state, intercepted);
512515
Log.LocationChangeSucceeded(_logger, uri, CircuitId);
513-
});
516+
}));
514517
}
515518

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

548551
try
549552
{
550-
var shouldContinueNavigation = await Renderer.Dispatcher.InvokeAsync(async () =>
553+
var shouldContinueNavigation = await HandleInboundActivityAsync(() => Renderer.Dispatcher.InvokeAsync(async () =>
551554
{
552555
Log.LocationChanging(_logger, uri, CircuitId);
553556
return await _navigationManager.HandleLocationChangingAsync(uri, state, intercepted);
554-
});
557+
}));
555558

556559
await Client.SendAsync("JS.EndLocationChanging", callId, shouldContinueNavigation);
557560
}
@@ -589,6 +592,40 @@ public void SendPendingBatches()
589592
_ = Renderer.Dispatcher.InvokeAsync(Renderer.ProcessBufferedRenderBatches);
590593
}
591594

595+
// Internal for testing.
596+
internal Task HandleInboundActivityAsync(Func<Task> handler)
597+
=> _dispatchInboundActivity(handler);
598+
599+
// Internal for testing.
600+
internal async Task<TResult> HandleInboundActivityAsync<TResult>(Func<Task<TResult>> handler)
601+
{
602+
TResult result = default;
603+
await _dispatchInboundActivity(async () => result = await handler());
604+
return result;
605+
}
606+
607+
private static Func<Func<Task>, Task> BuildInboundActivityDispatcher(IReadOnlyList<CircuitHandler> circuitHandlers, Circuit circuit)
608+
{
609+
Func<CircuitInboundActivityContext, Task>? result = null;
610+
611+
for (var i = circuitHandlers.Count - 1; i >= 0; i--)
612+
{
613+
if (circuitHandlers[i] is IHandleCircuitActivity inboundActivityHandler)
614+
{
615+
var next = result ?? (static (context) => context.Handler());
616+
result = (context) => inboundActivityHandler.HandleInboundActivityAsync(context, next);
617+
}
618+
}
619+
620+
if (result is null)
621+
{
622+
// If there are no registered handlers, there is no need to allocate a context on each call.
623+
return static (handler) => handler();
624+
}
625+
626+
return (handler) => result(new(handler, circuit));
627+
}
628+
592629
private void AssertInitialized()
593630
{
594631
if (!_initialized)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
5+
6+
/// <summary>
7+
/// Contains information about inbound <see cref="Circuits.Circuit"/> activity.
8+
/// </summary>
9+
public sealed class CircuitInboundActivityContext
10+
{
11+
internal Func<Task> Handler { get; }
12+
13+
/// <summary>
14+
/// Gets the <see cref="Circuits.Circuit"/> associated with the activity.
15+
/// </summary>
16+
public Circuit Circuit { get; }
17+
18+
internal CircuitInboundActivityContext(Func<Task> handler, Circuit circuit)
19+
{
20+
Handler = handler;
21+
Circuit = circuit;
22+
}
23+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Server.Circuits;
5+
6+
/// <summary>
7+
/// A handler to process inbound circuit activity.
8+
/// </summary>
9+
public interface IHandleCircuitActivity
10+
{
11+
/// <summary>
12+
/// Invoked when inbound activity on the circuit causes an asynchronous task to be dispatched on the server.
13+
/// </summary>
14+
/// <param name="context">The <see cref="CircuitInboundActivityContext"/>.</param>
15+
/// <param name="next">The next handler to invoke.</param>
16+
/// <returns>A <see cref="Task"/> that completes when the activity has finished.</returns>
17+
Task HandleInboundActivityAsync(CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next);
18+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext
3+
Microsoft.AspNetCore.Components.Server.Circuits.CircuitInboundActivityContext.Circuit.get -> Microsoft.AspNetCore.Components.Server.Circuits.Circuit!
4+
Microsoft.AspNetCore.Components.Server.Circuits.IHandleCircuitActivity
5+
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!

src/Components/Server/test/Circuits/CircuitHostTest.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,83 @@ public async Task DisposeAsync_InvokesCircuitHandler()
318318
handler2.VerifyAll();
319319
}
320320

321+
[Fact]
322+
public async Task HandleInboundActivityAsync_InvokesCircuitActivityHandlers()
323+
{
324+
// Arrange
325+
var handler1 = new Mock<CircuitHandler>(MockBehavior.Strict);
326+
var handler2 = new Mock<CircuitHandler>(MockBehavior.Strict);
327+
var handler3 = new Mock<CircuitHandler>(MockBehavior.Strict);
328+
var sequence = new MockSequence();
329+
330+
// We deliberately avoid making handler2 an inbound activity handler
331+
var activityHandler1 = handler1.As<IHandleCircuitActivity>();
332+
var activityHandler3 = handler3.As<IHandleCircuitActivity>();
333+
334+
var asyncLocal1 = new AsyncLocal<bool>();
335+
var asyncLocal3 = new AsyncLocal<bool>();
336+
337+
activityHandler1
338+
.InSequence(sequence)
339+
.Setup(h => h.HandleInboundActivityAsync(It.IsAny<CircuitInboundActivityContext>(), It.IsAny<Func<CircuitInboundActivityContext, Task>>()))
340+
.Returns(async (CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next) =>
341+
{
342+
asyncLocal1.Value = true;
343+
await next(context);
344+
})
345+
.Verifiable();
346+
347+
activityHandler3
348+
.InSequence(sequence)
349+
.Setup(h => h.HandleInboundActivityAsync(It.IsAny<CircuitInboundActivityContext>(), It.IsAny<Func<CircuitInboundActivityContext, Task>>()))
350+
.Returns(async (CircuitInboundActivityContext context, Func<CircuitInboundActivityContext, Task> next) =>
351+
{
352+
asyncLocal3.Value = true;
353+
await next(context);
354+
})
355+
.Verifiable();
356+
357+
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object, handler3.Object });
358+
var asyncLocal1ValueInHandler = false;
359+
var asyncLocal3ValueInHandler = false;
360+
361+
// Act
362+
await circuitHost.HandleInboundActivityAsync(() =>
363+
{
364+
asyncLocal1ValueInHandler = asyncLocal1.Value;
365+
asyncLocal3ValueInHandler = asyncLocal3.Value;
366+
return Task.CompletedTask;
367+
});
368+
369+
// Assert
370+
activityHandler1.VerifyAll();
371+
activityHandler3.VerifyAll();
372+
373+
Assert.False(asyncLocal1.Value);
374+
Assert.False(asyncLocal3.Value);
375+
376+
Assert.True(asyncLocal1ValueInHandler);
377+
Assert.True(asyncLocal3ValueInHandler);
378+
}
379+
380+
[Fact]
381+
public async Task HandleInboundActivityAsync_InvokesHandlerFunc_WhenNoCircuitActivityHandlersAreRegistered()
382+
{
383+
// Arrange
384+
var circuitHost = TestCircuitHost.Create();
385+
var wasHandlerFuncInvoked = false;
386+
387+
// Act
388+
await circuitHost.HandleInboundActivityAsync(() =>
389+
{
390+
wasHandlerFuncInvoked = true;
391+
return Task.CompletedTask;
392+
});
393+
394+
// Assert
395+
Assert.True(wasHandlerFuncInvoked);
396+
}
397+
321398
private static TestRemoteRenderer GetRemoteRenderer()
322399
{
323400
var serviceCollection = new ServiceCollection();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Components.TestServer;
5+
using Microsoft.AspNetCore.Components.E2ETest;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
7+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
8+
using Microsoft.AspNetCore.E2ETesting;
9+
using OpenQA.Selenium;
10+
using TestServer;
11+
using Xunit.Abstractions;
12+
13+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;
14+
15+
public class CircuitContextTest : ServerTestBase<BasicTestAppServerSiteFixture<ServerStartup>>
16+
{
17+
public CircuitContextTest(
18+
BrowserFixture browserFixture,
19+
BasicTestAppServerSiteFixture<ServerStartup> serverFixture,
20+
ITestOutputHelper output)
21+
: base(browserFixture, serverFixture, output)
22+
{
23+
}
24+
25+
protected override void InitializeAsyncCore()
26+
{
27+
Navigate(ServerPathBase, noReload: false);
28+
Browser.MountTestComponent<CircuitContextComponent>();
29+
Browser.Equal("Circuit Context", () => Browser.Exists(By.TagName("h1")).Text);
30+
}
31+
32+
[Fact]
33+
public void ComponentMethods_HaveCircuitContext()
34+
{
35+
Browser.Click(By.Id("trigger-click-event-button"));
36+
37+
Browser.True(() => HasCircuitContext("SetParametersAsync"));
38+
Browser.True(() => HasCircuitContext("OnInitializedAsync"));
39+
Browser.True(() => HasCircuitContext("OnParametersSetAsync"));
40+
Browser.True(() => HasCircuitContext("OnAfterRenderAsync"));
41+
Browser.True(() => HasCircuitContext("InvokeDotNet"));
42+
Browser.True(() => HasCircuitContext("OnClickEvent"));
43+
44+
bool HasCircuitContext(string eventName)
45+
{
46+
var resultText = Browser.FindElement(By.Id($"circuit-context-result-{eventName}")).Text;
47+
var result = bool.Parse(resultText);
48+
return result;
49+
}
50+
}
51+
}

src/Components/test/testassets/BasicTestApp/Index.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<option value="BasicTestApp.AuthTest.CascadingAuthenticationStateParent">Cascading authentication state</option>
1111
<option value="BasicTestApp.BindCasesComponent">bind cases</option>
1212
<option value="BasicTestApp.CascadingValueTest.CascadingValueSupplier">Cascading values</option>
13+
<option value="@GetTestServerProjectComponent("Components.TestServer.CircuitContextComponent")">Circuit context</option>
1314
<option value="BasicTestApp.ComponentRefComponent">Component ref component</option>
1415
<option value="BasicTestApp.ConcurrentRenderParent">Concurrent rendering</option>
1516
<option value="BasicTestApp.ConfigurationComponent">Configuration</option>

src/Components/test/testassets/BasicTestApp/wwwroot/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
</div>
2727

2828
<!-- Used for specific test cases -->
29+
<script src="js/circuitContextTest.js"></script>
2930
<script src="js/jsinteroptests.js"></script>
3031
<script src="js/renderattributestest.js"></script>
3132
<script src="js/webComponentPerformingJsInterop.js"></script>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
window.circuitContextTest = {
2+
invokeDotNetMethod: async (dotNetObject) => {
3+
await dotNetObject.invokeMethodAsync('InvokeDotNet');
4+
},
5+
};

0 commit comments

Comments
 (0)