Skip to content

Commit 0746cc5

Browse files
committed
Add JoinableTaskFactory.DisableProcessing()
This method is like the WPF `Dispatcher.DisableProcessing()` method, except that it impacts the `SynchronizationContext` that `JoinableTask` delegates execute within. As part of this, I introduce a test helper class for actually testing the penetrability of sync blocks so we can adequately test this new API. Since this test helper also can apply to `NoMessagePumpSyncContext` testing (which did not exist), I added new tests for that class too.
1 parent ebd4ed3 commit 0746cc5

14 files changed

Lines changed: 617 additions & 16 deletions

.github/copilot-instructions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,6 @@ dotnet run --no-build -c Release --framework net9.0 -- --list-tests
8282
## Coding style
8383

8484
* Honor StyleCop rules and fix any reported build warnings *after* getting tests to pass.
85-
* In C# files, use namespace *statements* instead of namespace *blocks* for all new files.
85+
* In C# files, use namespace *statements* instead of namespace *blocks* for all new files that define namespaces.
86+
* Test files are *not* expected to declare namespaces.
8687
* Add API doc comments to all new public and internal members.

samples/DisableProcessing.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#pragma warning disable VSTHRD103 // Call async methods when in an async method
5+
6+
using System.IO;
7+
using Microsoft.VisualStudio.Threading;
8+
9+
internal class DisableProcessing
10+
{
11+
private readonly JoinableTaskFactory joinableTaskFactory = null!;
12+
13+
private void Simple()
14+
{
15+
#region Simple
16+
this.joinableTaskFactory.Run(async delegate
17+
{
18+
this.joinableTaskFactory.DisableProcessing();
19+
20+
// Synchronous I/O and lock contentions will NOT result in any reentrancy within this JoinableTask.
21+
});
22+
#endregion
23+
}
24+
25+
private void Exhaustive()
26+
{
27+
#region Exhaustive
28+
this.joinableTaskFactory.Run(async delegate
29+
{
30+
// Async I/O isn't expected to synchronously block, and thus would never allow unwanted reentrancy.
31+
string content = await File.ReadAllTextAsync(@"somefile.txt");
32+
33+
// Here, synchronous I/O and lock contentions MAY allow certain reentrancy (e.g. COM RPC messages).
34+
content = File.ReadAllText(@"somefile.txt");
35+
36+
using (this.joinableTaskFactory.DisableProcessing())
37+
{
38+
// Within this block, synchronous I/O and lock contentions will NOT result in any reentrancy.
39+
content = File.ReadAllText(@"somefile.txt");
40+
}
41+
42+
// Just disable the synchronous wait message pump for the rest of this JoinableTask.
43+
this.joinableTaskFactory.DisableProcessing();
44+
45+
// Sync I/O and lock contentions will NOT result in any reentrancy here.
46+
content = File.ReadAllText(@"somefile.txt");
47+
});
48+
#endregion
49+
}
50+
}

samples/Polyfill.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#if NETFRAMEWORK
5+
6+
using System;
7+
using System.IO;
8+
using System.Threading.Tasks;
9+
10+
internal static class PolyfillExtensions
11+
{
12+
extension(File)
13+
{
14+
internal static Task<string> ReadAllTextAsync(string path) => throw new NotImplementedException();
15+
}
16+
}
17+
18+
#endif
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

4-
using System.Threading;
54
using System.Threading.Tasks;
65
using Microsoft.VisualStudio.Threading;
76

