Skip to content

Commit b027cc3

Browse files
[blazor][wasm] Dispatch rendering to main thread (Net9) (#52724)
Co-authored-by: Steve Sanderson <[email protected]>
1 parent 7f312e5 commit b027cc3

30 files changed

+824
-28
lines changed

AspNetCore.sln

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedInAspNet.Server", "sr
13201320
EndProject
13211321
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StandaloneApp", "src\Components\WebAssembly\testassets\StandaloneApp\StandaloneApp.csproj", "{A40350FE-4334-4007-B1C3-6BEB1B070309}"
13221322
EndProject
1323+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ThreadingApp", "src\Components\WebAssembly\testassets\ThreadingApp\ThreadingApp.csproj", "{A40350FE-4334-4007-B1C3-6BEB1B070308}"
1324+
EndProject
13231325
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HealthChecks", "HealthChecks", "{C1E7F837-6988-43E2-9E1C-7302DB484F99}"
13241326
EndProject
13251327
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2A91479A-4ABE-4BB7-9A5E-CA3B9CCFC69E}"
@@ -8232,6 +8234,22 @@ Global
82328234
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x64.Build.0 = Release|Any CPU
82338235
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x86.ActiveCfg = Release|Any CPU
82348236
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x86.Build.0 = Release|Any CPU
8237+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
8238+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|Any CPU.Build.0 = Debug|Any CPU
8239+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|arm64.ActiveCfg = Debug|Any CPU
8240+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|arm64.Build.0 = Debug|Any CPU
8241+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x64.ActiveCfg = Debug|Any CPU
8242+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x64.Build.0 = Debug|Any CPU
8243+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x86.ActiveCfg = Debug|Any CPU
8244+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x86.Build.0 = Debug|Any CPU
8245+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|Any CPU.ActiveCfg = Release|Any CPU
8246+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|Any CPU.Build.0 = Release|Any CPU
8247+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|arm64.ActiveCfg = Release|Any CPU
8248+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|arm64.Build.0 = Release|Any CPU
8249+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x64.ActiveCfg = Release|Any CPU
8250+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x64.Build.0 = Release|Any CPU
8251+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x86.ActiveCfg = Release|Any CPU
8252+
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x86.Build.0 = Release|Any CPU
82358253
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
82368254
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|Any CPU.Build.0 = Debug|Any CPU
82378255
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|arm64.ActiveCfg = Debug|Any CPU

