diff --git a/AspNetCore.sln b/AspNetCore.sln index f821cd951067..ab7e9528048d 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -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}" @@ -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 diff --git a/src/Components/Components.slnf b/src/Components/Components.slnf index 2bf4acb27e15..c5fec391b24a 100644 --- a/src/Components/Components.slnf +++ b/src/Components/Components.slnf @@ -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", diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 23ca5499e6fb..231425bd56f4 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -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 _componentStateById = new Dictionary(); private readonly Dictionary _componentStateByComponent = new Dictionary(); @@ -1102,17 +1103,42 @@ private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? er /// if this method is being invoked by , otherwise . 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) { @@ -1195,7 +1221,7 @@ void NotifyExceptions(List exceptions) /// /// Determines how to handle an 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. /// @@ -1225,9 +1251,12 @@ public void Dispose() /// public async ValueTask DisposeAsync() { - if (_rendererIsDisposed) + lock (_lockObject) { - return; + if (_rendererIsDisposed) + { + return; + } } if (_disposeTask != null) diff --git a/src/Components/Components/src/Rendering/RendererSynchronizationContextDispatcher.cs b/src/Components/Components/src/Rendering/RendererSynchronizationContextDispatcher.cs index 2c8c893351a1..2015fdad1262 100644 --- a/src/Components/Components/src/Rendering/RendererSynchronizationContextDispatcher.cs +++ b/src/Components/Components/src/Rendering/RendererSynchronizationContextDispatcher.cs @@ -20,6 +20,7 @@ public RendererSynchronizationContextDispatcher() public override Task InvokeAsync(Action workItem) { + ArgumentNullException.ThrowIfNull(workItem); if (CheckAccess()) { workItem(); @@ -31,6 +32,7 @@ public override Task InvokeAsync(Action workItem) public override Task InvokeAsync(Func workItem) { + ArgumentNullException.ThrowIfNull(workItem); if (CheckAccess()) { return workItem(); @@ -41,6 +43,7 @@ public override Task InvokeAsync(Func workItem) public override Task InvokeAsync(Func workItem) { + ArgumentNullException.ThrowIfNull(workItem); if (CheckAccess()) { return Task.FromResult(workItem()); @@ -51,6 +54,7 @@ public override Task InvokeAsync(Func workItem) public override Task InvokeAsync(Func> workItem) { + ArgumentNullException.ThrowIfNull(workItem); if (CheckAccess()) { return workItem(); diff --git a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs index 5d896ffdb302..e0f0d984899d 100644 --- a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs +++ b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs @@ -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 diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index afb8f5ce1275..854164ba02f3 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/src/Components/ComponentsNoDeps.slnf @@ -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", diff --git a/src/Components/README.md b/src/Components/README.md index e442101b1323..eac392c2bbc2 100644 --- a/src/Components/README.md +++ b/src/Components/README.md @@ -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 ``` diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 239836a0e736..cdc98e922f74 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -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; @@ -75,6 +76,8 @@ internal WebAssemblyHostBuilder( Services = new ServiceCollection(); Logging = new LoggingBuilder(Services); + InitializeWebAssemblyRenderer(); + // Retrieve required attributes from JSRuntimeInvoker InitializeNavigationManager(jsMethods); InitializeRegisteredRootComponents(jsMethods); @@ -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; + } + } + } + /// /// Gets an that can be used to customize the application's /// configuration sources and read configuration attributes. diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs new file mode 100644 index 000000000000..1928b8224736 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs @@ -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 +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 InvokeAsync(Func workItem) + { + ArgumentNullException.ThrowIfNull(workItem); + if (CheckAccess()) + { + // it can throw synchronously, same as RendererSynchronizationContextDispatcher + return Task.FromResult(workItem()); + } + + var tcs = new TaskCompletionSource(); + + _mainSynchronizationContext!.Post(static (object? o) => + { + var state = ((TaskCompletionSource tcs, Func 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 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 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 InvokeAsync(Func> 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> 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; + } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs index d9b61aeece72..e0447385ba68 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs @@ -22,12 +22,18 @@ 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(); + // if SynchronizationContext.Current is null, it means we are on the single-threaded runtime + _dispatcher = WebAssemblyDispatcher._mainSynchronizationContext == null + ? NullDispatcher.Instance + : new WebAssemblyDispatcher(); + ElementReferenceContext = DefaultWebAssemblyJSRuntime.Instance.ElementReferenceContext; DefaultWebAssemblyJSRuntime.Instance.OnUpdateRootComponents += OnUpdateRootComponents; } @@ -69,7 +75,7 @@ public static void NotifyEndUpdateRootComponents(long batchId) DefaultWebAssemblyJSRuntime.Instance.InvokeVoid("Blazor._internal.endUpdateRootComponents", batchId); } - public override Dispatcher Dispatcher => NullDispatcher.Instance; + public override Dispatcher Dispatcher => _dispatcher; public Task AddComponentAsync([DynamicallyAccessedMembers(Component)] Type componentType, ParameterView parameters, string domElementSelector) { diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/App.razor b/src/Components/WebAssembly/testassets/ThreadingApp/App.razor new file mode 100644 index 000000000000..d2a25c29da30 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/App.razor @@ -0,0 +1,12 @@ + + + + + + + +