8-
public class SuppressRelevanceSample
7+
public class SuppressRelevance
98
{
109
private readonly ReentrantSemaphore semaphore = ReentrantSemaphore.Create(1, null, ReentrantSemaphore.ReentrancyMode.NotAllowed);
1110

@@ -19,7 +18,7 @@ await this.semaphore.ExecuteAsync(async delegate
1918
await Task.Yield(); // represents some async work
2019

2120
// Fire and forget code that uses the semaphore, but should *not*
22-
// inherit our own posession of the semaphore.
21+
// inherit our own possession of the semaphore.
2322
using (this.semaphore.SuppressRelevance())
2423
{
2524
this.DoSomethingLaterAsync().Forget(); // Don't await this, or a deadlock will occur.

src/Microsoft.VisualStudio.Threading/JoinableTask+JoinableTaskSynchronizationContext.cs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using System;
5-
using System.Collections.Generic;
6-
using System.Linq;
7-
using System.Text;
5+
using System.Runtime.InteropServices;
86
using System.Threading;
97
using System.Threading.Tasks;
8+
using Windows.Win32;
9+
using Windows.Win32.Foundation;
1010

1111
namespace Microsoft.VisualStudio.Threading
1212
{
@@ -67,6 +67,23 @@ internal bool MainThreadAffinitized
6767
get { return this.mainThreadAffinitized; }
6868
}
6969

70+
/// <summary>
71+
/// Gets or sets a value indicating whether synchronous waits should prohibit any message pump (e.g. CoWait).
72+
/// </summary>
73+
/// <value>The default value is <see langword="false" />.</value>
74+
internal bool DisableProcessing
75+
{
76+
get => field;
77+
set
78+
{
79+
if (field = value)
80+
{
81+
// This is required so that our override of Wait is invoked.
82+
this.SetWaitNotificationRequired();
83+
}
84+
}
85+
}
86+
7087
/// <summary>
7188
/// Forwards the specified message to the job this instance belongs to if applicable; otherwise to the factory.
7289
/// </summary>
@@ -134,6 +151,42 @@ public override void Send(SendOrPostCallback d, object? state)
134151
}
135152
}
136153

154+
/// <summary>
155+
/// Synchronously blocks without a message pump.
156+
/// </summary>
157+
/// <param name="waitHandles">An array of type <see cref="IntPtr" /> that contains the native operating system handles.</param>
158+
/// <param name="waitAll">true to wait for all handles; false to wait for any handle.</param>
159+
/// <param name="millisecondsTimeout">The number of milliseconds to wait, or <see cref="Timeout.Infinite" /> (-1) to wait indefinitely.</param>
160+
/// <returns>
161+
/// The array index of the object that satisfied the wait.
162+
/// </returns>
163+
public override unsafe int Wait(IntPtr[] waitHandles, bool waitAll, int millisecondsTimeout)
164+
{
165+
Requires.NotNull(waitHandles, nameof(waitHandles));
166+
167+
if (this.DisableProcessing)
168+
{
169+
// On .NET Framework we must take special care to NOT end up in a call to CoWait (which lets in RPC calls).
170+
// Off Windows, we can't p/invoke to kernel32, but it appears that .NET never calls CoWait, so we can rely on default behavior.
171+
// We're just going to use the OS as the switch instead of the runtime so that (one day) if we drop our .NET Framework specific target,
172+
// and if .NET ever adds CoWait support on Windows, we'll still behave properly.
173+
#if NET
174+
if (OperatingSystem.IsWindowsVersionAtLeast(5, 1, 2600))
175+
#else
176+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
177+
#endif
178+
{
179+
fixed (IntPtr* pHandles = waitHandles)
180+
{
181+
return (int)PInvoke.WaitForMultipleObjects((uint)waitHandles.Length, (HANDLE*)pHandles, waitAll, (uint)millisecondsTimeout);
182+
}
183+
}
184+
}
185+
186+
// Fallback to sync blocking such that CoWait might be called.
187+
return WaitHelper(waitHandles, waitAll, millisecondsTimeout);
188+
}
189+
137190
/// <summary>
138191
/// Called by the joinable task when it has completed.
139192
/// </summary>

src/Microsoft.VisualStudio.Threading/JoinableTask.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,10 @@ internal SynchronizationContext? ApplicableJobSyncContext
330330
{
331331
if (this.mainThreadJobSyncContext is null)
332332
{
333-
this.mainThreadJobSyncContext = new JoinableTaskSynchronizationContext(this, true);
333+
this.mainThreadJobSyncContext = new JoinableTaskSynchronizationContext(this, true)
334+
{
335+
DisableProcessing = this.DisableProcessing,
336+
};
334337
}
335338
}
336339
}
@@ -372,6 +375,23 @@ internal SynchronizationContext? ApplicableJobSyncContext
372375
}
373376
}
374377