src/Components/Components.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
3636
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
3737
"src\\Components\\WebAssembly\\testassets\\StandaloneApp\\StandaloneApp.csproj",
38+
"src\\Components\\WebAssembly\\testassets\\ThreadingApp\\ThreadingApp.csproj",
3839
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Client\\Wasm.Prerendered.Client.csproj",
3940
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Server\\Wasm.Prerendered.Server.csproj",
4041
"src\\Components\\WebAssembly\\testassets\\WasmLinkerTest\\WasmLinkerTest.csproj",

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree;
2424
// dispatching events to them, and notifying when the user interface is being updated.
2525
public abstract partial class Renderer : IDisposable, IAsyncDisposable
2626
{
27+
private readonly object _lockObject = new();
2728
private readonly IServiceProvider _serviceProvider;
2829
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
2930
private readonly Dictionary<IComponent, ComponentState> _componentStateByComponent = new Dictionary<IComponent, ComponentState>();
@@ -1102,17 +1103,42 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er
11021103
/// <param name="disposing"><see langword="true"/> if this method is being invoked by <see cref="IDisposable.Dispose"/>, otherwise <see langword="false"/>.</param>
11031104
protected virtual void Dispose(bool disposing)
11041105
{
1106+
// Unlike other Renderer APIs, we need Dispose to be thread-safe
1107+
// (and not require being called only from the sync context)
1108+
// because other classes many need to dispose a Renderer during their own Dispose (rather than DisposeAsync)
1109+
// and we don't want to force that other code to deal with calling InvokeAsync from a synchronous method.
1110+
lock (_lockObject)
1111+
{
1112+
if (_rendererIsDisposed)
1113+
{
1114+
// quitting synchronously as soon as possible is avoiding
1115+
// possible async dispatch to another thread and
1116+
// possible deadlock on synchronous `done.Wait()` below.
1117+
return;
1118+
}
1119+
}
1120+
11051121
if (!Dispatcher.CheckAccess())
11061122
{
11071123
// It's important that we only call the components' Dispose/DisposeAsync lifecycle methods
11081124
// on the sync context, like other lifecycle methods. In almost all cases we'd already be
11091125
// on the sync context here since DisposeAsync dispatches, but just in case someone is using
11101126
// Dispose directly, we'll dispatch and block.
1111-
Dispatcher.InvokeAsync(() => Dispose(disposing)).Wait();
1127+
var done = Dispatcher.InvokeAsync(() => Dispose(disposing));
1128+
1129+
// only block caller when this is not finalizer
1130+
if (disposing)
1131+
{
1132+
done.Wait();
1133+
}
1134+
11121135
return;
11131136
}
11141137

1115-
_rendererIsDisposed = true;
1138+
lock (_lockObject)
1139+
{
1140+
_rendererIsDisposed = true;
1141+
}
11161142

11171143
if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported)
11181144
{
@@ -1195,7 +1221,7 @@ void NotifyExceptions(List<Exception> exceptions)
11951221
/// <summary>
11961222
/// Determines how to handle an <see cref="IComponentRenderMode"/> when obtaining a component instance.
11971223
/// This is only called when a render mode is specified either at the call site or on the component type.
1198-
///
1224+
///
11991225
/// Subclasses may override this method to return a component of a different type, or throw, depending on whether the renderer
12001226
/// supports the render mode and how it implements that support.
12011227
/// </summary>
@@ -1225,9 +1251,12 @@ public void Dispose()
12251251
/// <inheritdoc />
12261252
public async ValueTask DisposeAsync()
12271253
{
1228-
if (_rendererIsDisposed)
1254+
lock (_lockObject)
12291255
{
1230-
return;
1256+
if (_rendererIsDisposed)
1257+
{
1258+
return;
1259+
}
12311260
}
12321261

12331262
if (_disposeTask != null)

src/Components/Components/src/Rendering/RendererSynchronizationContextDispatcher.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public RendererSynchronizationContextDispatcher()
2020

2121
public override Task InvokeAsync(Action workItem)
2222
{
23+
ArgumentNullException.ThrowIfNull(workItem);
2324
if (CheckAccess())
2425
{
2526
workItem();
@@ -31,6 +32,7 @@ public override Task InvokeAsync(Action workItem)
3132

3233
public override Task InvokeAsync(Func<Task> workItem)
3334
{
35+
ArgumentNullException.ThrowIfNull(workItem);
3436
if (CheckAccess())
3537
{
3638
return workItem();
@@ -41,6 +43,7 @@ public override Task InvokeAsync(Func<Task> workItem)
4143

4244
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
4345
{
46+
ArgumentNullException.ThrowIfNull(workItem);
4447
if (CheckAccess())
4548
{
4649
return Task.FromResult(workItem());
@@ -51,6 +54,7 @@ public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
5154

5255
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem)
5356
{
57+
ArgumentNullException.ThrowIfNull(workItem);
5458
if (CheckAccess())
5559
{
5660
return workItem();

src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,9 @@ public async Task InvokeAsync_SyncWorkInAsyncTaskIsCompletedFirst()
771771
await Task.Yield();
772772
actual = "First";
773773

774+
// this test assumes RendererSynchronizationContext optimization, which makes it synchronous execution.
775+
// with multi-threading runtime and WebAssemblyDispatcher `InvokeAsync` will be executed asynchronously ordering it differently.
776+
// See https://github.com/dotnet/aspnetcore/pull/52724#issuecomment-1895566632
774777
var invokeTask = context.InvokeAsync(async () =>
775778
{
776779
// When the sync context is idle, queued work items start synchronously

src/Components/ComponentsNoDeps.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
3535
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
3636
"src\\Components\\WebAssembly\\testassets\\StandaloneApp\\StandaloneApp.csproj",
37+
"src\\Components\\WebAssembly\\testassets\\ThreadingApp\\ThreadingApp.csproj",
3738
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Client\\Wasm.Prerendered.Client.csproj",
3839
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Server\\Wasm.Prerendered.Server.csproj",
3940
"src\\Components\\WebAssembly\\testassets\\WasmLinkerTest\\WasmLinkerTest.csproj",

src/Components/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,14 @@ Please see the [`Build From Source`](https://github.com/dotnet/aspnetcore/blob/m
110110

111111
##### WebAssembly Trimming
112112

113-
By default, WebAssembly E2E tests that run as part of the CI or when run in Release builds run with trimming enabled. It's possible that tests that successfully run locally might fail as part of the CI run due to errors introduced due to trimming. To test this scenario locally, either run the E2E tests in release build or with the `TestTrimmedApps` property set. For e.g.
113+
By default, WebAssembly E2E tests that run as part of the CI or when run in Release builds run with trimming enabled. It's possible that tests that successfully run locally might fail as part of the CI run due to errors introduced due to trimming. To test this scenario locally, either run the E2E tests in release build or with the `TestTrimmedOrMultithreadingApps` property set. For e.g.
114114

115115
```
116116
dotnet test -c Release
117117
```
118118
or
119119
```
120-
dotnet build /p:TestTrimmedApps=true
120+
dotnet build /p:TestTrimmedOrMultithreadingApps=true
121121
dotnet test --no-build
122122
```
123123

src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Microsoft.AspNetCore.Components.Routing;
1111
using Microsoft.AspNetCore.Components.Web;
1212
using Microsoft.AspNetCore.Components.WebAssembly.Infrastructure;
13+
using Microsoft.AspNetCore.Components.WebAssembly.Rendering;
1314
using Microsoft.AspNetCore.Components.WebAssembly.Services;
1415
using Microsoft.Extensions.Configuration;
1516
using Microsoft.Extensions.Configuration.Json;
@@ -75,6 +76,8 @@ internal WebAssemblyHostBuilder(
7576
Services = new ServiceCollection();
7677
Logging = new LoggingBuilder(Services);
7778

79+
InitializeWebAssemblyRenderer();
80+
7881
// Retrieve required attributes from JSRuntimeInvoker
7982
InitializeNavigationManager(jsMethods);
8083
InitializeRegisteredRootComponents(jsMethods);
@@ -176,6 +179,28 @@ private WebAssemblyHostEnvironment InitializeEnvironment(IInternalJSImportMethod
176179
return hostEnvironment;
177180
}
178181

182+
private static void InitializeWebAssemblyRenderer()
183+
{
184+
// note that when this is running in single-threaded context or multi-threaded-CoreCLR unit tests, we don't want to install WebAssemblyDispatcher
185+
if (OperatingSystem.IsBrowser())
186+
{
187+
var currentThread = Thread.CurrentThread;
188+
if (currentThread.IsThreadPoolThread || currentThread.IsBackground)
189+
{
190+
throw new InvalidOperationException("WebAssemblyHostBuilder needs to be instantiated in the UI thread.");
191+
}
192+
193+
// capture the JSSynchronizationContext from the main thread, which runtime already installed.
194+
// if SynchronizationContext.Current is null, it means we are on the single-threaded runtime
195+
// if user somehow installed SynchronizationContext different from JSSynchronizationContext, they need to make sure the behavior is consistent with JSSynchronizationContext.
196+
if (WebAssemblyDispatcher._mainSynchronizationContext == null && SynchronizationContext.Current != null)
197+
{
198+
WebAssemblyDispatcher._mainSynchronizationContext = SynchronizationContext.Current;
199+
WebAssemblyDispatcher._mainManagedThreadId = currentThread.ManagedThreadId;
200+
}
201+
}
202+
}
203+
179204
/// <summary>
180205
/// Gets an <see cref="WebAssemblyHostConfiguration"/> that can be used to customize the application's
181206
/// configuration sources and read configuration attributes.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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.WebAssembly.Rendering;
5+
6+
// When Blazor is deployed with multi-threaded runtime, WebAssemblyDispatcher will help to dispatch all Blazor JS interop calls to the main thread.
7+
// This is necessary because all JS objects have thread affinity. They are only available on the thread (WebWorker) which created them.
8+
// Also DOM is only available on the main (browser) thread.
9+
// Because all of the Dispatcher.InvokeAsync methods return Task, we don't need to propagate errors via OnUnhandledException handler
10+
internal sealed class WebAssemblyDispatcher : Dispatcher
11+
{
12+
internal static SynchronizationContext? _mainSynchronizationContext;
13+
internal static int _mainManagedThreadId;
14+
15+
// we really need the UI thread not just the right context, because JS objects have thread affinity
16+
public override bool CheckAccess() => _mainManagedThreadId == Environment.CurrentManagedThreadId;
17+
18+
public override Task InvokeAsync(Action workItem)
19+
{
20+
ArgumentNullException.ThrowIfNull(workItem);
21+
if (CheckAccess())
22+
{
23+
// this branch executes on correct thread and solved JavaScript objects thread affinity
24+
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
25+
workItem();
26+
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
27+
return Task.CompletedTask;
28+
}
29+
30+
var tcs = new TaskCompletionSource();
31+
32+
// RendererSynchronizationContext doesn't need to deal with thread affinity and so it could execute jobs on calling thread as optimization.
33+
// we could not do it for WASM/JavaScript, because we need to solve for thread affinity of JavaScript objects, so we always Post into the queue.
34+
_mainSynchronizationContext!.Post(static (object? o) =>
35+
{
36+
var state = ((TaskCompletionSource tcs, Action workItem))o!;
37+
try
38+
{
39+
state.workItem();
40+
state.tcs.SetResult();
41+
}
42+
catch (Exception ex)
43+
{
44+
state.tcs.SetException(ex);
45+
}
46+
}, (tcs, workItem));
47+
48+
return tcs.Task;
49+
}
50+
51+
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
52+
{
53+
ArgumentNullException.ThrowIfNull(workItem);
54+
if (CheckAccess())
55+
{
56+
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
57+
return Task.FromResult(workItem());
58+
}
59+
60+
var tcs = new TaskCompletionSource<TResult>();
61+
62+
_mainSynchronizationContext!.Post(static (object? o) =>
63+
{
64+
var state = ((TaskCompletionSource<TResult> tcs, Func<TResult> workItem))o!;
65+
try
66+
{
67+
var res = state.workItem();
68+
state.tcs.SetResult(res);
69+
}
70+
catch (Exception ex)
71+
{
72+
state.tcs.SetException(ex);
73+
}
74+
}, (tcs, workItem));
75+
76+
return tcs.Task;
77+
}
78+
79+
public override Task InvokeAsync(Func<Task> workItem)
80+
{
81+
ArgumentNullException.ThrowIfNull(workItem);
82+
if (CheckAccess())
83+
{
84+
// this branch executes on correct thread and solved JavaScript objects thread affinity
85+
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
86+
return workItem();
87+
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
88+
}
89+
90+
var tcs = new TaskCompletionSource();
91+
92+
_mainSynchronizationContext!.Post(static (object? o) =>
93+
{
94+
var state = ((TaskCompletionSource tcs, Func<Task> workItem))o!;
95+
96+
try
97+
{
98+
state.workItem().ContinueWith(t =>
99+
{
100+
if (t.IsFaulted)
101+
{
102+
state.tcs.SetException(t.Exception);
103+
}
104+
else if (t.IsCanceled)
105+
{
106+
state.tcs.SetCanceled();
107+
}
108+
else
109+
{
110+
state.tcs.SetResult();
111+
}
112+
}, TaskScheduler.FromCurrentSynchronizationContext());
113+
}
114+
catch (Exception ex)
115+
{
116+
// it could happen that the workItem will throw synchronously
117+
state.tcs.SetException(ex);
118+
}
119+
}, (tcs, workItem));
120+
121+
return tcs.Task;
122+
}
123+
124+
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem)
125+
{
126+
ArgumentNullException.ThrowIfNull(workItem);
127+
if (CheckAccess())
128+
{
129+
// this branch executes on correct thread and solved JavaScript objects thread affinity
130+
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
131+
return workItem();
132+
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
133+
}
134+
135+
var tcs = new TaskCompletionSource<TResult>();
136+
137+
_mainSynchronizationContext!.Post(static (object? o) =>
138+
{
139+
var state = ((TaskCompletionSource<TResult> tcs, Func<Task<TResult>> workItem))o!;
140+
try
141+
{
142+
state.workItem().ContinueWith(t =>
143+
{
144+
if (t.IsFaulted)
145+
{
146+
state.tcs.SetException(t.Exception);
147+
}
148+
else if (t.IsCanceled)
149+
{
150+
state.tcs.SetCanceled();
151+
}
152+
else
153+
{
154+
state.tcs.SetResult(t.Result);
155+
}
156+
}, TaskScheduler.FromCurrentSynchronizationContext());
157+
}
158+
catch (Exception ex)
159+
{
160+
state.tcs.SetException(ex);
161+
}
162+
}, (tcs, workItem));
163+
164+
return tcs.Task;
165+
}
166+
}

0 commit comments

Comments
 (0)