From 30049eb9e5a52d0d3157c44d61bc3eee4d32924e Mon Sep 17 00:00:00 2001 From: Vivian Lim Date: Mon, 9 Feb 2026 16:52:42 -0800 Subject: [PATCH 1/3] Add a JTF with a token that gets cancelled when JoinTillEmptyAsync is called. - Add an internal event to JoinableTaskCollection which is raised when JoinTillEmptyAsync is called - Add SelfCancellingJoinableTaskFactory, a variant of DelegatingJoinableTaskFactory which contains a CancellationTokenSource and cancels it during shutdown (signalled by that event, or Dispose being called) --- .../JoinableTaskCollection.cs | 12 +++ .../SelfCancellingJoinableTaskFactory.cs | 80 ++++++++++++++++ .../SelfCancellingJoinableTaskFactoryTests.cs | 93 +++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs create mode 100644 test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs index e929d97b6..345b7d2b6 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs @@ -55,6 +55,16 @@ public JoinableTaskCollection(JoinableTaskContext context, bool refCountAddedJob this.refCountAddedJobs = refCountAddedJobs; } + /// + /// Occurs when is called. + /// + /// + /// This event is raised before the method begins waiting for the collection to empty. + /// It may be used to signal cancellation to tasks in the collection so they can complete + /// and allow the collection to drain. + /// + internal event EventHandler? JoinTillEmptyAsyncRequested; + /// /// Gets the to which this collection belongs. /// @@ -160,6 +170,8 @@ public JoinRelease Join() /// public async Task JoinTillEmptyAsync(CancellationToken cancellationToken) { + this.JoinTillEmptyAsyncRequested?.Invoke(this, EventArgs.Empty); + cancellationToken.ThrowIfCancellationRequested(); if (this.emptyEvent is null) diff --git a/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs new file mode 100644 index 000000000..38d2ad386 --- /dev/null +++ b/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; + +namespace Microsoft.VisualStudio.Threading; + +/// +/// A that exposes a which is canceled +/// when the underlying begins draining via +/// , or when this factory is disposed. +/// +/// +/// This allows code that has access to the factory to observe shutdown without +/// requiring a separate to be plumbed through call sites. +/// +public class SelfCancellingJoinableTaskFactory : DelegatingJoinableTaskFactory, IDisposable +{ + /// + /// The source for . + /// + private readonly CancellationTokenSource cancellationTokenSource; + + /// + /// A value indicating whether this instance has been disposed. + /// + private bool disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The inner factory to delegate to. Must have a non-null . + public SelfCancellingJoinableTaskFactory(JoinableTaskFactory innerFactory) + : base(Requires.NotNull(innerFactory, nameof(innerFactory))) + { + Assumes.NotNull(innerFactory.Collection); + this.cancellationTokenSource = new CancellationTokenSource(); + this.DisposalToken = this.cancellationTokenSource.Token; + innerFactory.Collection!.JoinTillEmptyAsyncRequested += this.OnJoinTillEmptyAsyncRequested; + } + + /// + /// Gets a that is canceled when the underlying collection + /// begins draining or when this factory is disposed. + /// + public CancellationToken DisposalToken { get; private init; } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes managed and unmanaged resources held by this instance. + /// + /// if was called; if the object is being finalized. + protected virtual void Dispose(bool disposing) + { + if (disposing && !this.disposed) + { + this.disposed = true; + + if (this.Collection is object) + { + this.Collection.JoinTillEmptyAsyncRequested -= this.OnJoinTillEmptyAsyncRequested; + } + + this.cancellationTokenSource.Cancel(); + this.cancellationTokenSource.Dispose(); + } + } + + private void OnJoinTillEmptyAsyncRequested(object? sender, EventArgs e) + { + this.cancellationTokenSource.Cancel(); + } +} diff --git a/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs new file mode 100644 index 000000000..f0ea7620c --- /dev/null +++ b/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; + +public class SelfCancellingJoinableTaskFactoryTests : TestBase +{ + private readonly JoinableTaskContext context; + private readonly JoinableTaskCollection joinableCollection; + private readonly JoinableTaskFactory asyncPump; + + public SelfCancellingJoinableTaskFactoryTests(ITestOutputHelper logger) + : base(logger) + { + this.context = JoinableTaskContext.CreateNoOpContext(); + this.joinableCollection = this.context.CreateCollection(); + this.asyncPump = this.context.CreateFactory(this.joinableCollection); + } + + [Fact] + public void CancelledOnDispose() + { + var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); + + factory.Dispose(); + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public async Task CancelledOnJoinTillEmptyAsync() + { + using var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); + + // The collection is already empty, so this completes immediately. + await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public async Task MultipleJoinTillEmptyCallsDoNotThrow() + { + using var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); + + await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); + await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public void MultipleDisposeDoesNotThrow() + { + var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); + factory.Dispose(); + factory.Dispose(); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public void ConstructorRequiresCollection() + { + // A factory created with just a context has no collection. + JoinableTaskFactory factoryWithoutCollection = this.context.Factory; + Assert.ThrowsAny(() => new SelfCancellingJoinableTaskFactory(factoryWithoutCollection)); + } + + [Fact] + public async Task TaskObservesCancellationDuringDrain() + { + using SelfCancellingJoinableTaskFactory factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); + AsyncManualResetEvent taskStarted = new AsyncManualResetEvent(); + + JoinableTask jt = factory.RunAsync(async delegate + { + taskStarted.Set(); + try + { + await Task.Delay(UnexpectedTimeout, factory.DisposalToken); + Assert.Fail("Task was not cancelled by factory disposal token before UnexpectedTimeout"); + } + catch (OperationCanceledException) + { + } + }); + + await taskStarted.WaitAsync().WithTimeout(UnexpectedTimeout); + await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); + Assert.True(jt.IsCompleted); + } +} From 24f02784a4f7680359dc14d88d8687646ab68644 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 10 Feb 2026 10:43:19 -0700 Subject: [PATCH 2/3] Add `DisposableJoinableTaskFactory` --- .../DelegatingJoinableTaskFactory.cs | 4 - .../DisposableJoinableTaskFactory.cs | 78 +++++++++++++++++++ .../JoinableTaskFactory.cs | 2 +- 3 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs diff --git a/src/Microsoft.VisualStudio.Threading/DelegatingJoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/DelegatingJoinableTaskFactory.cs index a17438de7..ae804ddcf 100644 --- a/src/Microsoft.VisualStudio.Threading/DelegatingJoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/DelegatingJoinableTaskFactory.cs @@ -1,10 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs new file mode 100644 index 000000000..af52b7f07 --- /dev/null +++ b/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.VisualStudio.Threading.Tests; + +/// +/// A variant on that tracks pending tasks and blocks disposal until all tasks have completed. +/// A cancellation token is provided so pending tasks can cooperatively cancel when disposal is requested. +/// +/// +/// +/// Cancellation of pending tasks is cooperative. +/// If a pending task does not observe , then disposal may take longer to complete, +/// or even never complete if a pending task never completes. +/// +/// +/// Creating tasks after disposal has been requested is not prevented by this class. +/// +/// +public class DisposableJoinableTaskFactory : DelegatingJoinableTaskFactory, IDisposable, System.IAsyncDisposable +{ + private readonly CancellationTokenSource disposalTokenSource = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The factory instance to be wrapped. Must have an associated collection. + public DisposableJoinableTaskFactory(JoinableTaskFactory innerFactory) + : base(innerFactory) + { + Requires.Argument(this.Collection is not null, nameof(innerFactory), "A collection must be associated with the factory."); + } + + /// + /// Initializes a new instance of the class. + /// + /// The used to construct the . + /// + /// This constructor creates a using . + /// + public DisposableJoinableTaskFactory(JoinableTaskContext joinableTaskContext) + : this(Requires.NotNull(joinableTaskContext).CreateFactory(Requires.NotNull(joinableTaskContext).CreateCollection())) + { + } + + /// + /// Gets a disposal token that should be used by tasks created by this factory to know when they should stop doing work. + /// + /// + /// This token is canceled when the factory is disposed. + /// + public CancellationToken DisposalToken => this.disposalTokenSource.Token; + + /// + protected new JoinableTaskCollection Collection => base.Collection!; + + /// + public void Dispose() + { + this.disposalTokenSource.Cancel(); + this.disposalTokenSource.Dispose(); + + this.Context.Factory.Run(() => this.Collection.JoinTillEmptyAsync()); + } + + /// + public async ValueTask DisposeAsync() + { + this.disposalTokenSource.Cancel(); + this.disposalTokenSource.Dispose(); + + await this.Collection.JoinTillEmptyAsync(); + } +} diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs index 3417adb3a..b1bfb5336 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskFactory.cs @@ -93,7 +93,7 @@ internal SynchronizationContext? ApplicableJobSyncContext /// /// Gets the collection to which created tasks belong until they complete. May be null. /// - internal JoinableTaskCollection? Collection + protected internal JoinableTaskCollection? Collection { get { return this.jobCollection; } } From 121be2a351aead4e842321fdc504db29df7c3a8a Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Tue, 10 Feb 2026 11:16:01 -0700 Subject: [PATCH 3/3] Mix tests from @vivlimmsft's #1539 PR with an alternate design for a cancelable JTF --- .../DisposableJoinableTaskFactory.cs | 25 +++-- .../JoinableTaskCollection.cs | 12 --- .../SelfCancellingJoinableTaskFactory.cs | 80 ---------------- .../DisposableJoinableTaskFactoryTests.cs | 84 +++++++++++++++++ .../SelfCancellingJoinableTaskFactoryTests.cs | 93 ------------------- 5 files changed, 102 insertions(+), 192 deletions(-) delete mode 100644 src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs create mode 100644 test/Microsoft.VisualStudio.Threading.Tests/DisposableJoinableTaskFactoryTests.cs delete mode 100644 test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs diff --git a/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs index af52b7f07..6124ac8fc 100644 --- a/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs +++ b/src/Microsoft.VisualStudio.Threading/DisposableJoinableTaskFactory.cs @@ -5,7 +5,7 @@ using System.Threading; using System.Threading.Tasks; -namespace Microsoft.VisualStudio.Threading.Tests; +namespace Microsoft.VisualStudio.Threading; /// /// A variant on that tracks pending tasks and blocks disposal until all tasks have completed. @@ -33,6 +33,9 @@ public DisposableJoinableTaskFactory(JoinableTaskFactory innerFactory) : base(innerFactory) { Requires.Argument(this.Collection is not null, nameof(innerFactory), "A collection must be associated with the factory."); + + // Get it now, since after the CTS is disposed, it throws when we try to access the token. + this.DisposalToken = this.disposalTokenSource.Token; } /// @@ -53,16 +56,21 @@ public DisposableJoinableTaskFactory(JoinableTaskContext joinableTaskContext) /// /// This token is canceled when the factory is disposed. /// - public CancellationToken DisposalToken => this.disposalTokenSource.Token; + public CancellationToken DisposalToken { get; } - /// + /// + /// Gets the collection to which created tasks belong until they complete. + /// protected new JoinableTaskCollection Collection => base.Collection!; /// public void Dispose() { - this.disposalTokenSource.Cancel(); - this.disposalTokenSource.Dispose(); + if (!this.disposalTokenSource.IsCancellationRequested) + { + this.disposalTokenSource.Cancel(); + this.disposalTokenSource.Dispose(); + } this.Context.Factory.Run(() => this.Collection.JoinTillEmptyAsync()); } @@ -70,8 +78,11 @@ public void Dispose() /// public async ValueTask DisposeAsync() { - this.disposalTokenSource.Cancel(); - this.disposalTokenSource.Dispose(); + if (!this.disposalTokenSource.IsCancellationRequested) + { + this.disposalTokenSource.Cancel(); + this.disposalTokenSource.Dispose(); + } await this.Collection.JoinTillEmptyAsync(); } diff --git a/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs b/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs index 345b7d2b6..e929d97b6 100644 --- a/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs +++ b/src/Microsoft.VisualStudio.Threading/JoinableTaskCollection.cs @@ -55,16 +55,6 @@ public JoinableTaskCollection(JoinableTaskContext context, bool refCountAddedJob this.refCountAddedJobs = refCountAddedJobs; } - /// - /// Occurs when is called. - /// - /// - /// This event is raised before the method begins waiting for the collection to empty. - /// It may be used to signal cancellation to tasks in the collection so they can complete - /// and allow the collection to drain. - /// - internal event EventHandler? JoinTillEmptyAsyncRequested; - /// /// Gets the to which this collection belongs. /// @@ -170,8 +160,6 @@ public JoinRelease Join() /// public async Task JoinTillEmptyAsync(CancellationToken cancellationToken) { - this.JoinTillEmptyAsyncRequested?.Invoke(this, EventArgs.Empty); - cancellationToken.ThrowIfCancellationRequested(); if (this.emptyEvent is null) diff --git a/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs b/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs deleted file mode 100644 index 38d2ad386..000000000 --- a/src/Microsoft.VisualStudio.Threading/SelfCancellingJoinableTaskFactory.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Threading; - -namespace Microsoft.VisualStudio.Threading; - -/// -/// A that exposes a which is canceled -/// when the underlying begins draining via -/// , or when this factory is disposed. -/// -/// -/// This allows code that has access to the factory to observe shutdown without -/// requiring a separate to be plumbed through call sites. -/// -public class SelfCancellingJoinableTaskFactory : DelegatingJoinableTaskFactory, IDisposable -{ - /// - /// The source for . - /// - private readonly CancellationTokenSource cancellationTokenSource; - - /// - /// A value indicating whether this instance has been disposed. - /// - private bool disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The inner factory to delegate to. Must have a non-null . - public SelfCancellingJoinableTaskFactory(JoinableTaskFactory innerFactory) - : base(Requires.NotNull(innerFactory, nameof(innerFactory))) - { - Assumes.NotNull(innerFactory.Collection); - this.cancellationTokenSource = new CancellationTokenSource(); - this.DisposalToken = this.cancellationTokenSource.Token; - innerFactory.Collection!.JoinTillEmptyAsyncRequested += this.OnJoinTillEmptyAsyncRequested; - } - - /// - /// Gets a that is canceled when the underlying collection - /// begins draining or when this factory is disposed. - /// - public CancellationToken DisposalToken { get; private init; } - - /// - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Disposes managed and unmanaged resources held by this instance. - /// - /// if was called; if the object is being finalized. - protected virtual void Dispose(bool disposing) - { - if (disposing && !this.disposed) - { - this.disposed = true; - - if (this.Collection is object) - { - this.Collection.JoinTillEmptyAsyncRequested -= this.OnJoinTillEmptyAsyncRequested; - } - - this.cancellationTokenSource.Cancel(); - this.cancellationTokenSource.Dispose(); - } - } - - private void OnJoinTillEmptyAsyncRequested(object? sender, EventArgs e) - { - this.cancellationTokenSource.Cancel(); - } -} diff --git a/test/Microsoft.VisualStudio.Threading.Tests/DisposableJoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/DisposableJoinableTaskFactoryTests.cs new file mode 100644 index 000000000..0b54c85fb --- /dev/null +++ b/test/Microsoft.VisualStudio.Threading.Tests/DisposableJoinableTaskFactoryTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; + +public class DisposableJoinableTaskFactoryTests : TestBase +{ + private readonly JoinableTaskContext context; + private readonly JoinableTaskCollection joinableCollection; + private readonly JoinableTaskFactory asyncPump; + + public DisposableJoinableTaskFactoryTests(ITestOutputHelper logger) + : base(logger) + { + this.context = JoinableTaskContext.CreateNoOpContext(); + this.joinableCollection = this.context.CreateCollection(); + this.asyncPump = this.context.CreateFactory(this.joinableCollection); + } + + [Fact] + public void DisposeCancelsToken() + { + DisposableJoinableTaskFactory factory = new(this.asyncPump); + + // The collection is already empty, so this completes immediately. + factory.Dispose(); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public async Task DisposeAsyncCancelsToken() + { + DisposableJoinableTaskFactory factory = new(this.asyncPump); + + // The collection is already empty, so this completes immediately. + await factory.DisposeAsync(); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public void MultipleDisposalsDoNotThrow() + { + using DisposableJoinableTaskFactory factory = new(this.asyncPump); + + factory.Dispose(); + factory.Dispose(); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public async Task MultipleDisposeAsyncsDoesNotThrow() + { + DisposableJoinableTaskFactory factory = new(this.asyncPump); + + await factory.DisposeAsync(); + await factory.DisposeAsync(); + + Assert.True(factory.DisposalToken.IsCancellationRequested); + } + + [Fact] + public void ConstructorRequiresCollection() + { + // A factory created with just a context has no collection. + JoinableTaskFactory factoryWithoutCollection = this.context.Factory; + Assert.ThrowsAny(() => new DisposableJoinableTaskFactory(factoryWithoutCollection)); + } + + [Fact] + public async Task TaskObservesCancellationDuringDrain() + { + DisposableJoinableTaskFactory factory = new(this.asyncPump); + + JoinableTask jt = factory.RunAsync(() => Task.Delay(UnexpectedTimeout, factory.DisposalToken)); + + await factory.DisposeAsync().AsTask().WithTimeout(UnexpectedTimeout); + Assert.True(jt.IsCompleted); + await Assert.ThrowsAsync(async () => await jt); + } +} diff --git a/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs b/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs deleted file mode 100644 index f0ea7620c..000000000 --- a/test/Microsoft.VisualStudio.Threading.Tests/SelfCancellingJoinableTaskFactoryTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Threading.Tasks; - -public class SelfCancellingJoinableTaskFactoryTests : TestBase -{ - private readonly JoinableTaskContext context; - private readonly JoinableTaskCollection joinableCollection; - private readonly JoinableTaskFactory asyncPump; - - public SelfCancellingJoinableTaskFactoryTests(ITestOutputHelper logger) - : base(logger) - { - this.context = JoinableTaskContext.CreateNoOpContext(); - this.joinableCollection = this.context.CreateCollection(); - this.asyncPump = this.context.CreateFactory(this.joinableCollection); - } - - [Fact] - public void CancelledOnDispose() - { - var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); - - factory.Dispose(); - Assert.True(factory.DisposalToken.IsCancellationRequested); - } - - [Fact] - public async Task CancelledOnJoinTillEmptyAsync() - { - using var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); - - // The collection is already empty, so this completes immediately. - await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); - - Assert.True(factory.DisposalToken.IsCancellationRequested); - } - - [Fact] - public async Task MultipleJoinTillEmptyCallsDoNotThrow() - { - using var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); - - await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); - await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); - - Assert.True(factory.DisposalToken.IsCancellationRequested); - } - - [Fact] - public void MultipleDisposeDoesNotThrow() - { - var factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); - factory.Dispose(); - factory.Dispose(); - - Assert.True(factory.DisposalToken.IsCancellationRequested); - } - - [Fact] - public void ConstructorRequiresCollection() - { - // A factory created with just a context has no collection. - JoinableTaskFactory factoryWithoutCollection = this.context.Factory; - Assert.ThrowsAny(() => new SelfCancellingJoinableTaskFactory(factoryWithoutCollection)); - } - - [Fact] - public async Task TaskObservesCancellationDuringDrain() - { - using SelfCancellingJoinableTaskFactory factory = new SelfCancellingJoinableTaskFactory(this.asyncPump); - AsyncManualResetEvent taskStarted = new AsyncManualResetEvent(); - - JoinableTask jt = factory.RunAsync(async delegate - { - taskStarted.Set(); - try - { - await Task.Delay(UnexpectedTimeout, factory.DisposalToken); - Assert.Fail("Task was not cancelled by factory disposal token before UnexpectedTimeout"); - } - catch (OperationCanceledException) - { - } - }); - - await taskStarted.WaitAsync().WithTimeout(UnexpectedTimeout); - await this.joinableCollection.JoinTillEmptyAsync().WithTimeout(UnexpectedTimeout); - Assert.True(jt.IsCompleted); - } -}