378+
/// <summary>
379+
/// Gets or sets a value indicating whether CoWait will be prohibited
380+
/// during synchronously blocking waits from code actively running within this <see cref="JoinableTask"/>.
381+
/// </summary>
382+
internal bool DisableProcessing
383+
{
384+
get => field;
385+
set
386+
{
387+
field = value;
388+
if (this.mainThreadJobSyncContext is { } syncContext)
389+
{
390+
syncContext.DisableProcessing = value;
391+
}
392+
}
393+
}
394+
375395
/// <summary>
376396
/// Gets a weak reference to this object.
377397
/// </summary>

src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,56 @@ public JoinableTask<T> RunAsync<T>(Func<Task<T>> asyncMethod, string? parentToke
294294
return this.RunAsync(asyncMethod, synchronouslyBlocking: false, parentToken, creationOptions: creationOptions);
295295
}
296296

297+
#pragma warning disable SA1629 // Documentation text should end with a period
298+
/// <summary>
299+
/// Prevents filtered message pumps from running during synchronous waits for the ambient <see cref="JoinableTask"/>.
300+
/// </summary>
301+
/// <returns>
302+
/// A value that may be disposed of when the need to suppress synchronous wait message pumps is ended.
303+
/// Alternatively it may be discarded if the rest of the <see cref="JoinableTask"/> is intended to have processing disabled.
304+
/// </returns>
305+
/// <exception cref="InvalidOperationException">Thrown when called outside the context of a <see cref="JoinableTask"/>.</exception>
306+
/// <remarks>
307+
/// <para>
308+
/// During a yielding <see langword="await"/> within a <see cref="JoinableTask"/>, no message pump ever runs
309+
/// regardless of whether this method is called, except for the internal one that lets in only relevant work.
310+
/// When user code runs within the <see cref="JoinableTask"/> delegate or its callees that ends up requiring
311+
/// a synchronous block of the main thread (e.g. synchronous I/O or lock contention), this wait is typically
312+
/// implemented by calling <see cref="SynchronizationContext.Wait(IntPtr[], bool, int)"/> on <see cref="SynchronizationContext.Current"/>.
313+
/// The default implementation of this method allows for certain interruptions (e.g. COM RPC calls), which
314+
/// <em>may</em> avoid deadlocks in certain situations.
315+
/// </para>
316+
/// <para>
317+
/// Calling this method will replace the default implementation of <see cref="SynchronizationContext.Wait(IntPtr[], bool, int)"/>
318+
/// with one that will not allow such interruptions while that <see cref="JoinableTask"/> is active and in control of
319+
/// <see cref="SynchronizationContext.Current"/>.
320+
/// </para>
321+
/// <para>
322+
/// Disabling processing has no effect on non-Windows operating systems.
323+
/// </para>
324+
/// <para>
325+
/// Nested calls to <see cref="DisableProcessing"/> are ignored. Only the outermost call has an effect on the behavior of the ambient <see cref="JoinableTask"/>
326+
/// and only disposing the return value from the outermost call will restore the default behavior.
327+
/// </para>
328+
/// <para>
329+
/// Disposing the resulting value will revert to the default behavior.
330+
/// Callers need not ever dispose of this value if the intent is to disable processing for the remainder of that
331+
/// <see cref="JoinableTask"/>'s execution.
332+
/// </para>
333+
/// </remarks>
334+
/// <example>
335+
/// <para>
336+
/// Here is a simple, common usage of this method:
337+
/// </para>
338+
/// <code source="../../samples/DisableProcessing.cs" region="Simple" lang="C#" />
339+
/// <para>
340+
/// Following are more examples of how it might be used:
341+
/// </para>
342+
/// <code source="../../samples/DisableProcessing.cs" region="Exhaustive" lang="C#" />
343+
/// </example>
344+
public ProcessingDisabledOperation DisableProcessing() => new(this.Context.AmbientTask ?? throw new InvalidOperationException(Strings.NoAmbientTask));
345+
#pragma warning restore SA1629 // Documentation text should end with a period
346+
297347
/// <summary>
298348
/// Responds to calls to <see cref="JoinableTaskFactory.MainThreadAwaiter.OnCompleted(Action)"/>
299349
/// by scheduling a continuation to execute on the Main thread.
@@ -682,6 +732,37 @@ static bool FailFast(Exception ex)
682732
}
683733
}
684734

