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 #48991

Closed
wants to merge 17 commits into from
Closed
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
2 changes: 2 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,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
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
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
1 change: 1 addition & 0 deletions src/Components/ComponentsNoDeps.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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
@@ -0,0 +1,135 @@
// 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 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.
// The calls to InvokeAsync methods are dispatched synchronously and the returned Task is only resolved after the main thread finished the callback.
internal sealed class WebAssemblyDispatcher : Dispatcher
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
{
public static readonly Dispatcher Instance = new WebAssemblyDispatcher();
private readonly SynchronizationContext? _context;

private WebAssemblyDispatcher()
{
// capture the JSSynchronizationContext from the main thread.
_context = SynchronizationContext.Current;
}

public override bool CheckAccess() => SynchronizationContext.Current == _context || _context == null;
Copy link
Member

Choose a reason for hiding this comment

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

Could you explain the || _context == null part of this check?

I would have thought that if SynchronizationContext.Current == null, then we'd only consider ourselves "having access" if the original _context was also null. So it seems like the logic would be more correct if the || _context == null part of this check was removed.


public override Task InvokeAsync(Action workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
workItem();
return Task.CompletedTask;
}

_context!.InvokeAsync(workItem);
return Task.CompletedTask;
Copy link
Member

Choose a reason for hiding this comment

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

This looks like a mistake, unless I'm confused.

Shouldn't this return the task returned by _context.InvokeAsync, instead of Task.CompletedTask?

Copy link
Member Author

Choose a reason for hiding this comment

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

It will invoke the void SynchronizationContextExtension.InvokeAsync and so there is no Task. Also it's blocking synchronous call.

Copy link
Member

Choose a reason for hiding this comment

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

}

public override Task InvokeAsync(Func<Task> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return workItem();
}

return _context!.InvokeAsync(workItem);
}

public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return Task.FromResult(workItem());
}

return _context!.InvokeAsync(static (workItem) => Task.FromResult(workItem()), workItem);
}

public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem)
{
ArgumentNullException.ThrowIfNull(workItem);
if (CheckAccess())
{
return workItem();
}

return _context!.InvokeAsync(workItem);
}
}

internal static class SynchronizationContextExtension
{
public static void InvokeAsync(this SynchronizationContext self, Action body)
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
{
Exception? exc = default;
self.Send((_) =>
{
try
{
body();
}
catch (Exception ex)
{
exc = ex;
}
}, null);
if (exc != null)
{
throw exc;
}
Comment on lines +75 to +89
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't this block? My understanding is that this runs the callback inline, isn't it?

I'm trying to make sense of how this works when you are in a background thread and try to dispatch back to the "main" thread.

My understanding is that the contract for send must block the thread.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this will block the sender thread. If this is the main thread, it would just invoke it.

}

public static TRes InvokeAsync<TRes>(this SynchronizationContext self, Func<TRes> body)
{
TRes? value = default;
Exception? exc = default;
self.Send((_) =>
{
try
{
value = body();
}
catch (Exception ex)
{
exc = ex;
}
}, null);
if (exc != null)
{
throw exc;
}
return value!;
}
Copy link
Member

@SteveSandersonMS SteveSandersonMS Jul 14, 2023

Choose a reason for hiding this comment

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

The logic around async and exception handling looks strange to me. At least, it will behave very differently from how RendererSynchronizationContext does.

