From 9d8f5c3dac88e7912b2eb9b12a81518d96c8ecea Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson <6995051+javiercn@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:56:50 +0200 Subject: [PATCH 1/3] Harden renderer synchronization context restoration --- .../RendererSynchronizationContext.cs | 14 ++- .../RendererSynchronizationContextTest.cs | 37 +++++++ .../ServerComponentRenderingTest.cs | 13 +++ .../BasicTestApp/DispatchingComponent.razor | 97 +++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs b/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs index d5f7ecf01a0b..ef6b3cadb184 100644 --- a/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs +++ b/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs @@ -229,7 +229,7 @@ private void SendIfQuiescedOrElsePost(Action callback, TState st /// /// Sets the current synchronization context to this instance, invokes the , - /// resets the synchronization context, and sets marks the builder as completed. + /// marks the builder as completed, and resets the synchronization context. /// private void InvokeWithThisAsCurrentSyncCtxThenSetResult( AsyncTaskMethodBuilder completion, @@ -244,8 +244,16 @@ private void InvokeWithThisAsCurrentSyncCtxThenSetResult( } finally { - SetSynchronizationContext(original); - completion.SetResult(); + try + { + // Complete the queue marker while this context is still current so that queued + // continuations are not inlined onto the caller's thread. + completion.SetResult(); + } + finally + { + SetSynchronizationContext(original); + } } } diff --git a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs index 5b0983369a06..290e4a30368c 100644 --- a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs +++ b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs @@ -790,4 +790,41 @@ public async Task InvokeAsync_SyncWorkInAsyncTaskIsCompletedFirst() // Assert Assert.Equal(expected, actual); } + + [Fact] + public void InvokeAsync_FuncTask_RestoresContextWhenQueuedWorkDoesNotFlowExecutionContext() + { + var context = new RendererSynchronizationContext(); + var contextEntered = new ManualResetEventSlim(); + var releaseContext = new ManualResetEventSlim(); + var queuedWorkDone = new ManualResetEventSlim(); + var callerDone = new ManualResetEventSlim(); + SynchronizationContext original = null; + SynchronizationContext actual = null; + + ThreadPool.UnsafeQueueUserWorkItem(_ => + { + original = SynchronizationContext.Current; + _ = context.InvokeAsync(async () => + { + contextEntered.Set(); + Assert.True(releaseContext.Wait(Timeout), "timeout"); + await Task.CompletedTask; + }); + + Assert.True(queuedWorkDone.Wait(Timeout), "timeout"); + actual = SynchronizationContext.Current; + callerDone.Set(); + }, null); + + Assert.True(contextEntered.Wait(Timeout), "timeout"); + using (ExecutionContext.SuppressFlow()) + { + _ = context.InvokeAsync(queuedWorkDone.Set); + } + + releaseContext.Set(); + Assert.True(callerDone.Wait(Timeout), "timeout"); + Assert.Same(original, actual); + } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerComponentRenderingTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerComponentRenderingTest.cs index 013296748744..ea9db9cbc99c 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerComponentRenderingTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerComponentRenderingTest.cs @@ -32,4 +32,17 @@ public void ThrowsIfRenderIsRequestedOutsideSyncContext() $"{typeof(InvalidOperationException).FullName}: The current thread is not associated with the Dispatcher. Use InvokeAsync() to switch execution to the Dispatcher when triggering rendering or component state.", () => result.Text); } + + [Fact] + public void RestoresContextWhenQueuedWorkDoesNotFlowExecutionContext() + { + var appElement = Browser.MountTestComponent(); + var result = appElement.FindElement(By.Id("suppressed-execution-context-result")); + + appElement.FindElement(By.Id("run-with-suppressed-execution-context")).Click(); + + Browser.Equal( + "Context leaked: False; Dispatcher overlapped: False", + () => result.Text); + } } diff --git a/src/Components/test/testassets/BasicTestApp/DispatchingComponent.razor b/src/Components/test/testassets/BasicTestApp/DispatchingComponent.razor index af48d814b6b8..7aa2116c972d 100644 --- a/src/Components/test/testassets/BasicTestApp/DispatchingComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/DispatchingComponent.razor @@ -14,9 +14,17 @@ + + +

@suppressedExecutionContextResult

@code { + static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + string result; + string suppressedExecutionContextResult; async Task RunWithoutDispatch() { @@ -67,6 +75,95 @@ result += " Fifth"; } + void RunWithSuppressedExecutionContext() + { + suppressedExecutionContextResult = "Running"; + + ThreadPool.UnsafeQueueUserWorkItem(_ => + { + try + { + InvokeAsync(() => { }).GetAwaiter().GetResult(); + var (contextLeaked, dispatcherOverlapped) = RunSuppressedExecutionContextScenario(); + suppressedExecutionContextResult = + $"Context leaked: {contextLeaked}; Dispatcher overlapped: {dispatcherOverlapped}"; + } + catch (Exception exception) + { + suppressedExecutionContextResult = exception.ToString(); + } + finally + { + _ = InvokeAsync(StateHasChanged); + } + }, null); + } + + (bool ContextLeaked, bool DispatcherOverlapped) RunSuppressedExecutionContextScenario() + { + using var rendererEntered = new ManualResetEventSlim(); + using var releaseRenderer = new ManualResetEventSlim(); + using var leakChecked = new ManualResetEventSlim(); + using var dispatcherOccupied = new ManualResetEventSlim(); + using var releaseDispatcher = new ManualResetEventSlim(); + using var holderDone = new ManualResetEventSlim(); + var contextLeaked = false; + var dispatcherOverlapped = false; + + ThreadPool.UnsafeQueueUserWorkItem(_ => + { + var originalContext = SynchronizationContext.Current; + InvokeAsync(async () => + { + rendererEntered.Set(); + Wait(releaseRenderer, nameof(releaseRenderer)); + await Task.CompletedTask; + }).GetAwaiter().GetResult(); + + contextLeaked = !ReferenceEquals(SynchronizationContext.Current, originalContext); + leakChecked.Set(); + + Wait(dispatcherOccupied, nameof(dispatcherOccupied)); + var componentUpdate = InvokeAsync(() => + { + dispatcherOverlapped = dispatcherOccupied.IsSet && !releaseDispatcher.IsSet; + StateHasChanged(); + }); + + releaseDispatcher.Set(); + componentUpdate.GetAwaiter().GetResult(); + holderDone.Set(); + }, null); + + Wait(rendererEntered, nameof(rendererEntered)); + Task queuedNotification; + using (ExecutionContext.SuppressFlow()) + { + queuedNotification = InvokeAsync(() => { }); + } + + releaseRenderer.Set(); + Wait(leakChecked, nameof(leakChecked)); + + InvokeAsync(() => + { + dispatcherOccupied.Set(); + Wait(releaseDispatcher, nameof(releaseDispatcher)); + }).GetAwaiter().GetResult(); + + Wait(holderDone, nameof(holderDone)); + queuedNotification.GetAwaiter().GetResult(); + return (contextLeaked, dispatcherOverlapped); + } + + static void Wait(ManualResetEventSlim signal, string name) + { + if (!signal.Wait(Timeout)) + { + throw new TimeoutException($"Timed out waiting for {name}."); + } + } + void AttemptToRender() { try From 95dbb582da4395625c5dcb6f84e36bcaee5078d1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson <6995051+javiercn@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:28:51 +0200 Subject: [PATCH 2/3] Simplify synchronization context restoration --- .../Rendering/RendererSynchronizationContext.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs b/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs index ef6b3cadb184..262dabf24398 100644 --- a/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs +++ b/src/Components/Components/src/Rendering/RendererSynchronizationContext.cs @@ -244,16 +244,10 @@ private void InvokeWithThisAsCurrentSyncCtxThenSetResult( } finally { - try - { - // Complete the queue marker while this context is still current so that queued - // continuations are not inlined onto the caller's thread. - completion.SetResult(); - } - finally - { - SetSynchronizationContext(original); - } + // Complete the queue marker while this context is still current so that queued + // continuations are not inlined onto the caller's thread. + completion.SetResult(); + SetSynchronizationContext(original); } } From 17cd294c3e7a05261fe8813b2209369734aa7d82 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson <6995051+javiercn@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:31:47 +0200 Subject: [PATCH 3/3] Assert dispatcher ordering instead of thread identity --- .../test/Rendering/RendererSynchronizationContextTest.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs index 290e4a30368c..c2692e31f69e 100644 --- a/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs +++ b/src/Components/Components/test/Rendering/RendererSynchronizationContextTest.cs @@ -694,7 +694,6 @@ public async Task InvokeAsync_FuncTaskT_CanRunAsynchronously_WhenBusy() { // Arrange var context = new RendererSynchronizationContext(); - var thread = Thread.CurrentThread; var e1 = new ManualResetEventSlim(); var e2 = new ManualResetEventSlim(); @@ -719,12 +718,12 @@ public async Task InvokeAsync_FuncTaskT_CanRunAsynchronously_WhenBusy() }); // Assert - Assert.False(e2.IsSet); + Assert.False(e3.IsSet); e2.Set(); // Unblock the first item await task1; - Assert.True(e3.Wait(Timeout), "timeout"); - Assert.NotSame(thread, await task2); + await task2; + Assert.True(e3.IsSet); } [Fact]