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(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 b7b8832e0..bdd3863ae 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,179 @@ public void SwitchToMainThreadAlwaysYield() }); } + [Fact] + public void PostsToUnderlyingSynchronizationContextConservatively() + { + var factory = new QueueingJoinableTaskFactory(this.context); + int executionCount = 0; + + factory.QueueUnderlyingCallback(delegate + { + Assert.Single(factory.PostedCallbacks); + executionCount++; + }); + factory.QueueUnderlyingCallback(() => executionCount++); + factory.QueueUnderlyingCallback(() => executionCount++); + + 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 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 InitialPostFailurePropagates() + { + var factory = new QueueingJoinableTaskFactory(this.context) { FailNextPost = true }; + + Assert.Throws(() => factory.QueueUnderlyingCallback(() => { })); + 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 CoalescingSupportsSynchronousUnderlyingPost() + { + var factory = new SynchronouslyPostingJoinableTaskFactory(this.context); + int executionCount = 0; + + factory.Post(delegate + { + for (int i = 0; i < 100; i++) + { + factory.Post(() => executionCount++); + } + }); + + Assert.Equal(100, executionCount); + 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 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)] + [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() { @@ -269,4 +444,140 @@ 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 bool FailNextPost { get; set; } + + internal void ExecutePostedCallback() + { + Assert.True(this.PostedCallbacks.TryDequeue(out (SendOrPostCallback Callback, object State) work)); + (SendOrPostCallback callback, object state) = work; + 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); + JoinableTask job = this.RunAsync(async delegate + { + await TaskScheduler.Default.SwitchTo(alwaysYield: true); + var callbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + JoinableTaskFactory.MainThreadAwaiter awaiter = this.SwitchToMainThreadAsync().GetAwaiter(); + awaiter.OnCompleted(delegate + { + try + { + awaiter.GetResult(); + callback(); + callbackCompleted.SetResult(null); + } + catch (Exception ex) + { + callbackCompleted.SetException(ex); + } + }); + queued.SetResult(null); + await callbackCompleted.Task; + }); + + return (job, queued.Task); + } + + protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) + { + if (this.FailNextPost) + { + this.FailNextPost = false; + throw new InvalidOperationException(); + } + + 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); + } + } + + 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); + } + + protected override void PostToUnderlyingSynchronizationContextCore(SendOrPostCallback callback, object state) + { + int depth = Interlocked.Increment(ref this.currentPostDepth); + 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); + } + finally + { + Interlocked.Decrement(ref this.currentPostDepth); + } + } + } }