  • Dispatcher.InvokeAsync(Action)
    • With WebAssemblyDispatcher, it blocks the caller until the action is fully complete. If the action throws, then it re-throws instead of returning any task.
    • With RendererSynchronizationContextDispatcher, it doesn't block the caller, and returns a Task that completes when the action is complete. If the action throws, the Task is faulted.
  • Dispatcher.InvokeAsync(Func<Task>)
    • With WebAssemblyDispatcher, it blocks the caller while the dispatch is in process (up until the recipient yields), and then returns the Task returned by the recipient. If the recipient throws synchronously, then it re-throws instead of returning any task.
    • With RendererSynchronizationContextDispatcher, it does not block the caller, and returns a a Task that completes when the dispatch and recipient's Task is complete. If the recipent throws synchronously, the returned Task is faulted.

Are these differences in behavior intentional? My guess is we should behave the same as RendererSynchronizationContextDispatcher/RendererSynchronizationContext.

Copy link
Member

Choose a reason for hiding this comment

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

Similarly please see how RendererSynchronizationContext deals with unhandled exceptions by calling OnUnhandledException on the Dispatcher.


public static TRes InvokeAsync<T1, TRes>(this SynchronizationContext self, Func<T1, TRes> body, T1 p1)
{
TRes? value = default;
Exception? exc = default;
self.Send((_) =>
{
try
{
value = body(p1);
}
catch (Exception ex)
{
exc = ex;
}
}, null);
if (exc != null)
{
throw exc;
}
return value!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,21 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Rendering;
internal sealed partial class WebAssemblyRenderer : WebRenderer
{
private readonly ILogger _logger;
private readonly Dispatcher _dispatcher;

public WebAssemblyRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop)
: base(serviceProvider, loggerFactory, DefaultWebAssemblyJSRuntime.Instance.ReadJsonSerializerOptions(), jsComponentInterop)
{
_logger = loggerFactory.CreateLogger<WebAssemblyRenderer>();
// if SynchronizationContext.Current is not JSSynchronizationContext on main thread, we are in single-threaded flavor of the dotnet wasm runtime or on the server side.
_dispatcher = SynchronizationContext.Current == null || SynchronizationContext.Current.GetType().FullName != "System.Runtime.InteropServices.JavaScript.JSSynchronizationContext"
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
? NullDispatcher.Instance
: WebAssemblyDispatcher.Instance;

ElementReferenceContext = DefaultWebAssemblyJSRuntime.Instance.ElementReferenceContext;
}

public override Dispatcher Dispatcher => NullDispatcher.Instance;
public override Dispatcher Dispatcher => _dispatcher;

public Task AddComponentAsync([DynamicallyAccessedMembers(Component)] Type componentType, ParameterView parameters, string domElementSelector)
{
Expand Down
12 changes: 12 additions & 0 deletions src/Components/WebAssembly/testassets/ThreadingApp/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Router AppAssembly="@typeof(ThreadingApp.Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<h2>Not found</h2>
Sorry, there's nothing at this address.
</LayoutView>
</NotFound>
</Router>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@page "/counter"
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
@using System.Runtime.InteropServices

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
int currentCount = 0;

void IncrementCount()
{
currentCount++;
}

protected override async Task OnInitializedAsync()
{
if(!OperatingSystem.IsBrowser())
{
return;
}

if (Thread.CurrentThread.ManagedThreadId != 1)
{
throw new Exception("We should be on main thread!");
}

Exception exc = null;
try
{
// send me to the thread pool
await Task.Delay(10).ConfigureAwait(false);
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
StateHasChanged(); // render should throw
}
catch(Exception ex)
{
exc=ex;
Console.WriteLine(ex.Message);
}
if (exc == null || exc.Message != "The current thread is not associated with the Dispatcher. Use InvokeAsync() to switch execution to the Dispatcher when triggering rendering or component state.")
{
throw new Exception("We should have thrown here!");
}

// test that we could create new thread
var tcs = new TaskCompletionSource<int>();
var t = new Thread(() => {
tcs.SetResult(Thread.CurrentThread.ManagedThreadId);
});
t.Start();
var newThreadId = await tcs.Task;
if (newThreadId == 1){
throw new Exception("We should be on new thread in the callback!");
}

new Timer(async (state) =>
{
// send me to the thread pool
await Task.Delay(10).ConfigureAwait(false);
if (Thread.CurrentThread.ManagedThreadId == 1)
{
throw new Exception("We should be on thread pool thread!");
}

await InvokeAsync(() =>
{
if (Thread.CurrentThread.ManagedThreadId != 1)
{
throw new Exception("We should be on main thread again!");
}
// we are back on main thread
pavelsavara marked this conversation as resolved.
Show resolved Hide resolved
IncrementCount();
StateHasChanged(); // render!
});
}, null, 0, 100);
}
}
Loading