diff --git a/docfx/docs/threading_rules.md b/docfx/docs/threading_rules.md
index cf6115457..a321c01c6 100644
--- a/docfx/docs/threading_rules.md
+++ b/docfx/docs/threading_rules.md
@@ -299,9 +299,50 @@ 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. 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(
+ SendOrPostCallback callback,
+ object state)
+{
+ this.PostToUnderlyingSynchronizationContextWithCoalescing(callback, state);
+}
+
+protected override void PostToUnderlyingSynchronizationContextCore(
+ SendOrPostCallback callback,
+ object state)
+{
+ this.UnderlyingSynchronizationContext!.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. 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
diff --git a/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs b/src/Microsoft.VisualStudio.Threading/DispatcherExtensions.cs
index 1b93c4bef..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,8 +75,57 @@ 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)
+ {
+ 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 b6bc0acfd..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;
@@ -23,11 +24,24 @@ 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]
+ private static List? synchronouslyPostingFactories;
+
///
/// The that owns this instance.
///
private readonly JoinableTaskContext owner;
+ private readonly object pendingUnderlyingSynchronizationContextCallbacksLock = new();
+
private readonly SynchronizationContext? mainThreadJobSyncContext;
///
@@ -35,6 +49,10 @@ public partial class JoinableTaskFactory
///
private readonly JoinableTaskCollection? jobCollection;
+ private Queue<(SendOrPostCallback Callback, object State, ExecutionContext? ExecutionContext)>? pendingUnderlyingSynchronizationContextCallbacks;
+
+ private bool underlyingSynchronizationContextCallbackPending;
+
///
/// Backing field for the property.
///
@@ -459,17 +477,62 @@ 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.
///
/// 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));
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.
+ ///
+ /// 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));
+ Assumes.NotNull(this.UnderlyingSynchronizationContext);
+
this.UnderlyingSynchronizationContext.Post(callback, state);
}
@@ -634,6 +697,62 @@ 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.
+ ///
+ ///
+ /// 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));
+
+ ExecutionContext? executionContext = ExecutionContext.Capture();
+ bool postCallback = false;
+ lock (this.pendingUnderlyingSynchronizationContextCallbacksLock)
+ {
+ this.pendingUnderlyingSynchronizationContextCallbacks ??= new Queue<(SendOrPostCallback, object, ExecutionContext?)>();
+ this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
+ this.pendingUnderlyingSynchronizationContextCallbacks.Enqueue((callback, state, executionContext));
+ if (!this.underlyingSynchronizationContextCallbackPending)
+ {
+ this.underlyingSynchronizationContextCallbackPending = true;
+ postCallback = true;
+ }
+ }
+
+ if (postCallback)
+ {
+ 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;
+ }
+
+ 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.
@@ -659,6 +778,168 @@ 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;
+ ExceptionDispatchInfo? synchronousCallbackException = null;
+ do
+ {
+ continueSynchronously = false;
+ (SendOrPostCallback Callback, object State, ExecutionContext? ExecutionContext)? callback = null;
+ bool postSuccessor = false;
+ bool completeSynchronousDrainAfterCallback = false;
+ try
+ {
+ lock (this.pendingUnderlyingSynchronizationContextCallbacksLock)
+ {
+ this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
+ if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0)
+ {
+ callback = this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue();
+ }
+
+ this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
+ if (IsSynchronouslyPosting(this))
+ {
+ completeSynchronousDrainAfterCallback = true;
+ continueSynchronously = this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0;
+ }
+ else if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0)
+ {
+ postSuccessor = true;
+ }
+ else
+ {
+ this.pendingUnderlyingSynchronizationContextCallbacks = null;
+ this.underlyingSynchronizationContextCallbackPending = false;
+ }
+ }
+
+ if (postSuccessor)
+ {
+ this.PostPendingUnderlyingSynchronizationContextCallback(propagateException: false);
+ }
+
+ if (callback is { } work)
+ {
+ try
+ {
+ this.ExecutePendingUnderlyingSynchronizationContextCallback(work.Callback, work.State, work.ExecutionContext);
+ }
+ catch (Exception ex) when (completeSynchronousDrainAfterCallback)
+ {
+ synchronousCallbackException ??= ExceptionDispatchInfo.Capture(ex);
+ }
+ finally
+ {
+ DisposeExecutionContext(work.ExecutionContext);
+ }
+ }
+ }
+ finally
+ {
+ if (completeSynchronousDrainAfterCallback)
+ {
+ lock (this.pendingUnderlyingSynchronizationContextCallbacksLock)
+ {
+ this.RemoveCompletedPendingUnderlyingSynchronizationContextCallbacks();
+ if (this.pendingUnderlyingSynchronizationContextCallbacks?.Count > 0)
+ {
+ continueSynchronously = true;
+ }
+ else
+ {
+ this.pendingUnderlyingSynchronizationContextCallbacks = null;
+ this.underlyingSynchronizationContextCallbackPending = false;
+ }
+ }
+ }
+ }
+ }
+ while (continueSynchronously);
+
+ synchronousCallbackException?.Throw();
+ }
+
+ private void PostPendingUnderlyingSynchronizationContextCallback(bool propagateException)
+ {
+ var driver = new UnderlyingSynchronizationContextCallback(this);
+ try
+ {
+ List synchronousPostingChain = synchronouslyPostingFactories ??= new();
+ synchronousPostingChain.Add(this);
+ bool restoreFlow = !ExecutionContext.IsFlowSuppressed();
+ if (restoreFlow)
+ {
+ ExecutionContext.SuppressFlow();
+ }
+
+ try
+ {
+ this.PostToUnderlyingSynchronizationContextCore(ExecuteOnePendingUnderlyingSynchronizationContextCallbackDelegate, driver);
+ }
+ finally
+ {
+ if (restoreFlow)
+ {
+ ExecutionContext.RestoreFlow();
+ }
+
+ synchronousPostingChain.RemoveAt(synchronousPostingChain.Count - 1);
+ }
+ }
+ 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;
+ }
+
+ if (propagateException)
+ {
+ throw;
+ }
+ }
+ }
+
+ 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 })
+#pragma warning restore VSOnly
+ {
+ DisposeExecutionContext(this.pendingUnderlyingSynchronizationContextCallbacks.Dequeue().ExecutionContext);
+ }
+ }
+
///
/// Wraps the invocation of an async method such that it may
/// execute asynchronously, but may potentially be
@@ -1411,4 +1692,21 @@ private void OnExecuting()
}
}
}
+
+ private sealed class UnderlyingSynchronizationContextCallback
+ {
+ private JoinableTaskFactory? factory;
+
+ 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