From e49b73bc6ecf59611d8175045804182b1a5c9469 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 15:55:47 -0600 Subject: [PATCH 01/19] Coalesce main thread dispatcher posts Queue pending main-thread callbacks per factory so only one underlying synchronization-context message is outstanding, while pruning callbacks already executed through another avenue. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 83 +++++++++++++++++- .../JoinableTaskFactoryTests.cs | 85 +++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index b6bc0acfd..560fceda9 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -23,11 +23,15 @@ namespace Microsoft.VisualStudio.Threading; /// public partial class JoinableTaskFactory { + private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((JoinableTaskFactory)state!).ExecuteOnePendingUnderlyingSynchronizationContextCallback(); + /// /// The that owns this instance. /// private readonly JoinableTaskContext owner; + private readonly object pendingUnderlyingSynchronizationContextCallbacksLock = new(); + private readonly SynchronizationContext? mainThreadJobSyncContext; /// @@ -35,6 +39,10 @@ public partial class JoinableTaskFactory /// private readonly JoinableTaskCollection? jobCollection; + private Queue? pendingUnderlyingSynchronizationContextCallbacks; + + private bool underlyingSynchronizationContextCallbackPending; + /// /// Backing field for the property. /// @@ -414,7 +422,20 @@ internal void PostToUnderlyingSynchronizationContextOrThreadPool(SingleExecutePr if (this.UnderlyingSynchronizationContext is object) { - this.PostToUnderlyingSynchronizationContext(SingleExecuteProtector.ExecuteOnce, callback); + bool postCallback; + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue(); + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue(callback); + postCallback = !this.underlyingSynchronizationContextCallbackPending; + this.underlyingSynchronizationContextCallbackPending = true; + } + + if (postCallback) + { + this.PostPendingUnderlyingSynchronizationContextCallback(); + } } else { @@ -659,6 +680,66 @@ private static void VerifyNoNonConcurrentSyncContext() } } + private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() + { + SingleExecuteProtector? callback = null; + try + { + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + callback = this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); + } + } + + callback?.TryExecute(); + } + finally + { + bool postAnotherCallback; + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + postAnotherCallback = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0; + this.underlyingSynchronizationContextCallbackPending = postAnotherCallback; + } + + if (postAnotherCallback) + { + this.PostPendingUnderlyingSynchronizationContextCallback(); + } + } + } + + private void PostPendingUnderlyingSynchronizationContextCallback() + { + try + { + this.PostToUnderlyingSynchronizationContext(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, this); + } + catch + { + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + this.underlyingSynchronizationContextCallbackPending = false; + } + + throw; + } + } + + private void RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks() + { + Assumes.True(Monitor.IsEntered(this.pendingUnderlyingSynchronizationContextCallbacksLock)); + + while (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0 && this.pendingUnderlyingSynchronizationContextCallbacks.Peek().HasBeenExecuted) + { + this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); + } + } + /// /// Wraps the invocation of an async method such that it may /// execute asynchronously, but may potentially be diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index b7b8832e0..2ce72439d 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Collections.Concurrent; +using System.Threading; using System.Threading.Tasks; public class JoinableTaskFactoryTests : JoinableTaskTestBase @@ -138,6 +140,48 @@ public void SwitchToMainThreadAlwaysYield() }); } + [Fact] + public void PostsToUnderlyingSynchronizationContextConservatively() + { + var factory = new QueueingJoinableTaskFactory(this.context); + int executionCount = 0; + + (JoinableTask _, Task firstQueued) = factory.QueueCallback(() => executionCount++); + (JoinableTask _, Task secondQueued) = factory.QueueCallback(() => executionCount++); + (JoinableTask _, Task thirdQueued) = factory.QueueCallback(() => executionCount++); + Task.WhenAll(firstQueued, secondQueued, thirdQueued).GetAwaiter().GetResult(); + + Assert.Single(factory.PostedCallbacks); + factory.ExecutePostedCallback(); + Assert.Equal(1, executionCount); + Assert.Single(factory.PostedCallbacks); + factory.ExecutePostedCallback(); + Assert.Equal(2, executionCount); + Assert.Single(factory.PostedCallbacks); + factory.ExecutePostedCallback(); + Assert.Equal(3, executionCount); + Assert.Empty(factory.PostedCallbacks); + } + + [Fact] + public void CompletedCallbacksAreRemovedBeforePostingAnotherMessage() + { + var factory = new QueueingJoinableTaskFactory(this.context); + int executionCount = 0; + (JoinableTask first, Task firstQueued) = factory.QueueCallback(() => executionCount++); + (JoinableTask second, Task secondQueued) = factory.QueueCallback(() => executionCount++); + (JoinableTask _, Task thirdQueued) = factory.QueueCallback(() => executionCount++); + Task.WhenAll(firstQueued, secondQueued, thirdQueued).GetAwaiter().GetResult(); + + first.Join(); + second.Join(); + + Assert.Single(factory.PostedCallbacks); + factory.ExecutePostedCallback(); + Assert.Equal(3, executionCount); + Assert.Empty(factory.PostedCallbacks); + } + [Fact] public void DisableProcessing_ThrowsOutsideJoinableTask() { @@ -269,4 +313,45 @@ protected override void OnTransitionedToMainThread(JoinableTask joinableTask, bo this.OnTransitionedToMainThreadCallback?.Invoke(joinableTask, canceled); } } + + private class QueueingJoinableTaskFactory : JoinableTaskFactory + { + internal QueueingJoinableTaskFactory(JoinableTaskContext owner) + : base(owner) + { + } + + internal ConcurrentQueue<(SendOrPostCallback Callback, object State)> PostedCallbacks { get; } = new(); + + internal void ExecutePostedCallback() + { + Assert.True(this.PostedCallbacks.TryDequeue(out (SendOrPostCallback Callback, object State) work)); + (SendOrPostCallback callback, object state) = work; + callback(state); + } + + internal (JoinableTask Job, Task Queued) QueueCallback(Action callback) + { + var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + JoinableTask job = this.RunAsync(async delegate + { + await TaskScheduler.Default.SwitchTo(alwaysYield: true); + var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(delegate + { + callback(); + callbackCompleted.SetResult(null); + }); + queued.SetResult(null); + await callbackCompleted.Task; + }); + + return (job, queued.Task); + } + + protected override void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) + { + this.PostedCallbacks.Enqueue((callback, state)); + } + } } From 7ad2b4dde7a81f017bd4366923aa4be598f626e1 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 16:06:42 -0600 Subject: [PATCH 02/19] Trim pending callbacks after post failure Release completed callbacks and the empty private queue when the underlying synchronization context rejects a post. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 560fceda9..56b5e9263 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -723,6 +723,12 @@ private void PostPendingUnderlyingSynchronizationContextCallback() { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count == 0) + { + this.pendingUnderlyingSynchronizationContextCallbacks = null; + } + this.underlyingSynchronizationContextCallbackPending = false; } From 45760a5ba6ae37425b6484b64e403c700e6eecf7 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 16:13:41 -0600 Subject: [PATCH 03/19] Release drained callback queues Drop the private queue after each drained burst and make the test callback harness propagate exceptions instead of hanging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 5 +++++ .../JoinableTaskFactoryTests.cs | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 56b5e9263..9d5afc0cc 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -703,6 +703,11 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); postAnotherCallback = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0; + if (!postAnotherCallback) + { + this.pendingUnderlyingSynchronizationContextCallbacks = null; + } + this.underlyingSynchronizationContextCallbackPending = postAnotherCallback; } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 2ce72439d..dc7da5b0f 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -339,8 +339,15 @@ internal void ExecutePostedCallback() var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(delegate { - callback(); - callbackCompleted.SetResult(null); + try + { + callback(); + callbackCompleted.SetResult(null); + } + catch (Exception ex) + { + callbackCompleted.SetException(ex); + } }); queued.SetResult(null); await callbackCompleted.Task; From ee10431bb2119bec585210aa96462bdac889e27f Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 16:22:19 -0600 Subject: [PATCH 04/19] Abandon private queue after post failure Clear the secondary dispatch queue when no underlying message can be established, preventing concurrent enqueuers from leaving an undriven queue. Each callback remains available through its owning joinable task queue. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 9d5afc0cc..05b556fed 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -728,12 +728,9 @@ private void PostPendingUnderlyingSynchronizationContextCallback() { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count == 0) - { - this.pendingUnderlyingSynchronizationContextCallbacks = null; - } - + // Every callback remains in its owning JoinableTask's execution queue, so abandon this + // secondary route when no underlying message was established. + this.pendingUnderlyingSynchronizationContextCallbacks = null; this.underlyingSynchronizationContextCallbackPending = false; } From f8d870c00ad385be49d4b2f0203cd78ea9649cbd Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 19:17:00 -0600 Subject: [PATCH 05/19] Make dispatcher post coalescing opt-in Keep existing JoinableTaskFactory overrides on their prior direct-post path while the base and WPF dispatcher implementations explicitly opt into the private coalescing queue. Add coverage for no-op derived factories. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DispatcherExtensions.cs | 6 ++ .../JoinableTaskFactory.cs | 72 ++++++++++++++----- .../JoinableTaskFactoryTests.cs | 45 +++++++++++- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs b/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs index 1b93c4bef..977f91cda 100644 --- a/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs +++ b/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs @@ -76,6 +76,12 @@ internal DispatcherJoinableTaskFactory(JoinableTaskFactory innerFactory, Dispatc /// protected internal override void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) + { + this.PostToUnderlyingSynchronizationContextWithCoalescing(callback, state); + } + + /// + protected internal override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { this.dispatcher.BeginInvoke(this.priority, callback, state); } diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 05b556fed..fa51c798f 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -39,7 +39,7 @@ public partial class JoinableTaskFactory /// private readonly JoinableTaskCollection? jobCollection; - private Queue? pendingUnderlyingSynchronizationContextCallbacks; + private Queue<(SendOrPostCallback Callback, object State)>? pendingUnderlyingSynchronizationContextCallbacks; private bool underlyingSynchronizationContextCallbackPending; @@ -422,20 +422,7 @@ internal void PostToUnderlyingSynchronizationContextOrThreadPool(SingleExecutePr if (this.UnderlyingSynchronizationContext is object) { - bool postCallback; - lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) - { - this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue(); - this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue(callback); - postCallback = !this.underlyingSynchronizationContextCallbackPending; - this.underlyingSynchronizationContextCallbackPending = true; - } - - if (postCallback) - { - this.PostPendingUnderlyingSynchronizationContextCallback(); - } + this.PostToUnderlyingSynchronizationContext(SingleExecuteProtector.ExecuteOnce, callback); } else { @@ -491,6 +478,19 @@ protected internal virtual void PostToUnderlyingSynchronizationContext(SendOrPos Requires.NotNull(callback, nameof(callback)); Assumes.NotNull(this.UnderlyingSynchronizationContext); + this.PostToUnderlyingSynchronizationContextWithCoalescing(callback, state); + } + + /// + /// Posts a message directly to the underlying synchronization context. + /// + /// The callback to invoke. + /// State to pass to the callback. + protected internal virtual void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) + { + Requires.NotNull(callback, nameof(callback)); + Assumes.NotNull(this.UnderlyingSynchronizationContext); + this.UnderlyingSynchronizationContext.Post(callback, state); } @@ -655,6 +655,34 @@ protected void Add(JoinableTask joinable) } } + /// + /// Posts a message to the underlying synchronization context while coalescing pending messages. + /// + /// The callback to invoke. + /// + /// State to pass to the callback. Implementing allows + /// the callback to be removed from the private queue when it has already executed by another means. + /// + protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCallback callback, object state) + { + Requires.NotNull(callback, nameof(callback)); + + bool postCallback; + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object)>(); + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue((callback, state)); + postCallback = !this.underlyingSynchronizationContextCallbackPending; + this.underlyingSynchronizationContextCallbackPending = true; + } + + if (postCallback) + { + this.PostPendingUnderlyingSynchronizationContextCallback(); + } + } + /// /// Throws an exception if an active AsyncReaderWriterLock /// upgradeable read or write lock is held by the caller. @@ -682,7 +710,7 @@ private static void VerifyNoNonConcurrentSyncContext() private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { - SingleExecuteProtector? callback = null; + (SendOrPostCallback Callback, object State)? callback = null; try { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) @@ -694,7 +722,10 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } } - callback?.TryExecute(); + if (callback is { } work) + { + work.Callback(work.State); + } } finally { @@ -722,7 +753,7 @@ private void PostPendingUnderlyingSynchronizationContextCallback() { try { - this.PostToUnderlyingSynchronizationContext(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, this); + this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, this); } catch { @@ -742,7 +773,10 @@ private void RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks() { Assumes.True(Monitor.IsEntered(this.pendingUnderlyingSynchronizationContextCallbacksLock)); - while (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0 && this.pendingUnderlyingSynchronizationContextCallbacks.Peek().HasBeenExecuted) +#pragma warning disable VSOnly // IPendingExecutionRequestState is intended for evaluation purposes only. + while (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0 + && this.pendingUnderlyingSynchronizationContextCallbacks.Peek().State is IPendingExecutionRequestState { IsCompleted: true }) +#pragma warning restore VSOnly { this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index dc7da5b0f..cb486f876 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -182,6 +182,19 @@ public void CompletedCallbacksAreRemovedBeforePostingAnotherMessage() Assert.Empty(factory.PostedCallbacks); } + [Fact] + public void DerivedFactoryDoesNotCoalesceUnlessOptedIn() + { + var factory = new NonCoalescingJoinableTaskFactory(this.context); + + (JoinableTask _, Task firstQueued) = factory.QueueCallback(); + (JoinableTask _, Task secondQueued) = factory.QueueCallback(); + Task.WhenAll(firstQueued, secondQueued).GetAwaiter().GetResult(); + + Assert.True(SpinWait.SpinUntil(() => factory.PostCount == 2, UnexpectedTimeout)); + Assert.Equal(2, factory.PostCount); + } + [Fact] public void DisableProcessing_ThrowsOutsideJoinableTask() { @@ -356,9 +369,39 @@ internal void ExecutePostedCallback() return (job, queued.Task); } - protected override void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) + protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { this.PostedCallbacks.Enqueue((callback, state)); } } + + private class NonCoalescingJoinableTaskFactory : JoinableTaskFactory + { + private int postCount; + + internal NonCoalescingJoinableTaskFactory(JoinableTaskContext owner) + : base(owner) + { + } + + internal int PostCount => Volatile.Read(ref this.postCount); + + internal (JoinableTask Job, Task Queued) QueueCallback() + { + var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + JoinableTask job = this.RunAsync(async delegate + { + await TaskScheduler.Default.SwitchTo(alwaysYield: true); + this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(() => { }); + queued.SetResult(null); + }); + + return (job, queued.Task); + } + + protected override void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) + { + Interlocked.Increment(ref this.postCount); + } + } } From 248bf8dc185f099959d0017f02315aafe4a5f568 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 19:27:29 -0600 Subject: [PATCH 06/19] Release factory from completed driver posts Use a one-shot state holder for lower-level driver messages so synchronization contexts that retain processed messages do not keep the JoinableTaskFactory alive. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index fa51c798f..27d345495 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -23,7 +23,7 @@ namespace Microsoft.VisualStudio.Threading; /// public partial class JoinableTaskFactory { - private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((JoinableTaskFactory)state!).ExecuteOnePendingUnderlyingSynchronizationContextCallback(); + private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((UnderlyingSynchronizationContextCallback)state!).Execute(); /// /// The that owns this instance. @@ -753,7 +753,7 @@ private void PostPendingUnderlyingSynchronizationContextCallback() { try { - this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, this); + this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); } catch { @@ -1533,5 +1533,21 @@ private void OnExecuting() this.job.Factory.OnTransitionedToMainThread(this.job, !this.job.Factory.Context.IsOnMainThread); } } + + } + + private sealed class UnderlyingSynchronizationContextCallback + { + private JoinableTaskFactory? factory; + + internal UnderlyingSynchronizationContextCallback(JoinableTaskFactory factory) + { + this.factory = factory; + } + + internal void Execute() + { + Interlocked.Exchange(ref this.factory, null)?.ExecuteOnePendingUnderlyingSynchronizationContextCallback(); + } } } From f2362d6a406d72a8482fee11a90a009fb861a4be Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 19:27:51 -0600 Subject: [PATCH 07/19] Fix nested type formatting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 27d345495..5323bcfbf 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -1533,7 +1533,6 @@ private void OnExecuting() this.job.Factory.OnTransitionedToMainThread(this.job, !this.job.Factory.Context.IsOnMainThread); } } - } private sealed class UnderlyingSynchronizationContextCallback From c2419db9cc2d482742ae38ba2229d39ea8c0f56b Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 19:44:58 -0600 Subject: [PATCH 08/19] Share underlying post failures Make concurrent enqueuers observe the in-flight lower-level post result while avoiding self-deadlock for synchronization contexts that invoke Post callbacks inline. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 82 ++++++++++++++----- .../JoinableTaskFactoryTests.cs | 33 +++++++- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 5323bcfbf..7b0d9bbaf 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -25,6 +25,9 @@ public partial class JoinableTaskFactory { private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((UnderlyingSynchronizationContextCallback)state!).Execute(); + [ThreadStatic] + private static JoinableTaskFactory? synchronouslyPostingFactory; + /// /// The that owns this instance. /// @@ -43,6 +46,8 @@ public partial class JoinableTaskFactory private bool underlyingSynchronizationContextCallbackPending; + private TaskCompletionSource? underlyingSynchronizationContextPostCompletion; + /// /// Backing field for the property. /// @@ -667,19 +672,31 @@ protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCa { Requires.NotNull(callback, nameof(callback)); - bool postCallback; + TaskCompletionSource? postCompletion = null; + Task? waitForPost = null; lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object)>(); this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue((callback, state)); - postCallback = !this.underlyingSynchronizationContextCallbackPending; - this.underlyingSynchronizationContextCallbackPending = true; + if (!this.underlyingSynchronizationContextCallbackPending) + { + this.underlyingSynchronizationContextCallbackPending = true; + postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + else + { + waitForPost = this.underlyingSynchronizationContextPostCompletion?.Task; + } } - if (postCallback) + if (postCompletion is object) { - this.PostPendingUnderlyingSynchronizationContextCallback(); + this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); + } + else if (synchronouslyPostingFactory != this) + { + waitForPost?.GetAwaiter().GetResult(); } } @@ -729,42 +746,69 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } finally { - bool postAnotherCallback; + TaskCompletionSource? postCompletion = null; lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - postAnotherCallback = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0; - if (!postAnotherCallback) + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + else { this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; } - - this.underlyingSynchronizationContextCallbackPending = postAnotherCallback; } - if (postAnotherCallback) + if (postCompletion is object) { - this.PostPendingUnderlyingSynchronizationContextCallback(); + this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); } } } - private void PostPendingUnderlyingSynchronizationContextCallback() + private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionSource postCompletion) { try { - this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); + JoinableTaskFactory? priorSynchronouslyPostingFactory = synchronouslyPostingFactory; + synchronouslyPostingFactory = this; + try + { + this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); + } + finally + { + synchronouslyPostingFactory = priorSynchronouslyPostingFactory; + } + + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + { + if (this.underlyingSynchronizationContextPostCompletion == postCompletion) + { + this.underlyingSynchronizationContextPostCompletion = null; + } + } + + postCompletion.SetResult(null); } - catch + catch (Exception ex) { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - // Every callback remains in its owning JoinableTask's execution queue, so abandon this - // secondary route when no underlying message was established. - this.pendingUnderlyingSynchronizationContextCallbacks = null; - this.underlyingSynchronizationContextCallbackPending = false; + if (this.underlyingSynchronizationContextPostCompletion == postCompletion) + { + // Every callback remains in its owning JoinableTask's execution queue, so abandon this + // secondary route when no underlying message was established. + this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; + this.underlyingSynchronizationContextPostCompletion = null; + } } + postCompletion.SetException(ex); + _ = postCompletion.Task.Exception; throw; } } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index cb486f876..db57f4a53 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -195,6 +195,17 @@ public void DerivedFactoryDoesNotCoalesceUnlessOptedIn() Assert.Equal(2, factory.PostCount); } + [Fact] + public void CoalescingSupportsSynchronousUnderlyingPost() + { + var factory = new SynchronouslyPostingJoinableTaskFactory(this.context); + int executionCount = 0; + + factory.Post(() => factory.Post(() => executionCount++)); + + Assert.Equal(1, executionCount); + } + [Fact] public void DisableProcessing_ThrowsOutsideJoinableTask() { @@ -350,10 +361,12 @@ internal void ExecutePostedCallback() { await TaskScheduler.Default.SwitchTo(alwaysYield: true); var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - this.SwitchToMainThreadAsync().GetAwaiter().OnCompleted(delegate + JoinableTaskFactory.MainThreadAwaiter awaiter = this.SwitchToMainThreadAsync().GetAwaiter(); + awaiter.OnCompleted(delegate { try { + awaiter.GetResult(); callback(); callbackCompleted.SetResult(null); } @@ -404,4 +417,22 @@ protected override void PostToUnderlyingSynchronizationContext(SendOrPostCallbac Interlocked.Increment(ref this.postCount); } } + + private class SynchronouslyPostingJoinableTaskFactory : JoinableTaskFactory + { + internal SynchronouslyPostingJoinableTaskFactory(JoinableTaskContext owner) + : base(owner) + { + } + + internal void Post(Action callback) + { + this.PostToUnderlyingSynchronizationContextWithCoalescing(state => ((Action)state!).Invoke(), callback); + } + + protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) + { + callback(state); + } + } } From 75992938913a6b0be2ca32ed8785f799da1fddd4 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 19 Aug 2026 19:52:48 -0600 Subject: [PATCH 09/19] Drain synchronous posts iteratively Avoid recursive reposting when a synchronization context executes Post callbacks inline, and verify a burst remains at a single posting stack depth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 65 +++++++++++-------- .../JoinableTaskFactoryTests.cs | 27 +++++++- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 7b0d9bbaf..3d8af83ba 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -727,45 +727,58 @@ private static void VerifyNoNonConcurrentSyncContext() private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { - (SendOrPostCallback Callback, object State)? callback = null; - try + bool continueSynchronously; + do { - lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + continueSynchronously = false; + (SendOrPostCallback Callback, object State)? callback = null; + try { - this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - callback = this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + callback = this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); + } } - } - if (callback is { } work) - { - work.Callback(work.State); + if (callback is { } work) + { + work.Callback(work.State); + } } - } - finally - { - TaskCompletionSource? postCompletion = null; - lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + finally { - this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + TaskCompletionSource? postCompletion = null; + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + if (synchronouslyPostingFactory == this) + { + continueSynchronously = true; + } + else + { + postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + else + { + this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; + } } - else + + if (postCompletion is object) { - this.pendingUnderlyingSynchronizationContextCallbacks = null; - this.underlyingSynchronizationContextCallbackPending = false; + this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); } } - - if (postCompletion is object) - { - this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); - } } + while (continueSynchronously); } private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionSource postCompletion) diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index db57f4a53..5f89e9ce1 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -201,9 +201,16 @@ public void CoalescingSupportsSynchronousUnderlyingPost() var factory = new SynchronouslyPostingJoinableTaskFactory(this.context); int executionCount = 0; - factory.Post(() => factory.Post(() => executionCount++)); + factory.Post(delegate + { + for (int i = 0; i < 100; i++) + { + factory.Post(() => executionCount++); + } + }); - Assert.Equal(1, executionCount); + Assert.Equal(100, executionCount); + Assert.Equal(1, factory.MaximumPostDepth); } [Fact] @@ -420,11 +427,16 @@ protected override void PostToUnderlyingSynchronizationContext(SendOrPostCallbac private class SynchronouslyPostingJoinableTaskFactory : JoinableTaskFactory { + private int currentPostDepth; + private int maximumPostDepth; + internal SynchronouslyPostingJoinableTaskFactory(JoinableTaskContext owner) : base(owner) { } + internal int MaximumPostDepth => Volatile.Read(ref this.maximumPostDepth); + internal void Post(Action callback) { this.PostToUnderlyingSynchronizationContextWithCoalescing(state => ((Action)state!).Invoke(), callback); @@ -432,7 +444,16 @@ internal void Post(Action callback) protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { - callback(state); + int depth = Interlocked.Increment(ref this.currentPostDepth); + Interlocked.Exchange(ref this.maximumPostDepth, Math.Max(this.MaximumPostDepth, depth)); + try + { + callback(state); + } + finally + { + Interlocked.Decrement(ref this.currentPostDepth); + } } } } From 10e2f0de4cdf7e544f9b0e89a4deea677f6a5ce8 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 16:27:47 -0600 Subject: [PATCH 10/19] Post coalesced successor before callback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 55 +++++++++++++------ .../JoinableTaskFactoryTests.cs | 16 ++++-- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 3d8af83ba..df728c389 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -725,6 +725,16 @@ private static void VerifyNoNonConcurrentSyncContext() } } + /// + /// Executes one callback from the private queue and, when more work is already queued, + /// posts its successor to the underlying synchronization context before invoking the callback. + /// + /// + /// Posting the successor first ensures that code invoked by the callback can enter a nested + /// message loop and find the next message already available. Synchronization contexts that + /// execute inline are + /// drained iteratively instead to avoid recursive stack growth. + /// private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { bool continueSynchronously; @@ -732,6 +742,8 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { continueSynchronously = false; (SendOrPostCallback Callback, object State)? callback = null; + TaskCompletionSource? postCompletion = null; + bool completeSynchronousDrainAfterCallback = false; try { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) @@ -741,6 +753,27 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { callback = this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); } + + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (synchronouslyPostingFactory == this) + { + completeSynchronousDrainAfterCallback = true; + continueSynchronously = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0; + } + else if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + else + { + this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; + } + } + + if (postCompletion is object) + { + this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); } if (callback is { } work) @@ -750,31 +783,21 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } finally { - TaskCompletionSource? postCompletion = null; - lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) + if (completeSynchronousDrainAfterCallback) { - this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - if (synchronouslyPostingFactory == this) + this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); + if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) { continueSynchronously = true; } else { - postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; } } - else - { - this.pendingUnderlyingSynchronizationContextCallbacks = null; - this.underlyingSynchronizationContextCallbackPending = false; - } - } - - if (postCompletion is object) - { - this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); } } } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 5f89e9ce1..bba54a8c2 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -146,10 +146,13 @@ public void PostsToUnderlyingSynchronizationContextConservatively() var factory = new QueueingJoinableTaskFactory(this.context); int executionCount = 0; - (JoinableTask _, Task firstQueued) = factory.QueueCallback(() => executionCount++); - (JoinableTask _, Task secondQueued) = factory.QueueCallback(() => executionCount++); - (JoinableTask _, Task thirdQueued) = factory.QueueCallback(() => executionCount++); - Task.WhenAll(firstQueued, secondQueued, thirdQueued).GetAwaiter().GetResult(); + factory.QueueUnderlyingCallback(delegate + { + Assert.Single(factory.PostedCallbacks); + executionCount++; + }); + factory.QueueUnderlyingCallback(() => executionCount++); + factory.QueueUnderlyingCallback(() => executionCount++); Assert.Single(factory.PostedCallbacks); factory.ExecutePostedCallback(); @@ -361,6 +364,11 @@ internal void ExecutePostedCallback() callback(state); } + internal void QueueUnderlyingCallback(Action callback) + { + this.PostToUnderlyingSynchronizationContextWithCoalescing(static state => ((Action)state!).Invoke(), callback); + } + internal (JoinableTask Job, Task Queued) QueueCallback(Action callback) { var queued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); From a4438851bd2f203909a2936d7afe4ceca60f0be7 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 16:42:11 -0600 Subject: [PATCH 11/19] Continue callback after failed repost Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 9 +++++--- .../JoinableTaskFactoryTests.cs | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index df728c389..d11f86668 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -773,7 +773,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() if (postCompletion is object) { - this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); + this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion, propagateException: false); } if (callback is { } work) @@ -804,7 +804,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() while (continueSynchronously); } - private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionSource postCompletion) + private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionSource postCompletion, bool propagateException = true) { try { @@ -845,7 +845,10 @@ private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionS postCompletion.SetException(ex); _ = postCompletion.Task.Exception; - throw; + if (propagateException) + { + throw; + } } } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index bba54a8c2..2c827e87d 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -185,6 +185,21 @@ public void CompletedCallbacksAreRemovedBeforePostingAnotherMessage() Assert.Empty(factory.PostedCallbacks); } + [Fact] + public void RepostFailureDoesNotPreventCurrentCallback() + { + var factory = new QueueingJoinableTaskFactory(this.context); + int executionCount = 0; + factory.QueueUnderlyingCallback(() => executionCount++); + factory.QueueUnderlyingCallback(() => executionCount++); + factory.FailNextPost = true; + + factory.ExecutePostedCallback(); + + Assert.Equal(1, executionCount); + Assert.Empty(factory.PostedCallbacks); + } + [Fact] public void DerivedFactoryDoesNotCoalesceUnlessOptedIn() { @@ -357,6 +372,8 @@ internal QueueingJoinableTaskFactory(JoinableTaskContext owner) internal ConcurrentQueue<(SendOrPostCallback Callback, object State)> PostedCallbacks { get; } = new(); + internal bool FailNextPost { get; set; } + internal void ExecutePostedCallback() { Assert.True(this.PostedCallbacks.TryDequeue(out (SendOrPostCallback Callback, object State) work)); @@ -399,6 +416,12 @@ internal void QueueUnderlyingCallback(Action callback) protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { + if (this.FailNextPost) + { + this.FailNextPost = false; + throw new InvalidOperationException(); + } + this.PostedCallbacks.Enqueue((callback, state)); } } From 2e87def0985e9dedf675f17fa615638463478d63 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 20 Aug 2026 22:09:32 -0600 Subject: [PATCH 12/19] Track nested synchronous post factories Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 17 +++++++++++------ .../JoinableTaskFactoryTests.cs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index d11f86668..4168c6ee8 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -26,7 +26,7 @@ public partial class JoinableTaskFactory private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((UnderlyingSynchronizationContextCallback)state!).Execute(); [ThreadStatic] - private static JoinableTaskFactory? synchronouslyPostingFactory; + private static List? synchronouslyPostingFactories; /// /// The that owns this instance. @@ -694,12 +694,17 @@ protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCa { this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); } - else if (synchronouslyPostingFactory != this) + else if (!IsSynchronouslyPosting(this)) { waitForPost?.GetAwaiter().GetResult(); } } + private static bool IsSynchronouslyPosting(JoinableTaskFactory factory) + { + return synchronouslyPostingFactories?.Contains(factory) is true; + } + /// /// Throws an exception if an active AsyncReaderWriterLock /// upgradeable read or write lock is held by the caller. @@ -755,7 +760,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - if (synchronouslyPostingFactory == this) + if (IsSynchronouslyPosting(this)) { completeSynchronousDrainAfterCallback = true; continueSynchronously = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0; @@ -808,15 +813,15 @@ private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionS { try { - JoinableTaskFactory? priorSynchronouslyPostingFactory = synchronouslyPostingFactory; - synchronouslyPostingFactory = this; + List synchronousPostingChain = synchronouslyPostingFactories ??= new(); + synchronousPostingChain.Add(this); try { this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); } finally { - synchronouslyPostingFactory = priorSynchronouslyPostingFactory; + synchronousPostingChain.RemoveAt(synchronousPostingChain.Count - 1); } lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 2c827e87d..3903338d2 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -231,6 +231,21 @@ public void CoalescingSupportsSynchronousUnderlyingPost() Assert.Equal(1, factory.MaximumPostDepth); } + [Fact] + public void CoalescingSupportsSynchronousUnderlyingPostAcrossFactories() + { + var firstFactory = new SynchronouslyPostingJoinableTaskFactory(this.context); + var secondFactory = new SynchronouslyPostingJoinableTaskFactory(this.context); + using var completed = new ManualResetEventSlim(); + + Task postTask = Task.Run(() => firstFactory.Post(() => secondFactory.Post(() => firstFactory.Post(completed.Set)))); + + Assert.True(postTask.Wait(UnexpectedTimeout)); + Assert.True(completed.IsSet); + Assert.Equal(1, firstFactory.MaximumPostDepth); + Assert.Equal(1, secondFactory.MaximumPostDepth); + } + [Fact] public void DisableProcessing_ThrowsOutsideJoinableTask() { From 38e097261214e16390cfbb9b80d1257593f67829 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 13:58:50 -0600 Subject: [PATCH 13/19] Simplify coalesced post failure handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 64 +++++++------------ .../JoinableTaskFactoryTests.cs | 9 +++ 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 4168c6ee8..4f921c0e3 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -46,8 +46,6 @@ public partial class JoinableTaskFactory private bool underlyingSynchronizationContextCallbackPending; - private TaskCompletionSource? underlyingSynchronizationContextPostCompletion; - /// /// Backing field for the property. /// @@ -672,8 +670,7 @@ protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCa { Requires.NotNull(callback, nameof(callback)); - TaskCompletionSource? postCompletion = null; - Task? waitForPost = null; + bool postCallback = false; lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object)>(); @@ -682,24 +679,23 @@ protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCa if (!this.underlyingSynchronizationContextCallbackPending) { this.underlyingSynchronizationContextCallbackPending = true; - postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - else - { - waitForPost = this.underlyingSynchronizationContextPostCompletion?.Task; + postCallback = true; } } - if (postCompletion is object) - { - this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion); - } - else if (!IsSynchronouslyPosting(this)) + if (postCallback) { - waitForPost?.GetAwaiter().GetResult(); + this.PostPendingUnderlyingSynchronizationContextCallback(propagateException: true); } } + /// + /// Checks whether this thread is currently inside an underlying synchronous post for the specified factory. + /// + /// + /// The full chain is tracked so nested posts across factories (for example A to B to A) recognize an + /// ancestor factory and drain it iteratively instead of recursively posting another driver message. + /// private static bool IsSynchronouslyPosting(JoinableTaskFactory factory) { return synchronouslyPostingFactories?.Contains(factory) is true; @@ -747,7 +743,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { continueSynchronously = false; (SendOrPostCallback Callback, object State)? callback = null; - TaskCompletionSource? postCompletion = null; + bool postSuccessor = false; bool completeSynchronousDrainAfterCallback = false; try { @@ -767,7 +763,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } else if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) { - postCompletion = this.underlyingSynchronizationContextPostCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + postSuccessor = true; } else { @@ -776,9 +772,9 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } } - if (postCompletion is object) + if (postSuccessor) { - this.PostPendingUnderlyingSynchronizationContextCallback(postCompletion, propagateException: false); + this.PostPendingUnderlyingSynchronizationContextCallback(propagateException: false); } if (callback is { } work) @@ -809,7 +805,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() while (continueSynchronously); } - private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionSource postCompletion, bool propagateException = true) + private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateException) { try { @@ -823,33 +819,17 @@ private void PostPendingUnderlyingSynchronizationContextCallback(TaskCompletionS { synchronousPostingChain.RemoveAt(synchronousPostingChain.Count - 1); } - - lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) - { - if (this.underlyingSynchronizationContextPostCompletion == postCompletion) - { - this.underlyingSynchronizationContextPostCompletion = null; - } - } - - postCompletion.SetResult(null); } - catch (Exception ex) + catch { lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - if (this.underlyingSynchronizationContextPostCompletion == postCompletion) - { - // Every callback remains in its owning JoinableTask's execution queue, so abandon this - // secondary route when no underlying message was established. - this.pendingUnderlyingSynchronizationContextCallbacks = null; - this.underlyingSynchronizationContextCallbackPending = false; - this.underlyingSynchronizationContextPostCompletion = null; - } + // Every callback remains in its owning JoinableTask's execution queue, so abandon this + // secondary route when no underlying message was established. + this.pendingUnderlyingSynchronizationContextCallbacks = null; + this.underlyingSynchronizationContextCallbackPending = false; } - postCompletion.SetException(ex); - _ = postCompletion.Task.Exception; if (propagateException) { throw; @@ -861,6 +841,8 @@ private void RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks() { Assumes.True(Monitor.IsEntered(this.pendingUnderlyingSynchronizationContextCallbacksLock)); + // Only inspect the head to keep enqueue and drain operations O(1). Completed entries behind live work + // are removed as they reach the head, while coalescing still limits the underlying context to one driver. #pragma warning disable VSOnly // IPendingExecutionRequestState is intended for evaluation purposes only. while (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0 && this.pendingUnderlyingSynchronizationContextCallbacks.Peek().State is IPendingExecutionRequestState { IsCompleted: true }) diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 3903338d2..1d2c22b46 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -200,6 +200,15 @@ public void RepostFailureDoesNotPreventCurrentCallback() Assert.Empty(factory.PostedCallbacks); } + [Fact] + public void InitialPostFailurePropagates() + { + var factory = new QueueingJoinableTaskFactory(this.context) { FailNextPost = true }; + + Assert.Throws(() => factory.QueueUnderlyingCallback(() => { })); + Assert.Empty(factory.PostedCallbacks); + } + [Fact] public void DerivedFactoryDoesNotCoalesceUnlessOptedIn() { From 0bdc82cb8df88b0ddbf7de5a869ccbfb83682870 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 14:07:03 -0600 Subject: [PATCH 14/19] Make post depth tracking atomic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactoryTests.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 1d2c22b46..8c739276c 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -500,7 +500,18 @@ internal void Post(Action callback) protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { int depth = Interlocked.Increment(ref this.currentPostDepth); - Interlocked.Exchange(ref this.maximumPostDepth, Math.Max(this.MaximumPostDepth, depth)); + int observedMaximum = this.MaximumPostDepth; + while (depth > observedMaximum) + { + int priorMaximum = Interlocked.CompareExchange(ref this.maximumPostDepth, depth, observedMaximum); + if (priorMaximum == observedMaximum) + { + break; + } + + observedMaximum = priorMaximum; + } + try { callback(state); From bb4376abf776f639f79a9bb869f3585057298c7e Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 14:19:49 -0600 Subject: [PATCH 15/19] Document derived factory coalescing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docfx/docs/threading_rules.md | 43 +++++++++++++++++-- .../JoinableTaskFactory.cs | 16 +++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docfx/docs/threading_rules.md b/docfx/docs/threading_rules.md index cf6115457..6b8b10f7b 100644 --- a/docfx/docs/threading_rules.md +++ b/docfx/docs/threading_rules.md @@ -299,9 +299,46 @@ The following describes how to replace the mechanism for getting to the UI thread in a host-independent way: You can set your own priority by creating your own derived type of -`JoinableTaskFactory` and overriding the `PostToUnderlyingSynchronizationContext` -method. This method is responsible both for initial switches to the UI -thread as well as resuming on the UI thread after a yielding await. +`JoinableTaskFactory`. + +The base implementation coalesces pending callbacks so that only one driver +message at a time is queued to the underlying synchronization context. A +derived type that does not override `PostToUnderlyingSynchronizationContext` +inherits this behavior. + +For backward compatibility, an override of +`PostToUnderlyingSynchronizationContext` remains fully authoritative and does +not automatically coalesce. Existing derived types may suppress a post, +redirect it, or apply semantics that the base class cannot safely assume. Such +types therefore retain their original behavior. + +A derived type may explicitly opt into coalescing by routing +`PostToUnderlyingSynchronizationContext` through +`PostToUnderlyingSynchronizationContextWithCoalescing`, and overriding +`PostToUnderlyingSynchronizationContextCore` with the actual dispatcher +operation: + +```csharp +protected override void PostToUnderlyingSynchronizationContext( + SendOrPostCallback callback, + object state) +{ + this.PostToUnderlyingSynchronizationContextWithCoalescing(callback, state); +} + +protected override void PostToUnderlyingSynchronizationContextCore( + SendOrPostCallback callback, + object state) +{ + this.dispatcher.Post(callback, state); +} +``` + +`PostToUnderlyingSynchronizationContext` is responsible both for initial +switches to the UI thread and for resuming on the UI thread after a yielding +await. When coalescing is enabled, the core method may be called once for a +sequence of pending callbacks and should only perform the underlying post; it +should not call the coalescing helper. Note that the `JoinableTaskFactory` class has no default constructor, so when implementing your own `JoinableTaskFactory`-derived type you will need to add diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 4f921c0e3..c1bcb7c31 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -476,6 +476,13 @@ internal void Post(SendOrPostCallback callback, object? state, bool mainThreadAf /// /// The callback to invoke. /// State to pass to the callback. + /// + /// The base implementation coalesces pending callbacks. An override replaces that behavior entirely, + /// preserving the semantics of derived types written before coalescing was introduced. A derived type + /// may opt into coalescing by calling + /// from this override and overriding to perform + /// the actual post. + /// protected internal virtual void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) { Requires.NotNull(callback, nameof(callback)); @@ -489,6 +496,10 @@ protected internal virtual void PostToUnderlyingSynchronizationContext(SendOrPos /// /// The callback to invoke. /// State to pass to the callback. + /// + /// Derived types that opt into coalescing should override this method, rather than + /// , with their custom dispatcher operation. + /// protected internal virtual void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) { Requires.NotNull(callback, nameof(callback)); @@ -666,6 +677,11 @@ protected void Add(JoinableTask joinable) /// State to pass to the callback. Implementing allows /// the callback to be removed from the private queue when it has already executed by another means. /// + /// + /// This method is intended for derived types that override + /// and explicitly opt into coalescing. Such types should also override + /// to perform the actual dispatcher post. + /// protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCallback callback, object state) { Requires.NotNull(callback, nameof(callback)); From ccfe623cfb5de60245e59ad2e89d5c32f9939c21 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 14:27:56 -0600 Subject: [PATCH 16/19] Clarify derived override sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docfx/docs/threading_rules.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docfx/docs/threading_rules.md b/docfx/docs/threading_rules.md index 6b8b10f7b..d518f4275 100644 --- a/docfx/docs/threading_rules.md +++ b/docfx/docs/threading_rules.md @@ -16,17 +16,17 @@ The rules are listed below with minimal examples. For a more thorough explanatio If a method has certain thread apartment requirements (STA or MTA) it must either: - 1. Have an asynchronous signature, and asynchronously marshal to the appropriate +1. Have an asynchronous signature, and asynchronously marshal to the appropriate thread if it isn't originally invoked on a compatible thread. The recommended means of switching to the main thread is: - ```csharp +```csharp await joinableTaskFactoryInstance.SwitchToMainThreadAsync(); - ``` +``` OR - 2. Have a synchronous signature, and throw an exception when called on the wrong thread. +2. Have a synchronous signature, and throw an exception when called on the wrong thread. This can be done in Visual Studio with `ThreadHelper.ThrowIfNotOnUIThread()` or `ThreadHelper.ThrowIfOnUIThread()`. @@ -316,7 +316,9 @@ A derived type may explicitly opt into coalescing by routing `PostToUnderlyingSynchronizationContext` through `PostToUnderlyingSynchronizationContextWithCoalescing`, and overriding `PostToUnderlyingSynchronizationContextCore` with the actual dispatcher -operation: +operation. Because `JoinableTaskFactory` is defined in another assembly, C# +requires these `protected internal` base members to be declared `protected` +when overridden: ```csharp protected override void PostToUnderlyingSynchronizationContext( @@ -330,7 +332,7 @@ protected override void PostToUnderlyingSynchronizationContextCore( SendOrPostCallback callback, object state) { - this.dispatcher.Post(callback, state); + this.UnderlyingSynchronizationContext!.Post(callback, state); } ``` @@ -338,7 +340,9 @@ protected override void PostToUnderlyingSynchronizationContextCore( switches to the UI thread and for resuming on the UI thread after a yielding await. When coalescing is enabled, the core method may be called once for a sequence of pending callbacks and should only perform the underlying post; it -should not call the coalescing helper. +should not call the coalescing helper. Replace the synchronization-context post +shown above with the custom dispatcher or priority operation required by the +derived factory. Note that the `JoinableTaskFactory` class has no default constructor, so when implementing your own `JoinableTaskFactory`-derived type you will need to add From e7c568954215370ee79bf15afe03f63d6beb45b5 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 14:34:41 -0600 Subject: [PATCH 17/19] Restore threading rules list formatting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docfx/docs/threading_rules.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docfx/docs/threading_rules.md b/docfx/docs/threading_rules.md index d518f4275..a321c01c6 100644 --- a/docfx/docs/threading_rules.md +++ b/docfx/docs/threading_rules.md @@ -16,17 +16,17 @@ The rules are listed below with minimal examples. For a more thorough explanatio If a method has certain thread apartment requirements (STA or MTA) it must either: -1. Have an asynchronous signature, and asynchronously marshal to the appropriate + 1. Have an asynchronous signature, and asynchronously marshal to the appropriate thread if it isn't originally invoked on a compatible thread. The recommended means of switching to the main thread is: -```csharp + ```csharp await joinableTaskFactoryInstance.SwitchToMainThreadAsync(); -``` + ``` OR -2. Have a synchronous signature, and throw an exception when called on the wrong thread. + 2. Have a synchronous signature, and throw an exception when called on the wrong thread. This can be done in Visual Studio with `ThreadHelper.ThrowIfNotOnUIThread()` or `ThreadHelper.ThrowIfOnUIThread()`. From f5df5c2981e2e0d2fb28c752f1ea1aff8d387809 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Fri, 21 Aug 2026 17:30:00 -0600 Subject: [PATCH 18/19] Preserve execution context when coalescing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../JoinableTaskFactory.cs | 35 ++++++++++++++--- .../JoinableTaskFactoryTests.cs | 39 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index c1bcb7c31..7217a4bf6 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -23,6 +23,12 @@ namespace Microsoft.VisualStudio.Threading; /// public partial class JoinableTaskFactory { + private static readonly ContextCallback ExecutePendingUnderlyingSynchronizationContextCallbackDelegate = state => + { + var callback = ((SendOrPostCallback Callback, object State))state!; + callback.Callback(callback.State); + }; + private static readonly SendOrPostCallback ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate = state => ((UnderlyingSynchronizationContextCallback)state!).Execute(); [ThreadStatic] @@ -42,7 +48,7 @@ public partial class JoinableTaskFactory /// private readonly JoinableTaskCollection? jobCollection; - private Queue<(SendOrPostCallback Callback, object State)>? pendingUnderlyingSynchronizationContextCallbacks; + private Queue<(SendOrPostCallback Callback, object State, ExecutionContext? ExecutionContext)>? pendingUnderlyingSynchronizationContextCallbacks; private bool underlyingSynchronizationContextCallbackPending; @@ -686,12 +692,13 @@ protected void PostToUnderlyingSynchronizationContextWithCoalescing(SendOrPostCa { Requires.NotNull(callback, nameof(callback)); + ExecutionContext? executionContext = ExecutionContext.Capture(); bool postCallback = false; lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { - this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object)>(); + this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object, ExecutionContext?)>(); this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks(); - this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue((callback, state)); + this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue((callback, state, executionContext)); if (!this.underlyingSynchronizationContextCallbackPending) { this.underlyingSynchronizationContextCallbackPending = true; @@ -758,7 +765,7 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() do { continueSynchronously = false; - (SendOrPostCallback Callback, object State)? callback = null; + (SendOrPostCallback Callback, object State, ExecutionContext? ExecutionContext)? callback = null; bool postSuccessor = false; bool completeSynchronousDrainAfterCallback = false; try @@ -795,7 +802,14 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() if (callback is { } work) { - work.Callback(work.State); + if (work.ExecutionContext is object) + { + ExecutionContext.Run(work.ExecutionContext, ExecutePendingUnderlyingSynchronizationContextCallbackDelegate, (work.Callback, work.State)); + } + else + { + work.Callback(work.State); + } } } finally @@ -827,12 +841,23 @@ private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateE { List synchronousPostingChain = synchronouslyPostingFactories ??= new(); synchronousPostingChain.Add(this); + bool restoreFlow = !ExecutionContext.IsFlowSuppressed(); + if (restoreFlow) + { + ExecutionContext.SuppressFlow(); + } + try { this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); } finally { + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + synchronousPostingChain.RemoveAt(synchronousPostingChain.Count - 1); } } diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 8c739276c..3e7d20cb0 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -255,6 +255,45 @@ public void CoalescingSupportsSynchronousUnderlyingPostAcrossFactories() Assert.Equal(1, secondFactory.MaximumPostDepth); } + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + public void CoalescingPreservesExecutionContextPerCallback(bool firstFlowsExecutionContext, bool secondFlowsExecutionContext) + { + var factory = new QueueingJoinableTaskFactory(this.context); + var asyncLocal = new System.Threading.AsyncLocal(); + string? firstObservedValue = null; + string? secondObservedValue = null; + + asyncLocal.Value = "first"; + QueueCallback(firstFlowsExecutionContext, () => firstObservedValue = asyncLocal.Value); + asyncLocal.Value = "second"; + QueueCallback(secondFlowsExecutionContext, () => secondObservedValue = asyncLocal.Value); + asyncLocal.Value = null; + + factory.ExecutePostedCallback(); + factory.ExecutePostedCallback(); + + Assert.Equal(firstFlowsExecutionContext ? "first" : null, firstObservedValue); + Assert.Equal(secondFlowsExecutionContext ? "second" : null, secondObservedValue); + + void QueueCallback(bool flowExecutionContext, Action callback) + { + if (flowExecutionContext) + { + factory.QueueUnderlyingCallback(callback); + } + else + { + using (ExecutionContext.SuppressFlow()) + { + factory.QueueUnderlyingCallback(callback); + } + } + } + } + [Fact] public void DisableProcessing_ThrowsOutsideJoinableTask() { From 92471e3d2a7dc796a8e806a3884205a81d921a0f Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Mon, 24 Aug 2026 14:16:04 -0600 Subject: [PATCH 19/19] Harden coalesced callback execution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DispatcherExtensions.cs | 44 ++++++++++ .../JoinableTaskFactory.cs | 61 ++++++++++++-- .../DispatcherExtensionsTests.cs | 80 +++++++++++++++++++ .../JoinableTaskFactoryTests.cs | 19 +++++ 4 files changed, 198 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs b/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs index 977f91cda..093db5679 100644 --- a/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs +++ b/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs @@ -4,6 +4,7 @@ #if NETFRAMEWORK || WINDOWS using System; +using System.Globalization; using System.Threading; using System.Windows.Threading; @@ -74,6 +75,49 @@ internal DispatcherJoinableTaskFactory(JoinableTaskFactory innerFactory, Dispatc this.priority = priority; } + /// + internal override void ExecutePendingUnderlyingSynchronizationContextCallback( + SendOrPostCallback callback, + object state, + ExecutionContext? executionContext) + { + if (executionContext is null) + { + callback(state); + return; + } + + CultureInfo dispatcherCulture = CultureInfo.CurrentCulture; + CultureInfo dispatcherUICulture = CultureInfo.CurrentUICulture; + CultureInfo resultingCulture = dispatcherCulture; + CultureInfo resultingUICulture = dispatcherUICulture; + try + { + ExecutionContext.Run( + executionContext, + _ => + { + CultureInfo.CurrentCulture = dispatcherCulture; + CultureInfo.CurrentUICulture = dispatcherUICulture; + try + { + callback(state); + } + finally + { + resultingCulture = CultureInfo.CurrentCulture; + resultingUICulture = CultureInfo.CurrentUICulture; + } + }, + null); + } + finally + { + CultureInfo.CurrentCulture = resultingCulture; + CultureInfo.CurrentUICulture = resultingUICulture; + } + } + /// protected internal override void PostToUnderlyingSynchronizationContext(SendOrPostCallback callback, object state) { diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 7217a4bf6..9aa64a43e 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using JoinableTaskSynchronizationContext = Microsoft.VisualStudio.Threading.JoinableTask.JoinableTaskSynchronizationContext; @@ -476,6 +477,27 @@ internal void Post(SendOrPostCallback callback, object? state, bool mainThreadAf } } + /// + /// Executes a pending callback under its captured execution context. + /// + /// The callback to invoke. + /// State to pass to the callback. + /// The execution context captured for the callback, or when flow was suppressed. + internal virtual void ExecutePendingUnderlyingSynchronizationContextCallback( + SendOrPostCallback callback, + object state, + ExecutionContext? executionContext) + { + if (executionContext is object) + { + ExecutionContext.Run(executionContext, ExecutePendingUnderlyingSynchronizationContextCallbackDelegate, (callback, state)); + } + else + { + callback(state); + } + } + /// /// Posts a message to the specified underlying SynchronizationContext for processing when the main thread /// is freely available. @@ -724,6 +746,13 @@ private static bool IsSynchronouslyPosting(JoinableTaskFactory factory) return synchronouslyPostingFactories?.Contains(factory) is true; } + private static void DisposeExecutionContext(ExecutionContext? executionContext) + { +#if NETFRAMEWORK + executionContext?.Dispose(); +#endif + } + /// /// Throws an exception if an active AsyncReaderWriterLock /// upgradeable read or write lock is held by the caller. @@ -762,6 +791,7 @@ private static void VerifyNoNonConcurrentSyncContext() private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() { bool continueSynchronously; + ExceptionDispatchInfo? synchronousCallbackException = null; do { continueSynchronously = false; @@ -802,13 +832,17 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() if (callback is { } work) { - if (work.ExecutionContext is object) + try { - ExecutionContext.Run(work.ExecutionContext, ExecutePendingUnderlyingSynchronizationContextCallbackDelegate, (work.Callback, work.State)); + this.ExecutePendingUnderlyingSynchronizationContextCallback(work.Callback, work.State, work.ExecutionContext); } - else + catch (Exception ex) when (completeSynchronousDrainAfterCallback) + { + synchronousCallbackException ??= ExceptionDispatchInfo.Capture(ex); + } + finally { - work.Callback(work.State); + DisposeExecutionContext(work.ExecutionContext); } } } @@ -833,10 +867,13 @@ private void ExecuteOnePendingUnderlyingSynchronizationContextCallback() } } while (continueSynchronously); + + synchronousCallbackException?.Throw(); } private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateException) { + var driver = new UnderlyingSynchronizationContextCallback(this); try { List synchronousPostingChain = synchronouslyPostingFactories ??= new(); @@ -849,7 +886,7 @@ private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateE try { - this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, new UnderlyingSynchronizationContextCallback(this)); + this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, driver); } finally { @@ -863,10 +900,20 @@ private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateE } catch { + if (driver.HasExecuted) + { + throw; + } + lock (this.pendingUnderlyingSynchronizationContextCallbacksLock) { // Every callback remains in its owning JoinableTask's execution queue, so abandon this // secondary route when no underlying message was established. + while (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0) + { + DisposeExecutionContext(this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue().ExecutionContext); + } + this.pendingUnderlyingSynchronizationContextCallbacks = null; this.underlyingSynchronizationContextCallbackPending = false; } @@ -889,7 +936,7 @@ private void RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks() && this.pendingUnderlyingSynchronizationContextCallbacks.Peek().State is IPendingExecutionRequestState { IsCompleted: true }) #pragma warning restore VSOnly { - this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue(); + DisposeExecutionContext(this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue().ExecutionContext); } } @@ -1655,6 +1702,8 @@ internal UnderlyingSynchronizationContextCallback(JoinableTaskFactory factory) this.factory = factory; } + internal bool HasExecuted => Volatile.Read(ref this.factory) is null; + internal void Execute() { Interlocked.Exchange(ref this.factory, null)?.ExecuteOnePendingUnderlyingSynchronizationContextCallback(); diff --git a/test/Microsoft.VisualStudio.Threading.Tests/DispatcherExtensionsTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/DispatcherExtensionsTests.cs index 44b5cf011..a7b7a3069 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/DispatcherExtensionsTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/DispatcherExtensionsTests.cs @@ -4,6 +4,7 @@ #if NETFRAMEWORK || WINDOWS using System; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using System.Windows.Threading; @@ -67,6 +68,85 @@ public void WithPriority_LowPriorityCanBlockOnHighPriorityWork() }); } + [Fact] + public void WithPriority_PreservesDispatcherCultureSemantics() + { + this.SimulateUIThread(async delegate + { + var dispatcherCulture = CultureInfo.GetCultureInfo("en-US"); + var postingCulture = CultureInfo.GetCultureInfo("fr-FR"); + var callbackCulture = CultureInfo.GetCultureInfo("de-DE"); + Dispatcher dispatcher = Dispatcher.CurrentDispatcher; + JoinableTaskFactory factory = this.asyncPump.WithPriority(dispatcher, DispatcherPriority.Normal); + var dispatcherCultureEstablished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + CultureInfo? originalDispatcherCulture = null; + CultureInfo? originalDispatcherUICulture = null; + _ = dispatcher.BeginInvoke(new Action(delegate + { + originalDispatcherCulture = CultureInfo.CurrentCulture; + originalDispatcherUICulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentCulture = dispatcherCulture; + CultureInfo.CurrentUICulture = dispatcherCulture; + dispatcherCultureEstablished.SetResult(null); + })); + await dispatcherCultureEstablished.Task.WithCancellation(this.TimeoutToken); + + try + { + CultureInfo? observedCulture = null; + CultureInfo? observedUICulture = null; + CultureInfo? cultureAfterCallback = null; + var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observedAfterCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await Task.Run(delegate + { + CultureInfo.CurrentCulture = postingCulture; + CultureInfo.CurrentUICulture = postingCulture; + JoinableTaskFactory.MainThreadAwaiter awaiter = factory.SwitchToMainThreadAsync().GetAwaiter(); + awaiter.OnCompleted(delegate + { + try + { + awaiter.GetResult(); + observedCulture = CultureInfo.CurrentCulture; + observedUICulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentCulture = callbackCulture; + CultureInfo.CurrentUICulture = callbackCulture; + _ = dispatcher.BeginInvoke(new Action(delegate + { + cultureAfterCallback = CultureInfo.CurrentCulture; + observedAfterCallback.SetResult(null); + })); + callbackCompleted.SetResult(null); + } + catch (Exception ex) + { + callbackCompleted.SetException(ex); + } + }); + }); + + await callbackCompleted.Task.WithCancellation(this.TimeoutToken); + await observedAfterCallback.Task.WithCancellation(this.TimeoutToken); + Assert.Equal(dispatcherCulture, observedCulture); + Assert.Equal(dispatcherCulture, observedUICulture); + Assert.Equal(callbackCulture, cultureAfterCallback); + } + finally + { + var dispatcherCultureRestored = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _ = dispatcher.BeginInvoke(new Action(delegate + { + CultureInfo.CurrentCulture = originalDispatcherCulture!; + CultureInfo.CurrentUICulture = originalDispatcherUICulture!; + dispatcherCultureRestored.SetResult(null); + })); + await dispatcherCultureRestored.Task.WithCancellation(this.TimeoutToken); + } + }); + } + #if NETFRAMEWORK [StaFact] public void WithPriority_MatchesDisableProcessingWithinDelegate() diff --git a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs index 3e7d20cb0..bdd3863ae 100644 --- a/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs +++ b/test/Microsoft.VisualStudio.Threading.Tests/JoinableTaskFactoryTests.cs @@ -255,6 +255,25 @@ public void CoalescingSupportsSynchronousUnderlyingPostAcrossFactories() Assert.Equal(1, secondFactory.MaximumPostDepth); } + [Fact] + public void CoalescingSupportsSynchronousCallbackExceptions() + { + var factory = new SynchronouslyPostingJoinableTaskFactory(this.context); + bool nestedCallbackExecuted = false; + bool subsequentCallbackExecuted = false; + + Assert.Throws(() => factory.Post(delegate + { + factory.Post(() => nestedCallbackExecuted = true); + throw new InvalidOperationException(); + })); + + Assert.True(nestedCallbackExecuted); + factory.Post(() => subsequentCallbackExecuted = true); + Assert.True(subsequentCallbackExecuted); + Assert.Equal(1, factory.MaximumPostDepth); + } + [Theory] [InlineData(true, true)] [InlineData(true, false)]