Not found

+ Sorry, there's nothing at this address. +
+
+
diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Counter.razor b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Counter.razor new file mode 100644 index 000000000000..f22b4e11500b --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Counter.razor @@ -0,0 +1,107 @@ +@page "/counter" +@using System.Runtime.InteropServices +@using System.Threading + +

Counter

+ +

Current count: @currentCount

+ + + + +@code { + int currentCount = 0; + System.Threading.Timer timer; + + void IncrementCount() + { + currentCount++; + } + + async Task TestThreads() + { + if (!OperatingSystem.IsBrowser()) + { + return; + } + + try + { + if (Thread.CurrentThread.ManagedThreadId != 1) + { + throw new Exception("We should be on main thread!"); + } + + Exception exc = null; + + // run in the thread pool + await Task.Run(() => + { + try + { + StateHasChanged(); // render should throw + return Task.CompletedTask; + } + catch (Exception ex) + { + Console.WriteLine("After expected fail " + Environment.CurrentManagedThreadId); + exc = ex; + return Task.CompletedTask; + } + }); + + if (exc == null || exc.Message == null || !exc.Message.Contains("The current thread is not associated with the Dispatcher")) + { + throw new Exception("We should have thrown here!"); + } + + // test that we could create new thread + var tcs = new TaskCompletionSource(); + var t = new Thread(() => + { + Console.WriteLine("From new thread " + Environment.CurrentManagedThreadId); + 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!"); + } + + timer = new System.Threading.Timer(async (state) => + { + Console.WriteLine("From timer " + Environment.CurrentManagedThreadId); + + // run in the thread pool + await Task.Run(async () => + { + if (Thread.CurrentThread.ManagedThreadId == 1) + { + throw new Exception("We should be on thread pool thread!"); + } + Console.WriteLine("From thread pool " + Environment.CurrentManagedThreadId); + + // we back to main thread + await InvokeAsync(() => + { + if (Thread.CurrentThread.ManagedThreadId != 1) + { + throw new Exception("We should be on main thread again!"); + } + Console.WriteLine("From UI thread " + Environment.CurrentManagedThreadId); + + // we are back on main thread + IncrementCount(); + StateHasChanged(); // render! + }); + }); + }, null, 100, 0); + } + catch(Exception ex) + { + Console.WriteLine(ex); + throw; + } + } +} diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Pages/FetchData.razor b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/FetchData.razor new file mode 100644 index 000000000000..8ea0b025cbd0 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/FetchData.razor @@ -0,0 +1,89 @@ +@using Microsoft.AspNetCore.Components.WebAssembly.Http; +@page "/fetchdata" +@page "/fetchdata/{StartDate:datetime}" +@inject HttpClient Http + + +

Weather forecast

+ +

This component demonstrates fetching data from the server.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.DateFormatted@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+

+ + ◀ Previous + + + Next ▶ + +

+} + +@code { + [Parameter] public DateTime? StartDate { get; set; } + + WeatherForecast[] forecasts; + DateTime startDate; + + protected override async Task OnParametersSetAsync() + { + startDate = StartDate.GetValueOrDefault(DateTime.Now); + var url = $"sample-data/weather.json?date={startDate.ToString("yyyy-MM-dd")}"; + using var req = new HttpRequestMessage(HttpMethod.Get, url); + req.SetBrowserResponseStreamingEnabled(true); + + // send the request from the UI thread + using var response = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); + + await Task.Run(async () => + { + // finish the processing in the thread pool + forecasts = await response.Content.ReadFromJsonAsync(); + for (var i = 0; i < forecasts.Length; i++) + { + forecasts[i].DateFormatted = startDate.AddDays(i).ToShortDateString(); + } + + await InvokeAsync(() => + { + // get back to UI thread and render + ShouldRender(); + return Task.CompletedTask; + }); + }); + } + + class WeatherForecast + { + public string DateFormatted { get; set; } + public int TemperatureC { get; set; } + public int TemperatureF { get; set; } + public string Summary { get; set; } + } +} diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Index.razor b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Index.razor new file mode 100644 index 000000000000..16dac3192520 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Pages/Index.razor @@ -0,0 +1,5 @@ +@page "/" + +

Hello, world!

+ +Welcome to your new app. diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Program.cs b/src/Components/WebAssembly/testassets/ThreadingApp/Program.cs new file mode 100644 index 000000000000..c5c26cf5262b --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Program.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +namespace ThreadingApp; + +public class Program +{ + public static async Task Main(string[] args) + { + var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.RootComponents.Add("app"); + builder.Services.AddSingleton(new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + + await builder.Build().RunAsync(); + } +} diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Properties/launchSettings.json b/src/Components/WebAssembly/testassets/ThreadingApp/Properties/launchSettings.json new file mode 100644 index 000000000000..32cb71e67a7d --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:56502/", + "sslPort": 44332 + } + }, + "profiles": { + "ThreadingApp": { + "commandName": "Project", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Shared/MainLayout.razor b/src/Components/WebAssembly/testassets/ThreadingApp/Shared/MainLayout.razor new file mode 100644 index 000000000000..0f4e22a9434d --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Shared/MainLayout.razor @@ -0,0 +1,15 @@ +@inherits LayoutComponentBase + + + +
+
+ About +
+ +
+ @Body +
+
diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/Shared/NavMenu.razor b/src/Components/WebAssembly/testassets/ThreadingApp/Shared/NavMenu.razor new file mode 100644 index 000000000000..7b045445a336 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/Shared/NavMenu.razor @@ -0,0 +1,35 @@ + + +
+ +
+ +@code { + bool collapseNavMenu = true; + + void ToggleNavMenu() + { + collapseNavMenu = !collapseNavMenu; + } +} diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/ThreadingApp.csproj b/src/Components/WebAssembly/testassets/ThreadingApp/ThreadingApp.csproj new file mode 100644 index 000000000000..7dce100846f6 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/ThreadingApp.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultNetCoreTargetFramework) + true + + + + + + + + diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/_Imports.razor b/src/Components/WebAssembly/testassets/ThreadingApp/_Imports.razor new file mode 100644 index 000000000000..f941c57b9019 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/_Imports.razor @@ -0,0 +1,6 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ThreadingApp +@using ThreadingApp.Shared diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/index.html b/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/index.html new file mode 100644 index 000000000000..9abb6798fea7 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/index.html @@ -0,0 +1,28 @@ + + + + + + + Blazor standalone + + + + Loading... + +
+ An unhandled exception has occurred. See browser dev tools for details. + Reload + 🗙 +
+ + + + + diff --git a/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/sample-data/weather.json b/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/sample-data/weather.json new file mode 100644 index 000000000000..2f9914fc5d32 --- /dev/null +++ b/src/Components/WebAssembly/testassets/ThreadingApp/wwwroot/sample-data/weather.json @@ -0,0 +1,32 @@ +[ + { + "dateFormatted": "06/05/2018", + "temperatureC": 1, + "summary": "Freezing", + "temperatureF": 33 + }, + { + "dateFormatted": "07/05/2018", + "temperatureC": 14, + "summary": "Bracing", + "temperatureF": 57 + }, + { + "dateFormatted": "08/05/2018", + "temperatureC": -13, + "summary": "Freezing", + "temperatureF": 9 + }, + { + "dateFormatted": "09/05/2018", + "temperatureC": -16, + "summary": "Balmy", + "temperatureF": 4 + }, + { + "dateFormatted": "10/05/2018", + "temperatureC": -2, + "summary": "Chilly", + "temperatureF": 29 + } +] diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/BlazorWasmTestAppFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/BlazorWasmTestAppFixture.cs index f63fb1f03bc3..9b70be5e2a6c 100644 --- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/BlazorWasmTestAppFixture.cs +++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/BlazorWasmTestAppFixture.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.E2ETesting; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -14,9 +15,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; public class BlazorWasmTestAppFixture : WebHostServerFixture { - public readonly bool TestTrimmedApps = typeof(ToggleExecutionModeServerFixture<>).Assembly + public readonly bool TestTrimmedOrMultithreadingApps = typeof(ToggleExecutionModeServerFixture<>).Assembly .GetCustomAttributes() - .First(m => m.Key == "Microsoft.AspNetCore.E2ETesting.TestTrimmedApps") + .First(m => m.Key == "Microsoft.AspNetCore.E2ETesting.TestTrimmedOrMultithreadingApps") .Value == "true"; public string Environment { get; set; } @@ -25,9 +26,9 @@ public class BlazorWasmTestAppFixture : WebHostServerFixture protected override IHost CreateWebHost() { - if (TestTrimmedApps) + if (TestTrimmedOrMultithreadingApps) { - var staticFilePath = Path.Combine(AppContext.BaseDirectory, "trimmed", typeof(TProgram).Assembly.GetName().Name); + var staticFilePath = Path.Combine(AppContext.BaseDirectory, "trimmed-or-threading", typeof(TProgram).Assembly.GetName().Name); if (!Directory.Exists(staticFilePath)) { throw new DirectoryNotFoundException($"Test is configured to use trimmed outputs, but trimmed outputs were not found in {staticFilePath}."); @@ -91,6 +92,14 @@ public void Configure(IApplicationBuilder app) app.UsePathBase(PathBase); } + app.Use(async (ctx, next) => + { + // Browser multi-threaded runtime requires cross-origin policy headers to enable SharedArrayBuffer. + ctx.Response.Headers.Append("Cross-Origin-Embedder-Policy", "require-corp"); + ctx.Response.Headers.Append("Cross-Origin-Opener-Policy", "same-origin"); + await next(ctx); + }); + app.UseStaticFiles(new StaticFileOptions { ServeUnknownFileTypes = true, diff --git a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj index 763b968c63b1..369ba7a9967f 100644 --- a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj +++ b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj @@ -33,8 +33,8 @@ false - true - true + true + true @@ -53,38 +53,44 @@ - + + - + + Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Performance.TestApp\" /> + Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\BasicTestApp\" /> + Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\GlobalizationWasmApp\;" /> + Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\StandaloneApp\;" /> + Properties="BuildProjectReferences=false;TestTrimmedOrMultithreadingApps=true;PublishDir=$(MSBuildThisFileDirectory)$(OutputPath)trimmed-or-threading\Wasm.Prerendered.Server\;" /> + + @@ -105,8 +111,8 @@ - <_Parameter1>Microsoft.AspNetCore.E2ETesting.TestTrimmedApps - <_Parameter2>$(TestTrimmedApps) + <_Parameter1>Microsoft.AspNetCore.E2ETesting.TestTrimmedOrMultithreadingApps + <_Parameter2>$(TestTrimmedOrMultithreadingApps) diff --git a/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs b/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs index e3d18e0b05a3..0684189419a3 100644 --- a/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs +++ b/src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs @@ -27,6 +27,9 @@ public void CanDispatchAsyncWorkToSyncContext() appElement.FindElement(By.Id("run-async-with-dispatch")).Click(); + // 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 Browser.Equal("First Second Third Fourth Fifth", () => result.Text); } } diff --git a/src/Components/test/E2ETest/Tests/ThreadingAppTest.cs b/src/Components/test/E2ETest/Tests/ThreadingAppTest.cs new file mode 100644 index 000000000000..f91e3afed2de --- /dev/null +++ b/src/Components/test/E2ETest/Tests/ThreadingAppTest.cs @@ -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. + +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class ThreadingAppTest + : ServerTestBase>, IDisposable +{ + public ThreadingAppTest( + BrowserFixture browserFixture, + BlazorWasmTestAppFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate("/", noReload: true); + WaitUntilLoaded(); + } + + [Fact] + public void HasTitle() + { + Assert.Equal("Blazor standalone", Browser.Title); + } + + [Fact] + public void HasHeading() + { + Assert.Equal("Hello, world!", Browser.Exists(By.TagName("h1")).Text); + } + + [Fact] + public void NavMenuHighlightsCurrentLocation() + { + var activeNavLinksSelector = By.CssSelector(".sidebar a.active"); + var mainHeaderSelector = By.TagName("h1"); + + // Verify we start at home, with the home link highlighted + Assert.Equal("Hello, world!", Browser.Exists(mainHeaderSelector).Text); + Assert.Collection(Browser.FindElements(activeNavLinksSelector), + item => Assert.Equal("Home", item.Text.Trim())); + + // Click on the "counter" link + Browser.Exists(By.LinkText("Counter")).Click(); + + // Verify we're now on the counter page, with that nav link (only) highlighted + Assert.Equal("Counter", Browser.Exists(mainHeaderSelector).Text); + Assert.Collection(Browser.FindElements(activeNavLinksSelector), + item => Assert.Equal("Counter", item.Text.Trim())); + + // Verify we can navigate back to home too + Browser.Exists(By.LinkText("Home")).Click(); + Assert.Equal("Hello, world!", Browser.Exists(mainHeaderSelector).Text); + Assert.Collection(Browser.FindElements(activeNavLinksSelector), + item => Assert.Equal("Home", item.Text.Trim())); + } + + [Fact] + public void CounterPageCanUseThreads() + { + // Navigate to "Counter" + Browser.Exists(By.LinkText("Counter")).Click(); + Assert.Equal("Counter", Browser.Exists(By.TagName("h1")).Text); + + var countDisplayElement = Browser.Exists(By.CssSelector("h1 + p")); + // see that initial state is zero + Browser.Equal("Current count: 0", () => countDisplayElement.Text); + + // start the test + var testThreadsButton = Browser.Exists(By.Id("TestThreads")); + testThreadsButton.Click(); + + // wait and see timer increase + Browser.NotEqual("Current count: 0", () => countDisplayElement.Text); + } + + [Fact] + public void HasFetchDataPage() + { + // Navigate to "Fetch data" + Browser.Exists(By.LinkText("Fetch data")).Click(); + Assert.Equal("Weather forecast", Browser.Exists(By.TagName("h1")).Text); + + // Wait until loaded + var tableSelector = By.CssSelector("table.table"); + Browser.Exists(tableSelector); + + // Check the table is displayed correctly + var rows = Browser.FindElements(By.CssSelector("table.table tbody tr")); + Assert.Equal(5, rows.Count); + var cells = rows.SelectMany(row => row.FindElements(By.TagName("td"))); + foreach (var cell in cells) + { + Assert.True(!string.IsNullOrEmpty(cell.Text)); + } + } + + [Fact] + public void IsStarted() + { + // Read from property + var jsExecutor = (IJavaScriptExecutor)Browser; + + var isStarted = jsExecutor.ExecuteScript("return window['__aspnetcore__testing__blazor_wasm__started__'];"); + if (isStarted is null) + { + throw new InvalidOperationException("Blazor wasm started value not set"); + } + + // Confirm server has started + Assert.True((bool)isStarted); + } + + private void WaitUntilLoaded() + { + var app = Browser.Exists(By.TagName("app")); + Browser.NotEqual("Loading...", () => app.Text); + } + + public void Dispose() + { + // Make the tests run faster by navigating back to the home page when we are done + // If we don't, then the next test will reload the whole page before it starts + Browser.Exists(By.LinkText("Home")).Click(); + } +} diff --git a/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs b/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs index 34472459b879..a3888b9e9778 100644 --- a/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs +++ b/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs @@ -37,7 +37,7 @@ public void WebAssemblyConfiguration_Works() // Verify values from the default 'appsettings.json' are read. Browser.Equal("Default key1-value", () => _appElement.FindElement(By.Id("key1")).Text); - if (_serverFixture.TestTrimmedApps) + if (_serverFixture.TestTrimmedOrMultithreadingApps) { // Verify values overriden by an environment specific 'appsettings.$(Environment).json are read Assert.Equal("Prod key2-value", _appElement.FindElement(By.Id("key2")).Text); diff --git a/src/Components/test/E2ETest/Tests/WebAssemblyPrerenderedTest.cs b/src/Components/test/E2ETest/Tests/WebAssemblyPrerenderedTest.cs index 9007e18ab483..189bb71719a1 100644 --- a/src/Components/test/E2ETest/Tests/WebAssemblyPrerenderedTest.cs +++ b/src/Components/test/E2ETest/Tests/WebAssemblyPrerenderedTest.cs @@ -23,7 +23,7 @@ public WebAssemblyPrerenderedTest( var testTrimmedApps = typeof(ToggleExecutionModeServerFixture<>).Assembly .GetCustomAttributes() - .First(m => m.Key == "Microsoft.AspNetCore.E2ETesting.TestTrimmedApps") + .First(m => m.Key == "Microsoft.AspNetCore.E2ETesting.TestTrimmedOrMultithreadingApps") .Value == "true"; if (testTrimmedApps) diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index 9692df654bd9..a336a1090ced 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -13,7 +13,7 @@ true - + <_BlazorBrotliCompressionLevel>NoCompression @@ -46,7 +46,7 @@ - + diff --git a/src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj b/src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj index d1749b5218cc..ffcaad706f56 100644 --- a/src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj +++ b/src/Components/test/testassets/GlobalizationWasmApp/GlobalizationWasmApp.csproj @@ -9,7 +9,7 @@ false - + <_BlazorBrotliCompressionLevel>NoCompression