735+
/// <summary>
736+
/// A struct whose disposal will revert the effect of an earlier call to <see cref="DisableProcessing"/>.
737+
/// </summary>
738+
public struct ProcessingDisabledOperation : IDisposable
739+
{
740+
private JoinableTask? owner;
741+
742+
/// <summary>
743+
/// Initializes a new instance of the <see cref="ProcessingDisabledOperation"/> struct.
744+
/// </summary>
745+
/// <param name="owner">The owner of this struct.</param>
746+
internal ProcessingDisabledOperation(JoinableTask owner)
747+
{
748+
if (owner.DisableProcessing is false)
749+
{
750+
owner.DisableProcessing = true;
751+
this.owner = owner;
752+
}
753+
}
754+
755+
/// <inheritdoc/>
756+
public void Dispose()
757+
{
758+
if (this.owner is { } owner)
759+
{
760+
owner.DisableProcessing = false;
761+
this.owner = null;
762+
}
763+
}
764+
}
765+
685766
/// <summary>
686767
/// An awaitable struct that facilitates an asynchronous transition to the Main thread.
687768
/// </summary>

src/Microsoft.VisualStudio.Threading/NoMessagePumpSyncContext.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using System;
5-
using System.Buffers;
65
using System.Runtime.InteropServices;
76
using System.Threading;
87
using global::Windows.Win32;
@@ -52,10 +51,10 @@ public override unsafe int Wait(IntPtr[] waitHandles, bool waitAll, int millisec
5251
Requires.NotNull(waitHandles, nameof(waitHandles));
5352

5453
// On .NET Framework we must take special care to NOT end up in a call to CoWait (which lets in RPC calls).
55-
// Off Windows, we can't p/invoke to kernel32, but it appears that .NET Core never calls CoWait, so we can rely on default behavior.
56-
// We're just going to use the OS as the switch instead of the framework so that (one day) if we drop our .NET Framework specific target,
57-
// and if .NET Core ever adds CoWait support on Windows, we'll still behave properly.
58-
#if NET5_0_OR_GREATER
54+
// Off Windows, we can't p/invoke to kernel32, but it appears that .NET never calls CoWait, so we can rely on default behavior.
55+
// We're just going to use the OS as the switch instead of the runtime so that (one day) if we drop our .NET Framework specific target,
56+
// and if .NET ever adds CoWait support on Windows, we'll still behave properly.
57+
#if NET
5958
if (OperatingSystem.IsWindowsVersionAtLeast(5, 1, 2600))
6059
#else
6160
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))

src/Microsoft.VisualStudio.Threading/ReentrantSemaphore.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ public static ReentrantSemaphore Create(int initialCount = 1, JoinableTaskContex
191191
/// <para>
192192
/// The following snippet demonstrates a way to use this method.
193193
/// </para>
194-
/// <code source="../../samples/ApiSamples.cs" region="SuppressRelevance" lang="C#" />
194+
/// <code source="../../samples/SuppressRelevance.cs" region="SuppressRelevance" lang="C#" />
195195
/// </example>
196196
public virtual RevertRelevance SuppressRelevance() => default;
197197

src/Microsoft.VisualStudio.Threading/Strings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,7 @@
193193
<data name="SyncContextNotSet" xml:space="preserve">
194194
<value>No SynchronizationContext to reach the main thread has been set.</value>
195195
</data>
196+
<data name="NoAmbientTask" xml:space="preserve">
197+
<value>No JoinableTask is active.</value>
198+
</data>
196199
</root>

0 commit comments

Comments
 (0)