Skip to content
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

[blazor][wasm] Dispatch rendering to main thread (Net9) #52724

Merged
merged 18 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedInAspNet.Server", "sr
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StandaloneApp", "src\Components\WebAssembly\testassets\StandaloneApp\StandaloneApp.csproj", "{A40350FE-4334-4007-B1C3-6BEB1B070309}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ThreadingApp", "src\Components\WebAssembly\testassets\ThreadingApp\ThreadingApp.csproj", "{A40350FE-4334-4007-B1C3-6BEB1B070308}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HealthChecks", "HealthChecks", "{C1E7F837-6988-43E2-9E1C-7302DB484F99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2A91479A-4ABE-4BB7-9A5E-CA3B9CCFC69E}"
Expand Down Expand Up @@ -8232,6 +8234,22 @@ Global
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x64.Build.0 = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x86.ActiveCfg = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070309}.Release|x86.Build.0 = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|arm64.ActiveCfg = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|arm64.Build.0 = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x64.ActiveCfg = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x64.Build.0 = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x86.ActiveCfg = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Debug|x86.Build.0 = Debug|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|Any CPU.Build.0 = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|arm64.ActiveCfg = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|arm64.Build.0 = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x64.ActiveCfg = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x64.Build.0 = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x86.ActiveCfg = Release|Any CPU
{A40350FE-4334-4007-B1C3-6BEB1B070308}.Release|x86.Build.0 = Release|Any CPU
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B06040BC-DA28-4923-8CAC-20EB517D471B}.Debug|arm64.ActiveCfg = Debug|Any CPU
Expand Down
1 change: 1 addition & 0 deletions src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
"src\\Components\\WebAssembly\\testassets\\StandaloneApp\\StandaloneApp.csproj",
"src\\Components\\WebAssembly\\testassets\\ThreadingApp\\ThreadingApp.csproj",
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Client\\Wasm.Prerendered.Client.csproj",
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Server\\Wasm.Prerendered.Server.csproj",
"src\\Components\\WebAssembly\\testassets\\WasmLinkerTest\\WasmLinkerTest.csproj",
Expand Down
39 changes: 34 additions & 5 deletions src/Components/Components/src/RenderTree/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.Components.RenderTree;
// dispatching events to them, and notifying when the user interface is being updated.
public abstract partial class Renderer : IDisposable, IAsyncDisposable
{
private readonly object _lockObject = new();
private readonly IServiceProvider _serviceProvider;
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
private readonly Dictionary<IComponent, ComponentState> _componentStateByComponent = new Dictionary<IComponent, ComponentState>();
Expand Down Expand Up @@ -1102,17 +1103,42 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er
/// <param name="disposing"><see langword="true"/> if this method is being invoked by <see cref="IDisposable.Dispose"/>, otherwise <see langword="false"/>.</param>
protected virtual void Dispose(bool disposing)
{
// Unlike other Renderer APIs, we need Dispose to be thread-safe
// (and not require being called only from the sync context)
// because other classes many need to dispose a Renderer during their own Dispose (rather than DisposeAsync)
// and we don't want to force that other code to deal with calling InvokeAsync from a synchronous method.
lock (_lockObject)
{
if (_rendererIsDisposed)
{
// quitting synchronously as soon as possible is avoiding
// possible async dispatch to another thread and
// possible deadlock on synchronous `done.Wait()` below.
return;
}
}

if (!Dispatcher.CheckAccess())
{
// It's important that we only call the components' Dispose/DisposeAsync lifecycle methods
// on the sync context, like other lifecycle methods. In almost all cases we'd already be
// on the sync context here since DisposeAsync dispatches, but just in case someone is using
// Dispose directly, we'll dispatch and block.
Dispatcher.InvokeAsync(() => Dispose(disposing)).Wait();
var done = Dispatcher.InvokeAsync(() => Dispose(disposing));

// only block caller when this is not finalizer
if (disposing)
{
done.Wait();
}

return;
}

_rendererIsDisposed = true;
lock (_lockObject)
{
_rendererIsDisposed = true;
}

if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported)
{
Expand Down Expand Up @@ -1195,7 +1221,7 @@ void NotifyExceptions(List<Exception> exceptions)
/// <summary>
/// Determines how to handle an <see cref="IComponentRenderMode"/> when obtaining a component instance.
/// This is only called when a render mode is specified either at the call site or on the component type.
///
///
/// Subclasses may override this method to return a component of a different type, or throw, depending on whether the renderer
/// supports the render mode and how it implements that support.
/// </summary>
Expand Down Expand Up @@ -1225,9 +1251,12 @@ public void Dispose()
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_rendererIsDisposed)
lock (_lockObject)
{
return;
if (_rendererIsDisposed)
{
return;
}
}

if (_disposeTask != null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public RendererSynchronizationContextDispatcher()

public override Task InvokeAsync(Action workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
workItem();
Expand All @@ -31,6 +32,7 @@ public override Task InvokeAsync(Action workItem)

public override Task InvokeAsync(Func<Task> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return workItem();
Expand All @@ -41,6 +43,7 @@ public override Task InvokeAsync(Func<Task> workItem)

public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return Task.FromResult(workItem());
Expand All @@ -51,6 +54,7 @@ public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)

public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return workItem();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,9 @@ public async Task InvokeAsync_SyncWorkInAsyncTaskIsCompletedFirst()
await Task.Yield();
actual = "First";

// this test assumes RendererSynchronizationContext optimization, which makes it synchronous execution.
// with multi-threading runtime and WebAssemblyDispatcher `InvokeAsync` will be executed asynchronously ordering it differently.
// See https://github.com/dotnet/aspnetcore/pull/52724#issuecomment-1895566632
var invokeTask = context.InvokeAsync(async () =>
{
// When the sync context is idle, queued work items start synchronously
Expand Down
1 change: 1 addition & 0 deletions src/Components/ComponentsNoDeps.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
"src\\Components\\WebAssembly\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
"src\\Components\\WebAssembly\\testassets\\StandaloneApp\\StandaloneApp.csproj",
"src\\Components\\WebAssembly\\testassets\\ThreadingApp\\ThreadingApp.csproj",
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Client\\Wasm.Prerendered.Client.csproj",
"src\\Components\\WebAssembly\\testassets\\Wasm.Prerendered.Server\\Wasm.Prerendered.Server.csproj",
"src\\Components\\WebAssembly\\testassets\\WasmLinkerTest\\WasmLinkerTest.csproj",
Expand Down
4 changes: 2 additions & 2 deletions src/Components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,14 @@ Please see the [`Build From Source`](https://github.com/dotnet/aspnetcore/blob/m

##### WebAssembly Trimming

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.
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.

```
dotnet test -c Release
```
or
```
dotnet build /p:TestTrimmedApps=true
dotnet build /p:TestTrimmedOrMultithreadingApps=true
dotnet test --no-build
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Infrastructure;
using Microsoft.AspNetCore.Components.WebAssembly.Rendering;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
Expand Down Expand Up @@ -75,6 +76,8 @@ internal WebAssemblyHostBuilder(
Services = new ServiceCollection();
Logging = new LoggingBuilder(Services);

InitializeWebAssemblyRenderer();

// Retrieve required attributes from JSRuntimeInvoker
InitializeNavigationManager(jsMethods);
InitializeRegisteredRootComponents(jsMethods);
Expand Down Expand Up @@ -176,6 +179,28 @@ private WebAssemblyHostEnvironment InitializeEnvironment(IInternalJSImportMethod
return hostEnvironment;
}

private static void InitializeWebAssemblyRenderer()
{
// note that when this is running in single-threaded context or multi-threaded-CoreCLR unit tests, we don't want to install WebAssemblyDispatcher
if (OperatingSystem.IsBrowser())
{
var currentThread = Thread.CurrentThread;
if (currentThread.IsThreadPoolThread || currentThread.IsBackground)
{
throw new InvalidOperationException("WebAssemblyHostBuilder needs to be instantiated in the UI thread.");
}

// capture the JSSynchronizationContext from the main thread, which runtime already installed.
// if SynchronizationContext.Current is null, it means we are on the single-threaded runtime
// if user somehow installed SynchronizationContext different from JSSynchronizationContext, they need to make sure the behavior is consistent with JSSynchronizationContext.
if (WebAssemblyDispatcher._mainSynchronizationContext == null && SynchronizationContext.Current != null)
{
WebAssemblyDispatcher._mainSynchronizationContext = SynchronizationContext.Current;
WebAssemblyDispatcher._mainManagedThreadId = currentThread.ManagedThreadId;
}
}
}

/// <summary>
/// Gets an <see cref="WebAssemblyHostConfiguration"/> that can be used to customize the application's
/// configuration sources and read configuration attributes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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.WebAssembly.Rendering;

// When Blazor is deployed with multi-threaded runtime, WebAssemblyDispatcher will help to dispatch all Blazor JS interop calls to the main thread.
// This is necessary because all JS objects have thread affinity. They are only available on the thread (WebWorker) which created them.
// Also DOM is only available on the main (browser) thread.
// Because all of the Dispatcher.InvokeAsync methods return Task, we don't need to propagate errors via OnUnhandledException handler
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
internal sealed class WebAssemblyDispatcher : Dispatcher
{
internal static SynchronizationContext? _mainSynchronizationContext;
internal static int _mainManagedThreadId;

// we really need the UI thread not just the right context, because JS objects have thread affinity
public override bool CheckAccess() => _mainManagedThreadId == Environment.CurrentManagedThreadId;

public override Task InvokeAsync(Action workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
// this branch executes on correct thread and solved JavaScript objects thread affinity
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
workItem();
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
return Task.CompletedTask;
}

var tcs = new TaskCompletionSource();

// RendererSynchronizationContext doesn't need to deal with thread affinity and so it could execute jobs on calling thread as optimization.
// 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.
_mainSynchronizationContext!.Post(static (object? o) =>
{
var state = ((TaskCompletionSource tcs, Action workItem))o!;
try
{
state.workItem();
state.tcs.SetResult();
}
catch (Exception ex)
{
state.tcs.SetException(ex);
}
}, (tcs, workItem));

return tcs.Task;
}

public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
return Task.FromResult(workItem());
kg marked this conversation as resolved.
Show resolved Hide resolved
}

var tcs = new TaskCompletionSource<TResult>();

_mainSynchronizationContext!.Post(static (object? o) =>
{
var state = ((TaskCompletionSource<TResult> tcs, Func<TResult> workItem))o!;
try
{
var res = state.workItem();
state.tcs.SetResult(res);
}
catch (Exception ex)
{
state.tcs.SetException(ex);
}
}, (tcs, workItem));

return tcs.Task;
}

public override Task InvokeAsync(Func<Task> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
// this branch executes on correct thread and solved JavaScript objects thread affinity
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
return workItem();
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
}

var tcs = new TaskCompletionSource();

_mainSynchronizationContext!.Post(static (object? o) =>
{
var state = ((TaskCompletionSource tcs, Func<Task> workItem))o!;

try
{
state.workItem().ContinueWith(t =>
{
if (t.IsFaulted)
{
state.tcs.SetException(t.Exception);
}
else if (t.IsCanceled)
{
state.tcs.SetCanceled();
}
else
{
state.tcs.SetResult();
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
catch (Exception ex)
{
// it could happen that the workItem will throw synchronously
state.tcs.SetException(ex);
}
}, (tcs, workItem));

return tcs.Task;
}

public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
// this branch executes on correct thread and solved JavaScript objects thread affinity
// but it executes out of order, if there are some pending jobs in the _mainSyncContext already, same as RendererSynchronizationContextDispatcher
return workItem();
// it can throw synchronously, same as RendererSynchronizationContextDispatcher
}

var tcs = new TaskCompletionSource<TResult>();

_mainSynchronizationContext!.Post(static (object? o) =>
{
var state = ((TaskCompletionSource<TResult> tcs, Func<Task<TResult>> workItem))o!;
try
{
state.workItem().ContinueWith(t =>
{
if (t.IsFaulted)
{
state.tcs.SetException(t.Exception);
}
else if (t.IsCanceled)
{
state.tcs.SetCanceled();
}
else
{
state.tcs.SetResult(t.Result);
}
}, TaskScheduler.FromCurrentSynchronizationContext());
}
catch (Exception ex)
{
state.tcs.SetException(ex);
}
}, (tcs, workItem));

return tcs.Task;
}
}
Loading
Loading