From b34ad3961f34e29a907d384431414f37124ac44f Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:31:03 +0000 Subject: [PATCH 01/10] Fix thread-flow disposal task settlement race --- .../TaskSchedulerEnqueueExtensionsFixture.cs | 63 +++++++++++ .../ThreadTaskFlowDisposalFixture.cs | 104 ++++++++++++++++++ TaskFlow/TaskSchedulerEnqueueExtensions.cs | 44 ++++---- TaskFlow/ThreadTaskFlow.cs | 45 +++----- 4 files changed, 205 insertions(+), 51 deletions(-) create mode 100644 TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs create mode 100644 TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs diff --git a/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs new file mode 100644 index 0000000..420269f --- /dev/null +++ b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs @@ -0,0 +1,63 @@ +namespace TaskFlow.Tests; + +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +[TestFixture] +internal sealed class TaskSchedulerEnqueueExtensionsFixture +{ + [TestCase("Action")] + [TestCase("ActionWithToken")] + [TestCase("TokenAction")] + [TestCase("TokenActionWithToken")] + [TestCase("Task")] + [TestCase("TokenTask")] + [TestCase("TokenTaskWithToken")] + [TestCase("StateTask")] + [TestCase("ValueTask")] + [TestCase("TokenValueTask")] + [TestCase("TokenValueTaskWithToken")] + [TestCase("StateValueTask")] + public void Enqueue_ShouldReturnSchedulerTaskWithoutAdditionalContinuations(string overload) + { + var scheduler = new PendingScheduler(); + using var cancellation = new CancellationTokenSource(); + var task = overload switch + { + "Action" => scheduler.Enqueue(() => { }), + "ActionWithToken" => scheduler.Enqueue(() => { }, cancellation.Token), + "TokenAction" => scheduler.Enqueue(token => { }), + "TokenActionWithToken" => scheduler.Enqueue(token => { }, cancellation.Token), + "Task" => scheduler.Enqueue(() => Task.CompletedTask), + "TokenTask" => scheduler.Enqueue(token => Task.CompletedTask), + "TokenTaskWithToken" => scheduler.Enqueue(token => Task.CompletedTask, cancellation.Token), + "StateTask" => scheduler.Enqueue((int state, CancellationToken token) => Task.CompletedTask, 42, cancellation.Token), + "ValueTask" => scheduler.Enqueue(() => ValueTask.CompletedTask), + "TokenValueTask" => scheduler.Enqueue(token => ValueTask.CompletedTask), + "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTask.CompletedTask, cancellation.Token, null), + "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTask.CompletedTask, 42, cancellation.Token), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + + // An extra async wrapper could still be pending after the scheduler has drained its queue. + Assert.That(task, Is.SameAs(scheduler.ScheduledTask)); + Assert.That(task.IsCompleted, Is.False); + scheduler.Complete!(); + Assert.That(task.IsCompletedSuccessfully, Is.True); + } + + private sealed class PendingScheduler : ITaskScheduler + { + public Task? ScheduledTask { get; private set; } + + public Action? Complete { get; private set; } + + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ScheduledTask = completion.Task; + Complete = () => completion.SetResult(default!); + return completion.Task; + } + } +} \ No newline at end of file diff --git a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs new file mode 100644 index 0000000..f61ce46 --- /dev/null +++ b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs @@ -0,0 +1,104 @@ +namespace TaskFlow.Tests; + +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +[TestFixture] +[CancelAfter(10000)] +internal sealed class ThreadTaskFlowDisposalFixture +{ + private static readonly int[] ExpectedExecutionOrder = [1, 4, 2, 3]; + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public async Task DisposeAsync_ShouldSettleCallerTasksBeforeReturning(bool currentThread, bool asynchronous) + { + await using var sut = CreateFlow(currentThread); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lastStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var lastRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var failure = new InvalidOperationException("Queued failure"); + var executed = new List(); + var observedCancellation = CancellationToken.None; + var first = sut.Enqueue(async _ => + { + started.SetResult(); + await release.Task; + }); + + Task Result(object? state, CancellationToken token) => RunAsync(1, token); + Task Fault(CancellationToken token) => RunAsync(2, token); + async ValueTask Canceled(CancellationToken token) + { + observedCancellation = token; + await RunAsync(3, token); + } + async Task RunAsync(int value, CancellationToken token) + { + if (asynchronous) + await Task.Yield(); + executed.Add(value); + if (value == 2) + throw failure; + if (value == 3) + token.ThrowIfCancellationRequested(); + return value; + } + + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var result = sut.Enqueue((state, token) => new ValueTask(Result(state, token)), null, CancellationToken.None); + var action = sut.Enqueue(() => executed.Add(4)); + var fault = sut.Enqueue(Fault); + var canceled = sut.Enqueue(Canceled, cancellation.Token, null); + var last = sut.Enqueue(async _ => + { + lastStarted.SetResult(); + await lastRelease.Task; + }); + await cancellation.CancelAsync(); + + var disposal = sut.DisposeAsync().AsTask(); + Assert.That(disposal.IsCompleted, Is.False); + release.SetResult(); + await lastStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(disposal.IsCompleted, Is.False, "Disposal must wait for the last asynchronous operation."); + lastRelease.SetResult(); + await disposal.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(first.IsCompletedSuccessfully, Is.True); + Assert.That(result.IsCompletedSuccessfully, Is.True); + Assert.That(action.IsCompletedSuccessfully, Is.True); + Assert.That(fault.IsFaulted, Is.True); + Assert.That(canceled.IsCanceled, Is.True); + Assert.That(last.IsCompletedSuccessfully, Is.True); + Assert.That(executed, Is.EqualTo(ExpectedExecutionOrder)); + } + Assert.That(await result, Is.EqualTo(1)); + await Assert.ThatAsync(async () => await fault, Throws.Exception.SameAs(failure)); + await Assert.ThatAsync(async () => await canceled, + Throws.InstanceOf().With.Property("CancellationToken").EqualTo(observedCancellation)); + } + finally + { + release.TrySetResult(); + lastRelease.TrySetResult(); + } + } + + private static ThreadTaskFlow CreateFlow(bool currentThread) + { + if (!currentThread) + return new DedicatedThreadTaskFlow(); + var flow = new CurrentThreadTaskFlow(); + new Thread(flow.Run) { IsBackground = true }.Start(); + return flow; + } +} \ No newline at end of file diff --git a/TaskFlow/TaskSchedulerEnqueueExtensions.cs b/TaskFlow/TaskSchedulerEnqueueExtensions.cs index 33df8b2..412f69f 100644 --- a/TaskFlow/TaskSchedulerEnqueueExtensions.cs +++ b/TaskFlow/TaskSchedulerEnqueueExtensions.cs @@ -245,11 +245,11 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// This method wraps the non-generic in a with a /// result to match the scheduler's signature requirement. /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, CancellationToken cancellationToken) + public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue(TaskFunc, cancellationToken).ConfigureAwait(false); + return taskScheduler.Enqueue(TaskFunc, cancellationToken); async ValueTask TaskFunc(CancellationToken token) { @@ -269,11 +269,11 @@ async ValueTask TaskFunc(CancellationToken token) /// /// This method uses for the cancellation token. /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc) + public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue(taskFunc, CancellationToken.None).ConfigureAwait(false); + return taskScheduler.Enqueue(taskFunc, CancellationToken.None); } /// @@ -353,11 +353,11 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFun /// to avoid method signature conflicts with other overloads. /// [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] - public static async Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, CancellationToken cancellationToken, DummyParameter? _ = null) + public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, CancellationToken cancellationToken, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue(TaskFunc, cancellationToken).ConfigureAwait(false); + return taskScheduler.Enqueue(TaskFunc, cancellationToken); async ValueTask TaskFunc(CancellationToken token) { @@ -382,11 +382,11 @@ async ValueTask TaskFunc(CancellationToken token) /// overload; use a named function or an explicit delegate cast. /// [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] - public static async Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) + public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue(valueTaskFunc, CancellationToken.None).ConfigureAwait(false); + return taskScheduler.Enqueue(valueTaskFunc, CancellationToken.None, _); } /// @@ -469,18 +469,17 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func va /// This method wraps the synchronous action in a to match the scheduler's /// asynchronous interface. /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) + public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue( + return taskScheduler.Enqueue( token => { action(token); return new ValueTask(Task.CompletedTask); }, - cancellationToken) - .ConfigureAwait(false); + cancellationToken); } /// @@ -495,18 +494,17 @@ await taskScheduler.Enqueue( /// This method uses for the cancellation token and wraps the /// synchronous action in a . /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Action action) + public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue( + return taskScheduler.Enqueue( token => { action(token); return new ValueTask(Task.CompletedTask); }, - CancellationToken.None) - .ConfigureAwait(false); + CancellationToken.None); } /// @@ -521,18 +519,17 @@ await taskScheduler.Enqueue( /// This method uses for the cancellation token, ignores the /// cancellation token parameter when calling the action, and wraps the synchronous action in a . /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Action action) + public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue( + return taskScheduler.Enqueue( _ => { action(); return new ValueTask(Task.CompletedTask); }, - CancellationToken.None) - .ConfigureAwait(false); + CancellationToken.None); } /// @@ -548,18 +545,17 @@ await taskScheduler.Enqueue( /// This method ignores the cancellation token parameter when calling the action and wraps the /// synchronous action in a . /// - public static async Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) + public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); - await taskScheduler.Enqueue( + return taskScheduler.Enqueue( _ => { action(); return new ValueTask(Task.CompletedTask); }, - cancellationToken) - .ConfigureAwait(false); + cancellationToken); } /// diff --git a/TaskFlow/ThreadTaskFlow.cs b/TaskFlow/ThreadTaskFlow.cs index f1c367f..483c819 100644 --- a/TaskFlow/ThreadTaskFlow.cs +++ b/TaskFlow/ThreadTaskFlow.cs @@ -90,20 +90,19 @@ protected ThreadTaskFlow(TaskFlowOptions options) /// be canceled so queue progression remains deterministic. /// /// - public override async Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + public override Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { Argument.NotNull(taskFunc); - ExecutionItem executionItem; lock (ThisLock) { CheckDisposed(); - executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); + var executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); _blockingCollection.Add(executionItem, CancellationToken.None); + return executionItem.Task; } - return await executionItem.GetTypedTask().ConfigureAwait(false); - async Task TaskFunc(object? stateObject, CancellationToken token) + async Task TaskFunc(object? stateObject, CancellationToken token) { var result = await taskFunc(stateObject, token).ConfigureAwait(false); return result; @@ -206,13 +205,19 @@ protected void ThreadStart(object? _) /// This class encapsulates the task function, state, and completion source for an enqueued task. /// It handles the execution of the task and propagation of results and exceptions. /// - private sealed class ExecutionItem + private abstract class ExecutionItem { - private readonly Func> _taskFunc; + public abstract void Execute(CancellationToken cancellationToken); + } + + // Complete the caller-facing typed task before Execute returns and the queue can shut down. + private sealed class ExecutionItem : ExecutionItem + { + private readonly Func> _taskFunc; private readonly object? _state; private readonly SynchronizationContext? _synchronizationContext; private readonly CancellationToken _cancellationToken; - private readonly TaskCompletionSource _taskCompletionSource; + private readonly TaskCompletionSource _taskCompletionSource; /// /// Initializes a new instance of the class. @@ -222,7 +227,7 @@ private sealed class ExecutionItem /// The synchronization context to use for continuations. /// A cancellation token that can be used to cancel the operation. public ExecutionItem( - Func> taskFunc, + Func> taskFunc, object? state, SynchronizationContext? synchronizationContext, CancellationToken cancellationToken) @@ -231,19 +236,14 @@ public ExecutionItem( _state = state; _synchronizationContext = synchronizationContext; _cancellationToken = cancellationToken; - _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } /// /// Gets a task that represents the completion of this execution item with the expected result type. /// - /// The expected result type. /// A task that completes with the result of the execution item. - public async Task GetTypedTask() - { - var result = await _taskCompletionSource.Task.ConfigureAwait(false); - return (T)result!; - } + public Task Task => _taskCompletionSource.Task; /// /// Executes the task function and handles the result. @@ -251,7 +251,7 @@ public async Task GetTypedTask() /// A cancellation token that can be used to cancel the operation. [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Exception propagated to TaskCompletionSource")] [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "linkedCts disposed after awaiter is done")] - public void Execute(CancellationToken cancellationToken) + public override void Execute(CancellationToken cancellationToken) { var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cancellationToken); var blockingCollection = new BlockingCollection<(SendOrPostCallback Callback, object? State)>(); @@ -285,7 +285,7 @@ public void Execute(CancellationToken cancellationToken) return; - void HandleAwaiterCompletion(TaskAwaiter awaiter) + void HandleAwaiterCompletion(TaskAwaiter awaiter) { try { @@ -305,15 +305,6 @@ void HandleAwaiterCompletion(TaskAwaiter awaiter) } } } - - /// - /// Cancels the execution of this item. - /// - /// The cancellation token that caused the cancellation. - public void Cancel(CancellationToken token) - { - _taskCompletionSource.TrySetCanceled(token); - } } /// From 82283f4c77ec35626c2a67e6d791726d7e72e976 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:51:17 +0000 Subject: [PATCH 02/10] Preserve async enqueue error semantics after disposal fix --- .../TaskSchedulerEnqueueExtensionsFixture.cs | 81 ++++++++++++++--- .../ThreadTaskFlowDisposalFixture.cs | 23 +++++ TaskFlow/Internal/TaskExtensions.cs | 9 ++ TaskFlow/TaskSchedulerEnqueueExtensions.cs | 86 ++++++++----------- TaskFlow/ThreadTaskFlow.cs | 20 +++-- 5 files changed, 151 insertions(+), 68 deletions(-) diff --git a/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs index 420269f..f5f6e05 100644 --- a/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs @@ -1,11 +1,22 @@ namespace TaskFlow.Tests; using NUnit.Framework; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Flow; [TestFixture] internal sealed class TaskSchedulerEnqueueExtensionsFixture { + private static readonly string[] OriginallyAsyncOverloads = + [ + "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", + "TokenTask", "TokenTaskWithToken", "TokenValueTask", "TokenValueTaskWithToken", + ]; + private static readonly string[] AsyncFailureOverloads = + [ + .. OriginallyAsyncOverloads, "Task", "ValueTask", + ]; + [TestCase("Action")] [TestCase("ActionWithToken")] [TestCase("TokenAction")] @@ -22,28 +33,70 @@ public void Enqueue_ShouldReturnSchedulerTaskWithoutAdditionalContinuations(stri { var scheduler = new PendingScheduler(); using var cancellation = new CancellationTokenSource(); - var task = overload switch + var task = Enqueue(scheduler, overload, cancellation.Token); + + // An extra async wrapper could still be pending after the scheduler has drained its queue. + Assert.That(task, Is.SameAs(scheduler.ScheduledTask)); + Assert.That(task.IsCompleted, Is.False); + scheduler.Complete!(); + Assert.That(task.IsCompletedSuccessfully, Is.True); + } + + [Test] + public async Task Enqueue_InvalidScheduler_ShouldReturnFaultedTask([ValueSource(nameof(OriginallyAsyncOverloads))] string overload) + { + Task? task = null; + Assert.That(() => { task = Enqueue(null!, overload, CancellationToken.None); }, Throws.Nothing); + Assert.That(task!.IsFaulted, Is.True); + await Assert.ThatAsync(async () => await task, Throws.TypeOf()); + } + + [Test] + public async Task Enqueue_SynchronousSchedulingFailure_ShouldSettleReturnedTask( + [ValueSource(nameof(AsyncFailureOverloads))] string overload, + [Values("failure", "cancellation", "uncanceledCancellation")] string outcome) + { + using var cancellation = new CancellationTokenSource(); + if (outcome == "cancellation") + await cancellation.CancelAsync(); + Exception failure = outcome == "failure" + ? new InvalidOperationException("Scheduling failed") + : new OperationCanceledException("Scheduling canceled", cancellation.Token); + var scheduler = new ThrowingScheduler(failure); + Task? task = null; + Assert.That(() => { task = Enqueue(scheduler, overload, cancellation.Token); }, Throws.Nothing); + Assert.That(task!.IsCanceled, Is.EqualTo(outcome != "failure")); + Assert.That(task.IsFaulted, Is.EqualTo(outcome == "failure")); + await Assert.ThatAsync(async () => await task, Throws.Exception.SameAs(failure)); + } + + [SuppressMessage("Reliability", "CA2016:Forward the CancellationToken parameter to methods that take one", Justification = "The cases intentionally exercise overloads without a cancellation token.")] + private static Task Enqueue(ITaskScheduler scheduler, string overload, CancellationToken cancellationToken) + { + return overload switch { "Action" => scheduler.Enqueue(() => { }), - "ActionWithToken" => scheduler.Enqueue(() => { }, cancellation.Token), + "ActionWithToken" => scheduler.Enqueue(() => { }, cancellationToken), "TokenAction" => scheduler.Enqueue(token => { }), - "TokenActionWithToken" => scheduler.Enqueue(token => { }, cancellation.Token), + "TokenActionWithToken" => scheduler.Enqueue(token => { }, cancellationToken), "Task" => scheduler.Enqueue(() => Task.CompletedTask), "TokenTask" => scheduler.Enqueue(token => Task.CompletedTask), - "TokenTaskWithToken" => scheduler.Enqueue(token => Task.CompletedTask, cancellation.Token), - "StateTask" => scheduler.Enqueue((int state, CancellationToken token) => Task.CompletedTask, 42, cancellation.Token), - "ValueTask" => scheduler.Enqueue(() => ValueTask.CompletedTask), - "TokenValueTask" => scheduler.Enqueue(token => ValueTask.CompletedTask), - "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTask.CompletedTask, cancellation.Token, null), - "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTask.CompletedTask, 42, cancellation.Token), + "TokenTaskWithToken" => scheduler.Enqueue(token => Task.CompletedTask, cancellationToken), + "StateTask" => scheduler.Enqueue((int state, CancellationToken token) => Task.CompletedTask, 42, cancellationToken), + "ValueTask" => scheduler.Enqueue(() => ValueTask.CompletedTask, null), + "TokenValueTask" => scheduler.Enqueue(token => ValueTask.CompletedTask, null), + "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTask.CompletedTask, cancellationToken, null), + "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTask.CompletedTask, 42, cancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(overload)), }; + } - // An extra async wrapper could still be pending after the scheduler has drained its queue. - Assert.That(task, Is.SameAs(scheduler.ScheduledTask)); - Assert.That(task.IsCompleted, Is.False); - scheduler.Complete!(); - Assert.That(task.IsCompletedSuccessfully, Is.True); + private sealed class ThrowingScheduler(Exception failure) : ITaskScheduler + { + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + { + throw failure; + } } private sealed class PendingScheduler : ITaskScheduler diff --git a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs index f61ce46..1f96fff 100644 --- a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs +++ b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs @@ -93,6 +93,29 @@ await Assert.ThatAsync(async () => await canceled, } } + [TestCase(false)] + [TestCase(true)] + public async Task Enqueue_AfterDisposal_ShouldReturnFaultedTask(bool currentThread) + { + await using var sut = CreateFlow(currentThread); + await sut.DisposeAsync(); + Task? task = null; + Assert.That(() => { task = sut.Enqueue((state, token) => new ValueTask(42), null, CancellationToken.None); }, Throws.Nothing); + Assert.That(task!.IsFaulted, Is.True); + await Assert.ThatAsync(async () => await task, Throws.TypeOf()); + } + + [TestCase(false)] + [TestCase(true)] + public async Task Enqueue_NullDelegate_ShouldReturnFaultedTask(bool currentThread) + { + await using var sut = CreateFlow(currentThread); + Task? task = null; + Assert.That(() => { task = sut.Enqueue(null!, null, CancellationToken.None); }, Throws.Nothing); + Assert.That(task!.IsFaulted, Is.True); + await Assert.ThatAsync(async () => await task, Throws.TypeOf()); + } + private static ThreadTaskFlow CreateFlow(bool currentThread) { if (!currentThread) diff --git a/TaskFlow/Internal/TaskExtensions.cs b/TaskFlow/Internal/TaskExtensions.cs index c0ab1a6..87cbb30 100644 --- a/TaskFlow/Internal/TaskExtensions.cs +++ b/TaskFlow/Internal/TaskExtensions.cs @@ -3,6 +3,7 @@ namespace System.Threading.Tasks.Flow.Internal; using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -10,6 +11,14 @@ namespace System.Threading.Tasks.Flow.Internal; internal static class TaskExtensions { + public static Task FromAsyncException(Exception exception) + { + // Keep the async method builder's original exception and cancellation-token semantics. + var completion = AsyncTaskMethodBuilder.Create(); + completion.SetException(exception); + return completion.Task; + } + public static void Await(this Task task) { Argument.NotNull(task); diff --git a/TaskFlow/TaskSchedulerEnqueueExtensions.cs b/TaskFlow/TaskSchedulerEnqueueExtensions.cs index 412f69f..831cecb 100644 --- a/TaskFlow/TaskSchedulerEnqueueExtensions.cs +++ b/TaskFlow/TaskSchedulerEnqueueExtensions.cs @@ -247,11 +247,9 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, CancellationToken cancellationToken) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue(TaskFunc, cancellationToken); + return EnqueueCore(taskScheduler, TaskFunc, cancellationToken); - async ValueTask TaskFunc(CancellationToken token) + async ValueTask TaskFunc(object? state, CancellationToken token) { await taskFunc(token).ConfigureAwait(false); return default; @@ -271,8 +269,6 @@ async ValueTask TaskFunc(CancellationToken token) /// public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc) { - Argument.NotNull(taskScheduler); - return taskScheduler.Enqueue(taskFunc, CancellationToken.None); } @@ -355,11 +351,9 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFun [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, CancellationToken cancellationToken, DummyParameter? _ = null) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue(TaskFunc, cancellationToken); + return EnqueueCore(taskScheduler, TaskFunc, cancellationToken); - async ValueTask TaskFunc(CancellationToken token) + async ValueTask TaskFunc(object? state, CancellationToken token) { await valueTaskFunc(token).ConfigureAwait(false); return default; @@ -384,8 +378,6 @@ async ValueTask TaskFunc(CancellationToken token) [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) { - Argument.NotNull(taskScheduler); - return taskScheduler.Enqueue(valueTaskFunc, CancellationToken.None, _); } @@ -471,15 +463,14 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func va /// public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue( - token => - { - action(token); - return new ValueTask(Task.CompletedTask); - }, - cancellationToken); + return EnqueueCore( + taskScheduler, + (_, token) => + { + action(token); + return new ValueTask(default(Unit)); + }, + cancellationToken); } /// @@ -496,15 +487,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue( - token => - { - action(token); - return new ValueTask(Task.CompletedTask); - }, - CancellationToken.None); + return taskScheduler.Enqueue(action, CancellationToken.None); } /// @@ -521,15 +504,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue( - _ => - { - action(); - return new ValueTask(Task.CompletedTask); - }, - CancellationToken.None); + return taskScheduler.Enqueue(action, CancellationToken.None); } /// @@ -547,15 +522,14 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) /// public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { - Argument.NotNull(taskScheduler); - - return taskScheduler.Enqueue( - _ => - { - action(); - return new ValueTask(Task.CompletedTask); - }, - cancellationToken); + return EnqueueCore( + taskScheduler, + (_, token) => + { + action(); + return new ValueTask(default(Unit)); + }, + cancellationToken); } /// @@ -599,6 +573,22 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func new ValueTask(func(token)), cancellationToken); } + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Preserve task-based error propagation without wrapping accepted tasks.")] + private static Task EnqueueCore(ITaskScheduler? taskScheduler, Func> taskFunc, CancellationToken cancellationToken) + { + if (taskScheduler is null) + return Task.FromException(new ArgumentNullException(nameof(taskScheduler))); + + try + { + return taskScheduler.Enqueue(taskFunc, null, cancellationToken); + } + catch (Exception exception) + { + return TaskExtensions.FromAsyncException(exception); + } + } + /// /// A class with no possible value other than null. Used to mark an optional parameter which should never be set. /// Taken from . diff --git a/TaskFlow/ThreadTaskFlow.cs b/TaskFlow/ThreadTaskFlow.cs index 483c819..fa0ad9b 100644 --- a/TaskFlow/ThreadTaskFlow.cs +++ b/TaskFlow/ThreadTaskFlow.cs @@ -90,16 +90,24 @@ protected ThreadTaskFlow(TaskFlowOptions options) /// be canceled so queue progression remains deterministic. /// /// + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Preserve task-based error propagation without wrapping accepted tasks.")] public override Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { - Argument.NotNull(taskFunc); + try + { + Argument.NotNull(taskFunc); - lock (ThisLock) + lock (ThisLock) + { + CheckDisposed(); + var executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); + _blockingCollection.Add(executionItem, CancellationToken.None); + return executionItem.Task; + } + } + catch (Exception exception) { - CheckDisposed(); - var executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); - _blockingCollection.Add(executionItem, CancellationToken.None); - return executionItem.Task; + return TaskExtensions.FromAsyncException(exception); } async Task TaskFunc(object? stateObject, CancellationToken token) From d078591a439fac4868b56a2001090ce9ed1449f1 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:03:07 +0000 Subject: [PATCH 03/10] Reject disposed enqueue calls synchronously --- .../ThreadTaskFlowDisposalFixture.cs | 78 +++++++++++++++++-- TaskFlow/TaskSchedulerEnqueueExtensions.cs | 8 +- TaskFlow/ThreadTaskFlow.cs | 8 +- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs index 1f96fff..9ae5643 100644 --- a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs +++ b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs @@ -8,6 +8,12 @@ namespace TaskFlow.Tests; internal sealed class ThreadTaskFlowDisposalFixture { private static readonly int[] ExpectedExecutionOrder = [1, 4, 2, 3]; + private static readonly string[] RejectedOverloads = + [ + "DirectGeneric", "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", + "Task", "TokenTask", "TokenTaskWithToken", "StateTask", + "ValueTask", "TokenValueTask", "TokenValueTaskWithToken", "StateValueTask", + ]; [TestCase(false, false)] [TestCase(false, true)] @@ -93,16 +99,42 @@ await Assert.ThatAsync(async () => await canceled, } } - [TestCase(false)] - [TestCase(true)] - public async Task Enqueue_AfterDisposal_ShouldReturnFaultedTask(bool currentThread) + [Test] + public void Enqueue_AfterDispose_ShouldThrowSynchronously( + [Values(false, true)] bool currentThread, + [ValueSource(nameof(RejectedOverloads))] string overload) + { + using var sut = CreateFlow(currentThread); + sut.Dispose(); + AssertDisposedEnqueueThrows(sut, overload); + } + + [Test] + public async Task Enqueue_AfterDisposeAsync_ShouldThrowSynchronously( + [Values(false, true)] bool currentThread, + [ValueSource(nameof(RejectedOverloads))] string overload) { await using var sut = CreateFlow(currentThread); await sut.DisposeAsync(); - Task? task = null; - Assert.That(() => { task = sut.Enqueue((state, token) => new ValueTask(42), null, CancellationToken.None); }, Throws.Nothing); + AssertDisposedEnqueueThrows(sut, overload); + } + + [Test] + public async Task Enqueue_AcceptedOperationThrowsObjectDisposed_ShouldFaultReturnedTask( + [Values(false, true)] bool currentThread, + [Values(false, true)] bool convenienceOverload) + { + await using var sut = CreateFlow(currentThread); + var failure = new ObjectDisposedException("operation-resource"); + Task? task = null; + Assert.That(() => + { + task = convenienceOverload + ? sut.Enqueue((Action)(() => throw failure)) + : sut.Enqueue((state, token) => throw failure, null, CancellationToken.None); + }, Throws.Nothing); + await Assert.ThatAsync(async () => await task!, Throws.Exception.SameAs(failure)); Assert.That(task!.IsFaulted, Is.True); - await Assert.ThatAsync(async () => await task, Throws.TypeOf()); } [TestCase(false)] @@ -116,6 +148,40 @@ public async Task Enqueue_NullDelegate_ShouldReturnFaultedTask(bool currentThrea await Assert.ThatAsync(async () => await task, Throws.TypeOf()); } + private static void AssertDisposedEnqueueThrows(ThreadTaskFlow sut, string overload) + { + var invoked = false; + Task? returnedTask = null; + void Action() => invoked = true; + Task TaskOperation() + { Action(); return Task.CompletedTask; } + ValueTask ValueTaskOperation() + { Action(); return ValueTask.CompletedTask; } + + Assert.That(() => + { + returnedTask = overload switch + { + "DirectGeneric" => sut.Enqueue((state, token) => { Action(); return new ValueTask(42); }, null, CancellationToken.None), + "Action" => sut.Enqueue(Action), + "ActionWithToken" => sut.Enqueue(Action, CancellationToken.None), + "TokenAction" => sut.Enqueue(token => { Action(); }), + "TokenActionWithToken" => sut.Enqueue(token => { Action(); }, CancellationToken.None), + "Task" => sut.Enqueue(TaskOperation), + "TokenTask" => sut.Enqueue(token => TaskOperation()), + "TokenTaskWithToken" => sut.Enqueue(token => TaskOperation(), CancellationToken.None), + "StateTask" => sut.Enqueue((int state, CancellationToken token) => TaskOperation(), 42, CancellationToken.None), + "ValueTask" => sut.Enqueue(ValueTaskOperation, null), + "TokenValueTask" => sut.Enqueue(token => ValueTaskOperation(), null), + "TokenValueTaskWithToken" => sut.Enqueue(token => ValueTaskOperation(), CancellationToken.None, null), + "StateValueTask" => sut.Enqueue((int state, CancellationToken token) => ValueTaskOperation(), 42, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + }, Throws.TypeOf()); + Assert.That(returnedTask, Is.Null, "A disposed flow must reject the call before returning a task."); + Assert.That(invoked, Is.False, "A rejected delegate must never execute."); + } + private static ThreadTaskFlow CreateFlow(bool currentThread) { if (!currentThread) diff --git a/TaskFlow/TaskSchedulerEnqueueExtensions.cs b/TaskFlow/TaskSchedulerEnqueueExtensions.cs index 831cecb..4d45e41 100644 --- a/TaskFlow/TaskSchedulerEnqueueExtensions.cs +++ b/TaskFlow/TaskSchedulerEnqueueExtensions.cs @@ -16,6 +16,10 @@ namespace System.Threading.Tasks.Flow; /// synchronous functions, and actions) and provide convenient overloads for common scenarios. /// /// +/// If a scheduler rejects an enqueue call by throwing , the exception +/// is propagated synchronously. Failures from accepted operations remain observable through their returned tasks. +/// +/// /// All extension methods perform argument validation and ensure proper exception propagation. /// Methods that don't specify a will use . /// @@ -573,7 +577,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func new ValueTask(func(token)), cancellationToken); } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Preserve task-based error propagation without wrapping accepted tasks.")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] private static Task EnqueueCore(ITaskScheduler? taskScheduler, Func> taskFunc, CancellationToken cancellationToken) { if (taskScheduler is null) @@ -583,7 +587,7 @@ private static Task EnqueueCore(ITaskScheduler? taskScheduler, Func(exception); } diff --git a/TaskFlow/ThreadTaskFlow.cs b/TaskFlow/ThreadTaskFlow.cs index fa0ad9b..18ad3c0 100644 --- a/TaskFlow/ThreadTaskFlow.cs +++ b/TaskFlow/ThreadTaskFlow.cs @@ -86,11 +86,15 @@ protected ThreadTaskFlow(TaskFlowOptions options) /// continuations within the task function will also be marshaled back to the same thread. /// /// + /// Calls made after disposal are rejected synchronously with . + /// Failures from accepted operations are reported through their returned tasks. + /// + /// /// Cancellation does not remove the queued item. The delegate is invoked with a linked token that may already /// be canceled so queue progression remains deterministic. /// /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Preserve task-based error propagation without wrapping accepted tasks.")] + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] public override Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { try @@ -105,7 +109,7 @@ public override Task Enqueue(Func return executionItem.Task; } } - catch (Exception exception) + catch (Exception exception) when (exception is not ObjectDisposedException) { return TaskExtensions.FromAsyncException(exception); } From 2fd101764f0edbd31f01631641105c4f2de893dd Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:42:59 +0000 Subject: [PATCH 04/10] Enforce scheduler disposal conformance across implementations --- .../TaskSchedulerDisposalConformance.cs | 69 ++++++ ...TaskSchedulerDisposalConformanceFixture.cs | 200 ++++++++++++++++++ .../ThreadTaskFlowDisposalFixture.cs | 78 ------- .../ExtendedStateTaskSchedulerExtensions.cs | 14 +- TaskFlow/ITaskScheduler.cs | 3 +- TaskFlow/Internal/TaskSchedulerPipeline.cs | 31 +-- TaskFlow/TaskFlowSchedulerAdapter.cs | 32 ++- 7 files changed, 327 insertions(+), 100 deletions(-) create mode 100644 TaskFlow.Tests/TaskSchedulerDisposalConformance.cs create mode 100644 TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs new file mode 100644 index 0000000..daf076b --- /dev/null +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs @@ -0,0 +1,69 @@ +namespace TaskFlow.Tests; + +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +internal static class TaskSchedulerDisposalConformance +{ + public static readonly string[] Overloads = + [ + "DirectGeneric", "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", + "Task", "TokenTask", "TokenTaskWithToken", "StateTask", + "ValueTask", "TokenValueTask", "TokenValueTaskWithToken", "StateValueTask", + ]; + + public static void AssertRejected(ITaskScheduler scheduler, string overload, bool keyed) + { + var invoked = false; + Task? returnedTask = null; + Assert.That(() => { returnedTask = Enqueue(scheduler, overload, () => invoked = true, keyed); }, Throws.TypeOf()); + Assert.That(returnedTask, Is.Null, "A disposed scheduler must reject the call before returning a task."); + Assert.That(invoked, Is.False, "A rejected delegate must never execute."); + } + + public static Task Enqueue(ITaskScheduler scheduler, string overload, Action action, bool keyed) + { + Task TaskOperation() + { action(); return Task.CompletedTask; } + ValueTask ValueTaskOperation() + { action(); return ValueTask.CompletedTask; } + if (keyed) + { + return overload switch + { + "DirectGeneric" => scheduler.Enqueue("key", (key, state, token) => { action(); return new ValueTask(42); }, 42, CancellationToken.None), + "Action" => scheduler.Enqueue("key", action), + "ActionWithToken" => scheduler.Enqueue("key", action, CancellationToken.None), + "TokenAction" => scheduler.Enqueue("key", (string key) => { action(); }), + "TokenActionWithToken" => scheduler.Enqueue("key", (string key) => { action(); }, CancellationToken.None), + "Task" => scheduler.Enqueue("key", TaskOperation), + "TokenTask" => scheduler.Enqueue("key", (CancellationToken token) => TaskOperation()), + "TokenTaskWithToken" => scheduler.Enqueue("key", (CancellationToken token) => TaskOperation(), CancellationToken.None), + "StateTask" => scheduler.Enqueue("key", (key, state, token) => { action(); return Task.FromResult(state); }, 42, CancellationToken.None), + "ValueTask" => scheduler.Enqueue("key", ValueTaskOperation), + "TokenValueTask" => scheduler.Enqueue("key", (CancellationToken token) => ValueTaskOperation()), + "TokenValueTaskWithToken" => scheduler.Enqueue("key", (CancellationToken token) => ValueTaskOperation(), CancellationToken.None), + "StateValueTask" => scheduler.Enqueue("key", (key, state, token) => { action(); return new ValueTask(state); }, 42, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } + + return overload switch + { + "DirectGeneric" => scheduler.Enqueue((state, token) => { action(); return new ValueTask(42); }, null, CancellationToken.None), + "Action" => scheduler.Enqueue(action), + "ActionWithToken" => scheduler.Enqueue(action, CancellationToken.None), + "TokenAction" => scheduler.Enqueue(token => { action(); }), + "TokenActionWithToken" => scheduler.Enqueue(token => { action(); }, CancellationToken.None), + "Task" => scheduler.Enqueue(TaskOperation), + "TokenTask" => scheduler.Enqueue(token => TaskOperation()), + "TokenTaskWithToken" => scheduler.Enqueue(token => TaskOperation(), CancellationToken.None), + "StateTask" => scheduler.Enqueue((int state, CancellationToken token) => TaskOperation(), 42, CancellationToken.None), + "ValueTask" => scheduler.Enqueue(ValueTaskOperation, null), + "TokenValueTask" => scheduler.Enqueue(token => ValueTaskOperation(), null), + "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTaskOperation(), CancellationToken.None, null), + "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTaskOperation(), 42, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } +} \ No newline at end of file diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs new file mode 100644 index 0000000..1ec8f31 --- /dev/null +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs @@ -0,0 +1,200 @@ +namespace TaskFlow.Tests; + +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +[TestFixtureSource(nameof(Scenarios))] +[CancelAfter(10000)] +internal sealed class TaskSchedulerDisposalConformanceFixture +{ + private readonly string _implementation; + private readonly string _configuration; + + private ITaskFlow? _flow; + private DisposableTaskScheduler? _adapterOwner; + private TaskSchedulerMiddlewareLifetime? _lifetime; + private ITaskScheduler _scheduler = null!; + private PhaseObserver _observer = null!; + + public TaskSchedulerDisposalConformanceFixture(string implementation, string configuration) + { + _implementation = implementation; + _configuration = configuration; + } + + public static IEnumerable Scenarios() + { + foreach (var implementation in new[] { "TaskFlow", "DedicatedThread", "CurrentThread" }) + foreach (var configuration in new[] { "Plain", "Pipeline", "ExtendedState", "Bounded" }) + yield return new TestFixtureData(implementation, configuration); + foreach (var configuration in new[] { "Concurrent", "Keyed", "KeyedBounded" }) + yield return new TestFixtureData("TaskFlow", configuration); + yield return new TestFixtureData("Adapter", "Plain"); + yield return new TestFixtureData("Adapter", "ExplicitLifetime"); + } + + [SetUp] + public async Task SetUp() + { + _observer = new PhaseObserver(); + _flow = null; + _adapterOwner = null; + _lifetime = null; + if (_implementation == "Adapter") + { + _adapterOwner = new DisposableTaskScheduler(); + _scheduler = new TaskFlowSchedulerAdapter(_adapterOwner); + } + else + { + _flow = _implementation switch + { + "TaskFlow" => new TaskFlow(), + "DedicatedThread" => new DedicatedThreadTaskFlow(), + "CurrentThread" => StartCurrentThread(), + _ => throw new ArgumentOutOfRangeException(nameof(_implementation)), + }; + _scheduler = _flow; + } + + _scheduler = _configuration switch + { + "Plain" => _scheduler, + "Pipeline" => _scheduler.UseMiddleware(_observer), + "ExtendedState" => _scheduler.WithExtendedState(42), + "Bounded" => _scheduler.WithBoundedAdmission(new BoundedAdmissionOptions { Capacity = 1 }).UseMiddleware(_observer), + "Concurrent" => _scheduler.WithConcurrencyLimit(1).UseMiddleware(_observer), + "Keyed" => _scheduler.WithKeyedScheduling().UseMiddleware(_observer), + "KeyedBounded" => _scheduler.WithKeyedScheduling().WithPerKeyBoundedAdmission(new BoundedAdmissionOptions { Capacity = 1 }).UseMiddleware(_observer), + "ExplicitLifetime" => WithExplicitLifetime(_scheduler), + _ => throw new ArgumentOutOfRangeException(nameof(_configuration)), + }; + // Exercise the same API before disposal so a permanently rejecting scheduler cannot pass. + await TaskSchedulerDisposalConformance.Enqueue(_scheduler, "DirectGeneric", () => { }, IsKeyed); + _observer.Invocations = 0; + } + + private bool IsKeyed => _configuration is "Keyed" or "KeyedBounded"; + + [TearDown] + public async Task TearDown() => await DisposeOwnerAsync(); + + [TestCaseSource(typeof(TaskSchedulerDisposalConformance), nameof(TaskSchedulerDisposalConformance.Overloads))] + public void Enqueue_AfterDispose_ShouldRejectSynchronously(string overload) + { + DisposeOwner(); + TaskSchedulerDisposalConformance.AssertRejected(_scheduler, overload, IsKeyed); + Assert.That(_observer.Invocations, Is.Zero, "Disposed middleware must not execute any phase."); + } + + [TestCaseSource(typeof(TaskSchedulerDisposalConformance), nameof(TaskSchedulerDisposalConformance.Overloads))] + public async Task Enqueue_AfterDisposeAsync_ShouldRejectSynchronously(string overload) + { + await DisposeOwnerAsync(); + TaskSchedulerDisposalConformance.AssertRejected(_scheduler, overload, IsKeyed); + Assert.That(_observer.Invocations, Is.Zero, "Disposed middleware must not execute any phase."); + } + + [TestCaseSource(typeof(TaskSchedulerDisposalConformance), nameof(TaskSchedulerDisposalConformance.Overloads))] + public async Task Enqueue_AfterShutdownStarts_ShouldRejectSynchronously(string overload) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async Task Operation() + { + started.SetResult(); + await release.Task.ConfigureAwait(false); + } + var held = IsKeyed ? _scheduler.Enqueue("key", Operation) : _scheduler.Enqueue(Operation); + Task? shutdown = null; + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var invocations = _observer.Invocations; + shutdown = DisposeOwnerAsync(); + TaskSchedulerDisposalConformance.AssertRejected(_scheduler, overload, IsKeyed); + Assert.That(_observer.Invocations, Is.EqualTo(invocations), "Entry rejection must not invoke middleware while accepted work drains."); + } + finally + { + release.TrySetResult(); + if (shutdown != null) + await shutdown; + await held; + } + } + + [TestCase("DirectGeneric")] + [TestCase("Action")] + public async Task Enqueue_AcceptedOperationThrowsObjectDisposed_ShouldFaultTask(string overload) + { + var failure = new ObjectDisposedException("operation-resource"); + Task? task = null; + Assert.That(() => { task = TaskSchedulerDisposalConformance.Enqueue(_scheduler, overload, () => throw failure, IsKeyed); }, Throws.Nothing); + await Assert.ThatAsync(async () => await task!, Throws.Exception.SameAs(failure)); + Assert.That(task!.IsFaulted, Is.True); + } + + private ITaskScheduler WithExplicitLifetime(ITaskScheduler scheduler) + { + _lifetime = new TaskSchedulerMiddlewareLifetime(); + return scheduler.WithBoundedAdmission(new BoundedAdmissionController(new BoundedAdmissionOptions { Capacity = 1 }), _lifetime).UseMiddleware(_observer); + } + + private void DisposeOwner() + { + _lifetime?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + _flow?.Dispose(); + _adapterOwner?.Dispose(); + } + + private async Task DisposeOwnerAsync() + { + if (_lifetime != null) + await _lifetime.DisposeAsync(); + if (_flow != null) + await _flow.DisposeAsync(); + _adapterOwner?.Dispose(); + } + + private static CurrentThreadTaskFlow StartCurrentThread() + { + var flow = new CurrentThreadTaskFlow(); + new Thread(flow.Run) { IsBackground = true }.Start(); + return flow; + } + + private sealed class PhaseObserver : ITaskSchedulerEnqueueMiddleware, ITaskSchedulerExecutionMiddleware, ITaskSchedulerCompletionMiddleware + { + public int Invocations { get; set; } + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) + { + Invocations++; + return next(context); + } + public ValueTask InvokeAsync(TaskSchedulerOperationContext context, TaskSchedulerExecutionDelegate next) + { + Invocations++; + return next(context); + } + public ValueTask> InvokeAsync(TaskSchedulerOperationContext context, TaskSchedulerOperationOutcome outcome, TaskSchedulerCompletionDelegate next) + { + Invocations++; + return next(context, outcome); + } + } + + private sealed class DisposableTaskScheduler : TaskScheduler, IDisposable + { + private int _disposed; + + public void Dispose() => Volatile.Write(ref _disposed, 1); + protected override void QueueTask(Task task) + { + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this); + ThreadPool.QueueUserWorkItem(_ => TryExecuteTask(task)); + } + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + protected override IEnumerable GetScheduledTasks() => []; + } +} \ No newline at end of file diff --git a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs index 9ae5643..506ec9d 100644 --- a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs +++ b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs @@ -8,12 +8,6 @@ namespace TaskFlow.Tests; internal sealed class ThreadTaskFlowDisposalFixture { private static readonly int[] ExpectedExecutionOrder = [1, 4, 2, 3]; - private static readonly string[] RejectedOverloads = - [ - "DirectGeneric", "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", - "Task", "TokenTask", "TokenTaskWithToken", "StateTask", - "ValueTask", "TokenValueTask", "TokenValueTaskWithToken", "StateValueTask", - ]; [TestCase(false, false)] [TestCase(false, true)] @@ -99,44 +93,6 @@ await Assert.ThatAsync(async () => await canceled, } } - [Test] - public void Enqueue_AfterDispose_ShouldThrowSynchronously( - [Values(false, true)] bool currentThread, - [ValueSource(nameof(RejectedOverloads))] string overload) - { - using var sut = CreateFlow(currentThread); - sut.Dispose(); - AssertDisposedEnqueueThrows(sut, overload); - } - - [Test] - public async Task Enqueue_AfterDisposeAsync_ShouldThrowSynchronously( - [Values(false, true)] bool currentThread, - [ValueSource(nameof(RejectedOverloads))] string overload) - { - await using var sut = CreateFlow(currentThread); - await sut.DisposeAsync(); - AssertDisposedEnqueueThrows(sut, overload); - } - - [Test] - public async Task Enqueue_AcceptedOperationThrowsObjectDisposed_ShouldFaultReturnedTask( - [Values(false, true)] bool currentThread, - [Values(false, true)] bool convenienceOverload) - { - await using var sut = CreateFlow(currentThread); - var failure = new ObjectDisposedException("operation-resource"); - Task? task = null; - Assert.That(() => - { - task = convenienceOverload - ? sut.Enqueue((Action)(() => throw failure)) - : sut.Enqueue((state, token) => throw failure, null, CancellationToken.None); - }, Throws.Nothing); - await Assert.ThatAsync(async () => await task!, Throws.Exception.SameAs(failure)); - Assert.That(task!.IsFaulted, Is.True); - } - [TestCase(false)] [TestCase(true)] public async Task Enqueue_NullDelegate_ShouldReturnFaultedTask(bool currentThread) @@ -148,40 +104,6 @@ public async Task Enqueue_NullDelegate_ShouldReturnFaultedTask(bool currentThrea await Assert.ThatAsync(async () => await task, Throws.TypeOf()); } - private static void AssertDisposedEnqueueThrows(ThreadTaskFlow sut, string overload) - { - var invoked = false; - Task? returnedTask = null; - void Action() => invoked = true; - Task TaskOperation() - { Action(); return Task.CompletedTask; } - ValueTask ValueTaskOperation() - { Action(); return ValueTask.CompletedTask; } - - Assert.That(() => - { - returnedTask = overload switch - { - "DirectGeneric" => sut.Enqueue((state, token) => { Action(); return new ValueTask(42); }, null, CancellationToken.None), - "Action" => sut.Enqueue(Action), - "ActionWithToken" => sut.Enqueue(Action, CancellationToken.None), - "TokenAction" => sut.Enqueue(token => { Action(); }), - "TokenActionWithToken" => sut.Enqueue(token => { Action(); }, CancellationToken.None), - "Task" => sut.Enqueue(TaskOperation), - "TokenTask" => sut.Enqueue(token => TaskOperation()), - "TokenTaskWithToken" => sut.Enqueue(token => TaskOperation(), CancellationToken.None), - "StateTask" => sut.Enqueue((int state, CancellationToken token) => TaskOperation(), 42, CancellationToken.None), - "ValueTask" => sut.Enqueue(ValueTaskOperation, null), - "TokenValueTask" => sut.Enqueue(token => ValueTaskOperation(), null), - "TokenValueTaskWithToken" => sut.Enqueue(token => ValueTaskOperation(), CancellationToken.None, null), - "StateValueTask" => sut.Enqueue((int state, CancellationToken token) => ValueTaskOperation(), 42, CancellationToken.None), - _ => throw new ArgumentOutOfRangeException(nameof(overload)), - }; - }, Throws.TypeOf()); - Assert.That(returnedTask, Is.Null, "A disposed flow must reject the call before returning a task."); - Assert.That(invoked, Is.False, "A rejected delegate must never execute."); - } - private static ThreadTaskFlow CreateFlow(bool currentThread) { if (!currentThread) diff --git a/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs b/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs index 5f360a2..fd2db1c 100644 --- a/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs @@ -1,6 +1,8 @@ namespace System.Threading.Tasks.Flow; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Flow.Annotations; +using System.Threading.Tasks.Flow.Internal; internal static class ExtendedStateTaskSchedulerExtensions { @@ -22,11 +24,19 @@ public ExtendedStateTaskSchedulerWrapper(ITaskScheduler baseTaskScheduler, TStat _extendedState = extendedState; } - public async Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { var extendedState = new ExtendedState(state, _extendedState); - return await _baseTaskScheduler.Enqueue(Action, extendedState, cancellationToken).ConfigureAwait(false); + try + { + return _baseTaskScheduler.Enqueue(Action, extendedState, cancellationToken); + } + catch (Exception exception) when (exception is not ObjectDisposedException) + { + return TaskExtensions.FromAsyncException(exception); + } async ValueTask Action(ExtendedState s, CancellationToken token) { diff --git a/TaskFlow/ITaskScheduler.cs b/TaskFlow/ITaskScheduler.cs index 08cd881..d143959 100644 --- a/TaskFlow/ITaskScheduler.cs +++ b/TaskFlow/ITaskScheduler.cs @@ -51,6 +51,7 @@ public interface ITaskScheduler /// Resources are properly managed when the scheduler is disposed /// /// - /// Thrown if the scheduler has been disposed. + /// Thrown synchronously if the scheduler or its owning lifetime has been disposed. + /// An exception from an accepted operation is instead reported through its returned task. Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/TaskFlow/Internal/TaskSchedulerPipeline.cs b/TaskFlow/Internal/TaskSchedulerPipeline.cs index c77db1a..9d45e5c 100644 --- a/TaskFlow/Internal/TaskSchedulerPipeline.cs +++ b/TaskFlow/Internal/TaskSchedulerPipeline.cs @@ -99,19 +99,10 @@ internal Task Submit(Func(taskFunc, state, _registrations.Length, _annotations, cancellationToken), request); } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Pipeline submission reports terminal failures through its returned task.")] - private Task EnqueueTerminal(Func> taskFunc, - object? state, CancellationToken cancellationToken) - { - try - { return _terminal.Enqueue(taskFunc, state, cancellationToken); } - catch (Exception exception) { return EnqueueFailure(exception); } - } - internal Task SubmitKeyed(TKey key, Func> taskFunc, object? state, AdmissionRequest? request, CancellationToken cancellationToken) where TKey : notnull { @@ -125,6 +116,21 @@ private Task SubmitOperation(PipelineOperation operat operation.AdmissionRequest = request; if (request != null) operation.OnQuiesced(request.OnQuiesced); + else if (Lifetime != null) + { + // Enqueue entry is synchronous and atomic with closing the owning lifetime. + // Explicit admission submissions keep their separate decision/completion contract. + try + { + Lifetime.Enter(); + operation.Lifetime = Lifetime; + } + catch (ObjectDisposedException) + { + operation.ReleaseActivity(); + throw; + } + } return EnqueueCore(operation); } @@ -135,7 +141,7 @@ private async Task EnqueueCore(PipelineOperation oper TResult result; try { - if (Lifetime != null) + if (Lifetime != null && operation.Lifetime == null) { Lifetime.Enter(); operation.Lifetime = Lifetime; @@ -169,9 +175,6 @@ private async Task InvokeEnqueue(PipelineOperation op finally { operation.ReleaseActivity(); } } - private static async Task EnqueueFailure(Exception exception) - => await Task.FromException(exception).ConfigureAwait(false); - private Task InvokeEnqueueCore(PipelineOperation operation, int index, CancellationToken cancellationToken) { while (index >= 0 && !(_registrations[index].Middleware is ITaskSchedulerEnqueueMiddleware)) diff --git a/TaskFlow/TaskFlowSchedulerAdapter.cs b/TaskFlow/TaskFlowSchedulerAdapter.cs index 35fa0e2..11164f7 100644 --- a/TaskFlow/TaskFlowSchedulerAdapter.cs +++ b/TaskFlow/TaskFlowSchedulerAdapter.cs @@ -1,6 +1,9 @@ namespace System.Threading.Tasks.Flow; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using System.Threading.Tasks.Flow.Annotations; +using System.Threading.Tasks.Flow.Internal; /// /// Provides an adapter that allows .NET Framework's to be used as an . @@ -81,7 +84,7 @@ public TaskFlowSchedulerAdapter(TaskScheduler taskScheduler) /// A representing the result of the enqueued task function. /// Thrown when is null. /// Thrown if the underlying task scheduler rejects the task. - /// Thrown if the underlying task scheduler has been disposed. + /// Thrown synchronously if the underlying task scheduler rejects scheduling because it has been disposed. /// /// /// This method uses Task.Factory.StartNew @@ -109,11 +112,30 @@ public TaskFlowSchedulerAdapter(TaskScheduler taskScheduler) /// scheduler starts the task, the task function may not be invoked. /// /// - public async Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { - Argument.NotNull(taskFunc); + try + { + Argument.NotNull(taskFunc); + var scheduled = Task.Factory.StartNew(() => taskFunc(state, cancellationToken), cancellationToken, TaskCreationOptions.None, _taskScheduler); + return AwaitOperation(scheduled); + } + catch (TaskSchedulerException exception) when (exception.InnerException is ObjectDisposedException disposed) + { + // Task.Factory.StartNew wraps exceptions from TaskScheduler.QueueTask. + ExceptionDispatchInfo.Capture(disposed).Throw(); + throw; + } + catch (Exception exception) when (exception is not ObjectDisposedException) + { + return TaskExtensions.FromAsyncException(exception); + } - var task = await Task.Factory.StartNew(() => taskFunc(state, cancellationToken), cancellationToken, TaskCreationOptions.None, _taskScheduler).ConfigureAwait(false); - return await task.ConfigureAwait(false); + static async Task AwaitOperation(Task> scheduled) + { + var operation = await scheduled.ConfigureAwait(false); + return await operation.ConfigureAwait(false); + } } } \ No newline at end of file From 8a5bdf123d6100837119b5dab89858338acda3d9 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:55:57 +0000 Subject: [PATCH 05/10] Propagate enqueue validation errors synchronously without catch-all handlers --- .../TaskSchedulerDisposalConformance.cs | 84 +++++++++++++++ ...TaskSchedulerDisposalConformanceFixture.cs | 19 +++- .../TaskSchedulerEnqueueExtensionsFixture.cs | 64 +++-------- .../ThreadTaskFlowDisposalFixture.cs | 11 -- .../ExtendedStateTaskSchedulerExtensions.cs | 14 +-- TaskFlow/ITaskScheduler.cs | 1 + TaskFlow/Internal/TaskExtensions.cs | 9 -- TaskFlow/TaskFlowSchedulerAdapter.cs | 10 +- TaskFlow/TaskSchedulerEnqueueExtensions.cs | 102 ++++++++++-------- TaskFlow/ThreadTaskFlow.cs | 20 ++-- 10 files changed, 180 insertions(+), 154 deletions(-) diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs index daf076b..08bcd3d 100644 --- a/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformance.cs @@ -10,6 +10,9 @@ internal static class TaskSchedulerDisposalConformance "DirectGeneric", "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", "Task", "TokenTask", "TokenTaskWithToken", "StateTask", "ValueTask", "TokenValueTask", "TokenValueTaskWithToken", "StateValueTask", + "TaskResult", "TokenTaskResult", "TokenTaskResultWithToken", "StateTaskResult", + "ValueTaskResult", "TokenValueTaskResult", "TokenValueTaskResultWithToken", "StateValueTaskResult", + "Func", "TokenFunc", ]; public static void AssertRejected(ITaskScheduler scheduler, string overload, bool keyed) @@ -44,6 +47,16 @@ ValueTask ValueTaskOperation() "TokenValueTask" => scheduler.Enqueue("key", (CancellationToken token) => ValueTaskOperation()), "TokenValueTaskWithToken" => scheduler.Enqueue("key", (CancellationToken token) => ValueTaskOperation(), CancellationToken.None), "StateValueTask" => scheduler.Enqueue("key", (key, state, token) => { action(); return new ValueTask(state); }, 42, CancellationToken.None), + "TaskResult" => scheduler.Enqueue("key", () => { action(); return Task.FromResult(42); }), + "TokenTaskResult" => scheduler.Enqueue("key", (CancellationToken token) => { action(); return Task.FromResult(42); }), + "TokenTaskResultWithToken" => scheduler.Enqueue("key", (CancellationToken token) => { action(); return Task.FromResult(42); }, CancellationToken.None), + "StateTaskResult" => scheduler.Enqueue("key", (key, state, token) => { action(); return Task.FromResult(state); }, 42, CancellationToken.None), + "ValueTaskResult" => scheduler.Enqueue("key", () => { action(); return new ValueTask(42); }), + "TokenValueTaskResult" => scheduler.Enqueue("key", (CancellationToken token) => { action(); return new ValueTask(42); }), + "TokenValueTaskResultWithToken" => scheduler.Enqueue("key", (CancellationToken token) => { action(); return new ValueTask(42); }, CancellationToken.None), + "StateValueTaskResult" => scheduler.Enqueue("key", (key, state, token) => { action(); return new ValueTask(state); }, 42, CancellationToken.None), + "Func" => scheduler.Enqueue("key", () => { action(); return 42; }), + "TokenFunc" => scheduler.Enqueue("key", (string key) => { action(); return 42; }, CancellationToken.None), _ => throw new ArgumentOutOfRangeException(nameof(overload)), }; } @@ -63,6 +76,77 @@ ValueTask ValueTaskOperation() "TokenValueTask" => scheduler.Enqueue(token => ValueTaskOperation(), null), "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTaskOperation(), CancellationToken.None, null), "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTaskOperation(), 42, CancellationToken.None), + "TaskResult" => scheduler.Enqueue(() => { action(); return Task.FromResult(42); }), + "TokenTaskResult" => scheduler.Enqueue(token => { action(); return Task.FromResult(42); }), + "TokenTaskResultWithToken" => scheduler.Enqueue(token => { action(); return Task.FromResult(42); }, CancellationToken.None), + "StateTaskResult" => scheduler.Enqueue((int state, CancellationToken token) => { action(); return Task.FromResult(state); }, 42, CancellationToken.None), + "ValueTaskResult" => scheduler.Enqueue(() => { action(); return new ValueTask(42); }, null), + "TokenValueTaskResult" => scheduler.Enqueue(token => { action(); return new ValueTask(42); }, null), + "TokenValueTaskResultWithToken" => scheduler.Enqueue(token => { action(); return new ValueTask(42); }, CancellationToken.None), + "StateValueTaskResult" => scheduler.Enqueue((int state, CancellationToken token) => { action(); return new ValueTask(state); }, 42, CancellationToken.None), + "Func" => scheduler.Enqueue(() => { action(); return 42; }), + "TokenFunc" => scheduler.Enqueue(token => { action(); return 42; }, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } + + public static Task EnqueueNullDelegate(ITaskScheduler scheduler, string overload, bool keyed) + { + if (keyed) + { + return overload switch + { + "DirectGeneric" => scheduler.Enqueue("key", (Func>)null!, 42, CancellationToken.None), + "Action" => scheduler.Enqueue("key", (Action)null!), + "ActionWithToken" => scheduler.Enqueue("key", (Action)null!, CancellationToken.None), + "TokenAction" => scheduler.Enqueue("key", (Action)null!), + "TokenActionWithToken" => scheduler.Enqueue("key", (Action)null!, CancellationToken.None), + "Task" => scheduler.Enqueue("key", (Func)null!), + "TokenTask" => scheduler.Enqueue("key", (Func)null!), + "TokenTaskWithToken" => scheduler.Enqueue("key", (Func)null!, CancellationToken.None), + "StateTask" => scheduler.Enqueue("key", (Func>)null!, 42, CancellationToken.None), + "ValueTask" => scheduler.Enqueue("key", (Func)null!), + "TokenValueTask" => scheduler.Enqueue("key", (Func)null!), + "TokenValueTaskWithToken" => scheduler.Enqueue("key", (Func)null!, CancellationToken.None), + "StateValueTask" => scheduler.Enqueue("key", (Func>)null!, 42, CancellationToken.None), + "TaskResult" => scheduler.Enqueue("key", (Func>)null!), + "TokenTaskResult" => scheduler.Enqueue("key", (Func>)null!), + "TokenTaskResultWithToken" => scheduler.Enqueue("key", (Func>)null!, CancellationToken.None), + "StateTaskResult" => scheduler.Enqueue("key", (Func>)null!, 42, CancellationToken.None), + "ValueTaskResult" => scheduler.Enqueue("key", (Func>)null!), + "TokenValueTaskResult" => scheduler.Enqueue("key", (Func>)null!), + "TokenValueTaskResultWithToken" => scheduler.Enqueue("key", (Func>)null!, CancellationToken.None), + "StateValueTaskResult" => scheduler.Enqueue("key", (Func>)null!, 42, CancellationToken.None), + "Func" => scheduler.Enqueue("key", (Func)null!), + "TokenFunc" => scheduler.Enqueue("key", (Func)null!, CancellationToken.None), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } + return overload switch + { + "DirectGeneric" => scheduler.Enqueue(null!, null, CancellationToken.None), + "Action" => scheduler.Enqueue((Action)null!), + "ActionWithToken" => scheduler.Enqueue((Action)null!, CancellationToken.None), + "TokenAction" => scheduler.Enqueue((Action)null!), + "TokenActionWithToken" => scheduler.Enqueue((Action)null!, CancellationToken.None), + "Task" => scheduler.Enqueue((Func)null!), + "TokenTask" => scheduler.Enqueue((Func)null!), + "TokenTaskWithToken" => scheduler.Enqueue((Func)null!, CancellationToken.None), + "StateTask" => scheduler.Enqueue((Func)null!, 42, CancellationToken.None), + "ValueTask" => scheduler.Enqueue((Func)null!, _: null), + "TokenValueTask" => scheduler.Enqueue((Func)null!, _: null), + "TokenValueTaskWithToken" => scheduler.Enqueue((Func)null!, CancellationToken.None, null), + "StateValueTask" => scheduler.Enqueue((Func)null!, 42, CancellationToken.None), + "TaskResult" => scheduler.Enqueue((Func>)null!), + "TokenTaskResult" => scheduler.Enqueue((Func>)null!), + "TokenTaskResultWithToken" => scheduler.Enqueue((Func>)null!, CancellationToken.None), + "StateTaskResult" => scheduler.Enqueue((Func>)null!, 42, CancellationToken.None), + "ValueTaskResult" => scheduler.Enqueue((Func>)null!, _: null), + "TokenValueTaskResult" => scheduler.Enqueue((Func>)null!, _: null), + "TokenValueTaskResultWithToken" => scheduler.Enqueue((Func>)null!, CancellationToken.None), + "StateValueTaskResult" => scheduler.Enqueue((Func>)null!, 42, CancellationToken.None), + "Func" => scheduler.Enqueue((Func)null!), + "TokenFunc" => scheduler.Enqueue((Func)null!, CancellationToken.None), _ => throw new ArgumentOutOfRangeException(nameof(overload)), }; } diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs index 1ec8f31..62b8f55 100644 --- a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs @@ -124,11 +124,22 @@ async Task Operation() } } - [TestCase("DirectGeneric")] - [TestCase("Action")] - public async Task Enqueue_AcceptedOperationThrowsObjectDisposed_ShouldFaultTask(string overload) + [TestCaseSource(typeof(TaskSchedulerDisposalConformance), nameof(TaskSchedulerDisposalConformance.Overloads))] + public void Enqueue_NullDelegate_ShouldThrowSynchronously(string overload) + { + Task? task = null; + Assert.That(() => { task = TaskSchedulerDisposalConformance.EnqueueNullDelegate(_scheduler, overload, IsKeyed); }, Throws.TypeOf()); + Assert.That(task, Is.Null); + Assert.That(_observer.Invocations, Is.Zero, "Invalid arguments must be rejected before middleware executes."); + } + + [TestCase("DirectGeneric", "disposed")] + [TestCase("DirectGeneric", "argument")] + [TestCase("Action", "disposed")] + [TestCase("Action", "argument")] + public async Task Enqueue_AcceptedOperationThrows_ShouldFaultTask(string overload, string exceptionType) { - var failure = new ObjectDisposedException("operation-resource"); + Exception failure = exceptionType == "disposed" ? new ObjectDisposedException("operation-resource") : new ArgumentException("Operation failed"); Task? task = null; Assert.That(() => { task = TaskSchedulerDisposalConformance.Enqueue(_scheduler, overload, () => throw failure, IsKeyed); }, Throws.Nothing); await Assert.ThatAsync(async () => await task!, Throws.Exception.SameAs(failure)); diff --git a/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs index f5f6e05..ca37a6c 100644 --- a/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerEnqueueExtensionsFixture.cs @@ -1,21 +1,12 @@ namespace TaskFlow.Tests; using NUnit.Framework; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Flow; [TestFixture] internal sealed class TaskSchedulerEnqueueExtensionsFixture { - private static readonly string[] OriginallyAsyncOverloads = - [ - "Action", "ActionWithToken", "TokenAction", "TokenActionWithToken", - "TokenTask", "TokenTaskWithToken", "TokenValueTask", "TokenValueTaskWithToken", - ]; - private static readonly string[] AsyncFailureOverloads = - [ - .. OriginallyAsyncOverloads, "Task", "ValueTask", - ]; + private static IEnumerable ExtensionOverloads => TaskSchedulerDisposalConformance.Overloads.Where(overload => overload != "DirectGeneric"); [TestCase("Action")] [TestCase("ActionWithToken")] @@ -32,8 +23,7 @@ internal sealed class TaskSchedulerEnqueueExtensionsFixture public void Enqueue_ShouldReturnSchedulerTaskWithoutAdditionalContinuations(string overload) { var scheduler = new PendingScheduler(); - using var cancellation = new CancellationTokenSource(); - var task = Enqueue(scheduler, overload, cancellation.Token); + var task = Enqueue(scheduler, overload); // An extra async wrapper could still be pending after the scheduler has drained its queue. Assert.That(task, Is.SameAs(scheduler.ScheduledTask)); @@ -42,54 +32,26 @@ public void Enqueue_ShouldReturnSchedulerTaskWithoutAdditionalContinuations(stri Assert.That(task.IsCompletedSuccessfully, Is.True); } - [Test] - public async Task Enqueue_InvalidScheduler_ShouldReturnFaultedTask([ValueSource(nameof(OriginallyAsyncOverloads))] string overload) + [TestCaseSource(nameof(ExtensionOverloads))] + public void Enqueue_InvalidScheduler_ShouldThrowSynchronously(string overload) { Task? task = null; - Assert.That(() => { task = Enqueue(null!, overload, CancellationToken.None); }, Throws.Nothing); - Assert.That(task!.IsFaulted, Is.True); - await Assert.ThatAsync(async () => await task, Throws.TypeOf()); + Assert.That(() => { task = Enqueue(null!, overload); }, Throws.TypeOf()); + Assert.That(task, Is.Null); } - [Test] - public async Task Enqueue_SynchronousSchedulingFailure_ShouldSettleReturnedTask( - [ValueSource(nameof(AsyncFailureOverloads))] string overload, - [Values("failure", "cancellation", "uncanceledCancellation")] string outcome) + [TestCaseSource(typeof(TaskSchedulerDisposalConformance), nameof(TaskSchedulerDisposalConformance.Overloads))] + public void Enqueue_SchedulingFailure_ShouldThrowSynchronously(string overload) { - using var cancellation = new CancellationTokenSource(); - if (outcome == "cancellation") - await cancellation.CancelAsync(); - Exception failure = outcome == "failure" - ? new InvalidOperationException("Scheduling failed") - : new OperationCanceledException("Scheduling canceled", cancellation.Token); + var failure = new ArgumentException("Scheduling rejected"); var scheduler = new ThrowingScheduler(failure); Task? task = null; - Assert.That(() => { task = Enqueue(scheduler, overload, cancellation.Token); }, Throws.Nothing); - Assert.That(task!.IsCanceled, Is.EqualTo(outcome != "failure")); - Assert.That(task.IsFaulted, Is.EqualTo(outcome == "failure")); - await Assert.ThatAsync(async () => await task, Throws.Exception.SameAs(failure)); + Assert.That(() => { task = Enqueue(scheduler, overload); }, Throws.Exception.SameAs(failure)); + Assert.That(task, Is.Null); } - [SuppressMessage("Reliability", "CA2016:Forward the CancellationToken parameter to methods that take one", Justification = "The cases intentionally exercise overloads without a cancellation token.")] - private static Task Enqueue(ITaskScheduler scheduler, string overload, CancellationToken cancellationToken) - { - return overload switch - { - "Action" => scheduler.Enqueue(() => { }), - "ActionWithToken" => scheduler.Enqueue(() => { }, cancellationToken), - "TokenAction" => scheduler.Enqueue(token => { }), - "TokenActionWithToken" => scheduler.Enqueue(token => { }, cancellationToken), - "Task" => scheduler.Enqueue(() => Task.CompletedTask), - "TokenTask" => scheduler.Enqueue(token => Task.CompletedTask), - "TokenTaskWithToken" => scheduler.Enqueue(token => Task.CompletedTask, cancellationToken), - "StateTask" => scheduler.Enqueue((int state, CancellationToken token) => Task.CompletedTask, 42, cancellationToken), - "ValueTask" => scheduler.Enqueue(() => ValueTask.CompletedTask, null), - "TokenValueTask" => scheduler.Enqueue(token => ValueTask.CompletedTask, null), - "TokenValueTaskWithToken" => scheduler.Enqueue(token => ValueTask.CompletedTask, cancellationToken, null), - "StateValueTask" => scheduler.Enqueue((int state, CancellationToken token) => ValueTask.CompletedTask, 42, cancellationToken), - _ => throw new ArgumentOutOfRangeException(nameof(overload)), - }; - } + private static Task Enqueue(ITaskScheduler scheduler, string overload) + => TaskSchedulerDisposalConformance.Enqueue(scheduler, overload, () => { }, false); private sealed class ThrowingScheduler(Exception failure) : ITaskScheduler { diff --git a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs index 506ec9d..f61ce46 100644 --- a/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs +++ b/TaskFlow.Tests/ThreadTaskFlowDisposalFixture.cs @@ -93,17 +93,6 @@ await Assert.ThatAsync(async () => await canceled, } } - [TestCase(false)] - [TestCase(true)] - public async Task Enqueue_NullDelegate_ShouldReturnFaultedTask(bool currentThread) - { - await using var sut = CreateFlow(currentThread); - Task? task = null; - Assert.That(() => { task = sut.Enqueue(null!, null, CancellationToken.None); }, Throws.Nothing); - Assert.That(task!.IsFaulted, Is.True); - await Assert.ThatAsync(async () => await task, Throws.TypeOf()); - } - private static ThreadTaskFlow CreateFlow(bool currentThread) { if (!currentThread) diff --git a/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs b/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs index fd2db1c..daed38a 100644 --- a/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/ExtendedStateTaskSchedulerExtensions.cs @@ -1,8 +1,6 @@ namespace System.Threading.Tasks.Flow; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks.Flow.Annotations; -using System.Threading.Tasks.Flow.Internal; internal static class ExtendedStateTaskSchedulerExtensions { @@ -24,19 +22,13 @@ public ExtendedStateTaskSchedulerWrapper(ITaskScheduler baseTaskScheduler, TStat _extendedState = extendedState; } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { + Argument.NotNull(taskFunc); + var extendedState = new ExtendedState(state, _extendedState); - try - { - return _baseTaskScheduler.Enqueue(Action, extendedState, cancellationToken); - } - catch (Exception exception) when (exception is not ObjectDisposedException) - { - return TaskExtensions.FromAsyncException(exception); - } + return _baseTaskScheduler.Enqueue(Action, extendedState, cancellationToken); async ValueTask Action(ExtendedState s, CancellationToken token) { diff --git a/TaskFlow/ITaskScheduler.cs b/TaskFlow/ITaskScheduler.cs index d143959..6da4a14 100644 --- a/TaskFlow/ITaskScheduler.cs +++ b/TaskFlow/ITaskScheduler.cs @@ -51,6 +51,7 @@ public interface ITaskScheduler /// Resources are properly managed when the scheduler is disposed /// /// + /// Thrown synchronously if is null. /// Thrown synchronously if the scheduler or its owning lifetime has been disposed. /// An exception from an accepted operation is instead reported through its returned task. Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken); diff --git a/TaskFlow/Internal/TaskExtensions.cs b/TaskFlow/Internal/TaskExtensions.cs index 87cbb30..c0ab1a6 100644 --- a/TaskFlow/Internal/TaskExtensions.cs +++ b/TaskFlow/Internal/TaskExtensions.cs @@ -3,7 +3,6 @@ namespace System.Threading.Tasks.Flow.Internal; using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -11,14 +10,6 @@ namespace System.Threading.Tasks.Flow.Internal; internal static class TaskExtensions { - public static Task FromAsyncException(Exception exception) - { - // Keep the async method builder's original exception and cancellation-token semantics. - var completion = AsyncTaskMethodBuilder.Create(); - completion.SetException(exception); - return completion.Task; - } - public static void Await(this Task task) { Argument.NotNull(task); diff --git a/TaskFlow/TaskFlowSchedulerAdapter.cs b/TaskFlow/TaskFlowSchedulerAdapter.cs index 11164f7..a0109fc 100644 --- a/TaskFlow/TaskFlowSchedulerAdapter.cs +++ b/TaskFlow/TaskFlowSchedulerAdapter.cs @@ -1,9 +1,7 @@ namespace System.Threading.Tasks.Flow; -using System.Diagnostics.CodeAnalysis; using System.Runtime.ExceptionServices; using System.Threading.Tasks.Flow.Annotations; -using System.Threading.Tasks.Flow.Internal; /// /// Provides an adapter that allows .NET Framework's to be used as an . @@ -112,12 +110,12 @@ public TaskFlowSchedulerAdapter(TaskScheduler taskScheduler) /// scheduler starts the task, the task function may not be invoked. /// /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { + Argument.NotNull(taskFunc); + try { - Argument.NotNull(taskFunc); var scheduled = Task.Factory.StartNew(() => taskFunc(state, cancellationToken), cancellationToken, TaskCreationOptions.None, _taskScheduler); return AwaitOperation(scheduled); } @@ -127,10 +125,6 @@ public Task Enqueue(Func> taskFun ExceptionDispatchInfo.Capture(disposed).Throw(); throw; } - catch (Exception exception) when (exception is not ObjectDisposedException) - { - return TaskExtensions.FromAsyncException(exception); - } static async Task AwaitOperation(Task> scheduled) { diff --git a/TaskFlow/TaskSchedulerEnqueueExtensions.cs b/TaskFlow/TaskSchedulerEnqueueExtensions.cs index 4d45e41..6f78931 100644 --- a/TaskFlow/TaskSchedulerEnqueueExtensions.cs +++ b/TaskFlow/TaskSchedulerEnqueueExtensions.cs @@ -16,8 +16,8 @@ namespace System.Threading.Tasks.Flow; /// synchronous functions, and actions) and provide convenient overloads for common scenarios. /// /// -/// If a scheduler rejects an enqueue call by throwing , the exception -/// is propagated synchronously. Failures from accepted operations remain observable through their returned tasks. +/// Argument validation and scheduler rejection exceptions, including , +/// are propagated synchronously. Failures from accepted operations remain observable through their returned tasks. /// /// /// All extension methods perform argument validation and ensure proper exception propagation. @@ -77,7 +77,7 @@ public static class TaskSchedulerEnqueueExtensions /// The function to execute that accepts a cancellation token and returns a . /// A cancellation token that can be used to cancel the operation. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method adapts a function that takes only a cancellation token to the scheduler's signature @@ -88,6 +88,7 @@ public static class TaskSchedulerEnqueueExtensions public static Task Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, null, cancellationToken); @@ -105,7 +106,7 @@ ValueTask TaskFunc(object? state, CancellationToken token) /// The function to execute that accepts a cancellation token and returns a . /// A cancellation token that can be used to cancel the operation. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the returned by the function in a @@ -116,6 +117,7 @@ ValueTask TaskFunc(object? state, CancellationToken token) public static Task Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, null, cancellationToken); @@ -135,7 +137,7 @@ ValueTask TaskFunc(object? state, CancellationToken token) /// The state object to pass to the function. /// A cancellation token that can be used to cancel the operation. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method adapts a strongly-typed state parameter function to the scheduler's object-based state signature @@ -144,6 +146,7 @@ ValueTask TaskFunc(object? state, CancellationToken token) public static Task Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc, TState state, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, state, cancellationToken); @@ -162,7 +165,7 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// The state object to pass to the function. /// A cancellation token that can be used to cancel the operation. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the non-generic in a with a @@ -171,6 +174,7 @@ ValueTask TaskFunc(object? s, CancellationToken token) public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, TState state, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, state, cancellationToken); @@ -191,7 +195,7 @@ async ValueTask TaskFunc(object? s, CancellationToken token) /// The state object to pass to the function. /// A cancellation token that can be used to cancel the operation. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the returned by the function in a @@ -200,6 +204,7 @@ async ValueTask TaskFunc(object? s, CancellationToken token) public static Task Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc, TState state, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, state, cancellationToken); @@ -218,7 +223,7 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// The state object to pass to the function. /// A cancellation token that can be used to cancel the operation. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the non-generic in a to match the scheduler's @@ -227,6 +232,7 @@ ValueTask TaskFunc(object? s, CancellationToken token) public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, TState state, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(TaskFunc, (object?)state, cancellationToken); @@ -243,7 +249,7 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// The function to execute that accepts a cancellation token and returns a . /// A cancellation token that can be used to cancel the operation. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the non-generic in a with a @@ -251,7 +257,10 @@ ValueTask TaskFunc(object? s, CancellationToken token) /// public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc, CancellationToken cancellationToken) { - return EnqueueCore(taskScheduler, TaskFunc, cancellationToken); + Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); + + return taskScheduler.Enqueue(TaskFunc, null, cancellationToken); async ValueTask TaskFunc(object? state, CancellationToken token) { @@ -266,7 +275,7 @@ async ValueTask TaskFunc(object? state, CancellationToken token) /// The task scheduler to enqueue the operation on. /// The function to execute that accepts a cancellation token and returns a . /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token. @@ -283,7 +292,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, FuncThe task scheduler to enqueue the operation on. /// The function to execute that accepts a cancellation token and returns a . /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and wraps the @@ -294,6 +303,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(token => new ValueTask(taskFunc(token)), CancellationToken.None); } @@ -305,7 +315,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, FuncThe task scheduler to enqueue the operation on. /// The function to execute that returns a . /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and ignores the @@ -314,6 +324,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func Enqueue(this ITaskScheduler taskScheduler, Func> taskFunc) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(_ => new ValueTask(taskFunc()), CancellationToken.None); } @@ -324,7 +335,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func /// The task scheduler to enqueue the operation on. /// The function to execute that returns a . /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and ignores the @@ -333,6 +344,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFunc) { Argument.NotNull(taskScheduler); + Argument.NotNull(taskFunc); return taskScheduler.Enqueue(_ => taskFunc(), CancellationToken.None); } @@ -345,7 +357,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFun /// A cancellation token that can be used to cancel the operation. /// Dummy parameter to disambiguate from other overloads. Should always be null. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the non-generic in a with a @@ -355,7 +367,10 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func taskFun [SuppressMessage("ApiDesign", "RS0027:Public API with optional parameter(s) should have the most parameters amongst its public overloads", Justification = "The released optional dummy parameter disambiguates Task and ValueTask lambdas and cannot be removed without a source break.")] public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, CancellationToken cancellationToken, DummyParameter? _ = null) { - return EnqueueCore(taskScheduler, TaskFunc, cancellationToken); + Argument.NotNull(taskScheduler); + Argument.NotNull(valueTaskFunc); + + return taskScheduler.Enqueue(TaskFunc, null, cancellationToken); async ValueTask TaskFunc(object? state, CancellationToken token) { @@ -371,7 +386,7 @@ async ValueTask TaskFunc(object? state, CancellationToken token) /// The function to execute that accepts a cancellation token and returns a . /// Dummy parameter to disambiguate from other overloads. Should always be null. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token. The dummy parameter @@ -393,7 +408,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, FuncThe function to execute that accepts a cancellation token and returns a . /// Dummy parameter to disambiguate from other overloads. Should always be null. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token. The dummy parameter @@ -403,6 +418,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func Enqueue(this ITaskScheduler taskScheduler, Func> valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); + Argument.NotNull(valueTaskFunc); return taskScheduler.Enqueue(valueTaskFunc, CancellationToken.None); } @@ -415,7 +431,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, FuncThe function to execute that returns a . /// Dummy parameter to disambiguate from other overloads. Should always be null. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and ignores the @@ -426,6 +442,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func Enqueue(this ITaskScheduler taskScheduler, Func> valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); + Argument.NotNull(valueTaskFunc); return taskScheduler.Enqueue(_ => valueTaskFunc(), CancellationToken.None); } @@ -437,7 +454,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, FuncThe function to execute that returns a . /// Dummy parameter to disambiguate from other overloads. Should always be null. /// A representing the completion of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and ignores the @@ -448,6 +465,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func valueTaskFunc, DummyParameter? _ = null) { Argument.NotNull(taskScheduler); + Argument.NotNull(valueTaskFunc); return taskScheduler.Enqueue(_ => valueTaskFunc(), CancellationToken.None, _); } @@ -459,7 +477,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func va /// The action to execute that accepts a cancellation token. /// A cancellation token that can be used to cancel the operation. /// A representing the completion of the enqueued action. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the synchronous action in a to match the scheduler's @@ -467,13 +485,16 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func va /// public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { - return EnqueueCore( - taskScheduler, + Argument.NotNull(taskScheduler); + Argument.NotNull(action); + + return taskScheduler.Enqueue( (_, token) => { action(token); return new ValueTask(default(Unit)); }, + null, cancellationToken); } @@ -483,7 +504,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, ActionThe task scheduler to enqueue the operation on. /// The action to execute that accepts a cancellation token. /// A representing the completion of the enqueued action. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token and wraps the @@ -500,7 +521,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, ActionThe task scheduler to enqueue the operation on. /// The action to execute. /// A representing the completion of the enqueued action. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token, ignores the @@ -518,7 +539,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) /// The action to execute. /// A cancellation token that can be used to cancel the operation. /// A representing the completion of the enqueued action. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method ignores the cancellation token parameter when calling the action and wraps the @@ -526,13 +547,16 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action action) /// public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, CancellationToken cancellationToken) { - return EnqueueCore( - taskScheduler, + Argument.NotNull(taskScheduler); + Argument.NotNull(action); + + return taskScheduler.Enqueue( (_, token) => { action(); return new ValueTask(default(Unit)); }, + null, cancellationToken); } @@ -543,7 +567,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, Can /// The task scheduler to enqueue the operation on. /// The synchronous function to execute. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method uses for the cancellation token, ignores the @@ -552,6 +576,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Action action, Can public static Task Enqueue(this ITaskScheduler taskScheduler, Func func) { Argument.NotNull(taskScheduler); + Argument.NotNull(func); return taskScheduler.Enqueue(_ => new ValueTask(func()), CancellationToken.None); } @@ -564,7 +589,7 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func func /// The synchronous function to execute that accepts a cancellation token. /// A cancellation token that can be used to cancel the operation. /// A representing the result of the enqueued function. - /// Thrown when is null. + /// Thrown synchronously when or is null. /// Thrown if the scheduler has been disposed. /// /// This method wraps the synchronous result in a to match the scheduler's @@ -573,26 +598,11 @@ public static Task Enqueue(this ITaskScheduler taskScheduler, Func func public static Task Enqueue(this ITaskScheduler taskScheduler, Func func, CancellationToken cancellationToken) { Argument.NotNull(taskScheduler); + Argument.NotNull(func); return taskScheduler.Enqueue(token => new ValueTask(func(token)), cancellationToken); } - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] - private static Task EnqueueCore(ITaskScheduler? taskScheduler, Func> taskFunc, CancellationToken cancellationToken) - { - if (taskScheduler is null) - return Task.FromException(new ArgumentNullException(nameof(taskScheduler))); - - try - { - return taskScheduler.Enqueue(taskFunc, null, cancellationToken); - } - catch (Exception exception) when (exception is not ObjectDisposedException) - { - return TaskExtensions.FromAsyncException(exception); - } - } - /// /// A class with no possible value other than null. Used to mark an optional parameter which should never be set. /// Taken from . diff --git a/TaskFlow/ThreadTaskFlow.cs b/TaskFlow/ThreadTaskFlow.cs index 18ad3c0..d522c2a 100644 --- a/TaskFlow/ThreadTaskFlow.cs +++ b/TaskFlow/ThreadTaskFlow.cs @@ -94,24 +94,16 @@ protected ThreadTaskFlow(TaskFlowOptions options) /// be canceled so queue progression remains deterministic. /// /// - [SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Disposed calls throw synchronously; other scheduling errors retain task-based propagation.")] public override Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { - try - { - Argument.NotNull(taskFunc); + Argument.NotNull(taskFunc); - lock (ThisLock) - { - CheckDisposed(); - var executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); - _blockingCollection.Add(executionItem, CancellationToken.None); - return executionItem.Task; - } - } - catch (Exception exception) when (exception is not ObjectDisposedException) + lock (ThisLock) { - return TaskExtensions.FromAsyncException(exception); + CheckDisposed(); + var executionItem = new ExecutionItem(TaskFunc, state, _synchronizationContext, cancellationToken); + _blockingCollection.Add(executionItem, CancellationToken.None); + return executionItem.Task; } async Task TaskFunc(object? stateObject, CancellationToken token) From 46a7b3bfadbb182958d0e6027a4daefa68feec4d Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:53:03 +0000 Subject: [PATCH 06/10] Restore execution item cancellation helper --- TaskFlow/ThreadTaskFlow.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/TaskFlow/ThreadTaskFlow.cs b/TaskFlow/ThreadTaskFlow.cs index d522c2a..c906ced 100644 --- a/TaskFlow/ThreadTaskFlow.cs +++ b/TaskFlow/ThreadTaskFlow.cs @@ -212,6 +212,8 @@ protected void ThreadStart(object? _) private abstract class ExecutionItem { public abstract void Execute(CancellationToken cancellationToken); + + public abstract void Cancel(CancellationToken token); } // Complete the caller-facing typed task before Execute returns and the queue can shut down. @@ -309,6 +311,15 @@ void HandleAwaiterCompletion(TaskAwaiter awaiter) } } } + + /// + /// Cancels the execution of this item. + /// + /// The cancellation token that caused the cancellation. + public override void Cancel(CancellationToken token) + { + _taskCompletionSource.TrySetCanceled(token); + } } /// From 7deb974394133f6ec42f46e7af569e01069a43e8 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:07:13 +0000 Subject: [PATCH 07/10] Preserve annotation-only enqueue conformance after lifetime refactor --- .../LifetimeTrackingRegressionFixture.cs | 4 ++-- ...TaskSchedulerDisposalConformanceFixture.cs | 3 ++- docs/design/middleware-lifetime.md | 20 +++++++++++-------- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs b/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs index 2fe26e3..65676e6 100644 --- a/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs +++ b/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs @@ -93,8 +93,8 @@ public async Task AnnotationOnlySnapshot_DrainsAcceptedWorkAndRejectsClosedEntry var shutdown = flow.DisposeAsync().AsTask(); Assert.That(shutdown.IsCompleted, Is.False); Task rejected = null!; - Assert.That(() => { rejected = scheduler.Enqueue(() => 0); }, Throws.Nothing); - await Assert.ThatAsync(async () => await rejected, Throws.TypeOf()); + Assert.That(() => { rejected = scheduler.Enqueue(() => 0); }, Throws.TypeOf()); + Assert.That(rejected, Is.Null, "Closed entry must reject the enqueue call before returning a task."); release.SetResult(true); Assert.That(await pending, Is.EqualTo(42)); await shutdown; diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs index 62b8f55..93123ab 100644 --- a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs @@ -25,7 +25,7 @@ public TaskSchedulerDisposalConformanceFixture(string implementation, string con public static IEnumerable Scenarios() { foreach (var implementation in new[] { "TaskFlow", "DedicatedThread", "CurrentThread" }) - foreach (var configuration in new[] { "Plain", "Pipeline", "ExtendedState", "Bounded" }) + foreach (var configuration in new[] { "Plain", "Annotated", "Pipeline", "ExtendedState", "Bounded" }) yield return new TestFixtureData(implementation, configuration); foreach (var configuration in new[] { "Concurrent", "Keyed", "KeyedBounded" }) yield return new TestFixtureData("TaskFlow", configuration); @@ -60,6 +60,7 @@ public async Task SetUp() _scheduler = _configuration switch { "Plain" => _scheduler, + "Annotated" => _scheduler.WithOperationName("disposal-conformance"), "Pipeline" => _scheduler.UseMiddleware(_observer), "ExtendedState" => _scheduler.WithExtendedState(42), "Bounded" => _scheduler.WithBoundedAdmission(new BoundedAdmissionOptions { Capacity = 1 }).UseMiddleware(_observer), diff --git a/docs/design/middleware-lifetime.md b/docs/design/middleware-lifetime.md index 2ef8c6c..f4eb335 100644 --- a/docs/design/middleware-lifetime.md +++ b/docs/design/middleware-lifetime.md @@ -28,10 +28,14 @@ its explicit middleware lifetime has finished draining and cleaning up. ## Operation activities -PipelineOperation starts with one activity owned by EnqueueCore. After successful -lifetime entry it stores the owner directly, without a bound exit delegate. -Failure to enter must not invoke completion middleware whose resources may already -have been cleaned up, or decrement a lifetime it never entered. +PipelineOperation starts with one activity. Plain Enqueue enters its lifetime +synchronously before starting EnqueueCore, so a closed owner throws directly from +the enqueue call. Entry and closure use the same lock: accepted work is counted +before shutdown can drain it. After successful entry, the operation stores its +owner directly and EnqueueCore owns the initial activity. If entry fails, the +submission releases that activity without invoking completion middleware or +exiting a lifetime it never entered. Explicit admission Submit retains its +separate asynchronous decision/completion contract. Every invocation of an enqueue or execution continuation acquires an activity before calling user code. Independently pending work retains its activity even @@ -65,10 +69,10 @@ terminals retain pipeline activity tracking; their completion hooks may not drai all submitted work. Context-aware submissions still construct an operation so the final annotations remain available to the delegate. -The successful forwarding path returns the terminal task directly. A cold catch -preserves pipeline submission's asynchronous failure behavior when a terminal -throws before returning a task. Its async failure helper also preserves the -canceled-task semantics of OperationCanceledException, including uncanceled tokens. +Forwarding returns the terminal task directly. Enqueue-time validation and +scheduler rejection propagate synchronously, including ObjectDisposedException +when the terminal has closed entry. Failures and cancellation from accepted +operations remain observable through their returned tasks. ExecutionContext flow, caller cancellation and terminal scheduler selection are left to the existing scheduling paths. No lifetime scope needs to flow with them. \ No newline at end of file From 54702db0ee7141c2bc725fbfddd6283e927b0d36 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:01:25 +0000 Subject: [PATCH 08/10] Preserve synchronous enqueue rejection through forwarding pipelines --- .../CompletionContinuationLifetimeFixture.cs | 6 +- .../LifetimeTrackingRegressionFixture.cs | 3 +- ...skSchedulerForwardingConformanceFixture.cs | 269 ++++++++++++++++++ .../CancelPreviousTaskSchedulerExtensions.cs | 28 +- ...ancellationScopeTaskSchedulerExtensions.cs | 24 +- .../ThrottlingTaskSchedulerExtensions.cs | 2 +- ...eProviderTimeoutTaskSchedulerExtensions.cs | 4 +- .../TimeoutTaskSchedulerExtensions.cs | 4 +- TaskFlow/ITaskScheduler.cs | 4 + TaskFlow/Internal/TaskExtensions.cs | 73 +++-- TaskFlow/Internal/TaskSchedulerPipeline.cs | 63 ++-- .../ITaskSchedulerEnqueueMiddleware.cs | 4 +- docs/design/middleware-lifetime.md | 28 +- 13 files changed, 446 insertions(+), 66 deletions(-) create mode 100644 TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs diff --git a/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs b/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs index 4c0943e..86fc9e6 100644 --- a/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs +++ b/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs @@ -96,7 +96,7 @@ public async Task SubmissionFailure_IndependentCompletionStillDrains() await using var lifetime = new TaskSchedulerMiddlewareLifetime(); var outer = new IndependentCompletion(events); var inner = new GatedCompletion(events); - var scheduler = new FailingTerminal().UseMiddleware(new Factory(outer), lifetime) + var scheduler = new FaultedTerminal().UseMiddleware(new Factory(outer), lifetime) .UseMiddleware(new Factory(inner)); var operation = scheduler.Enqueue(() => 42); try @@ -250,10 +250,10 @@ public async Task DisposalRacingChildCompletion_CleansUpOnceAfterChild() } } - private sealed class FailingTerminal : ITaskScheduler + private sealed class FaultedTerminal : ITaskScheduler { public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) - => throw new FormatException("submission"); + => Task.FromException(new FormatException("submission")); } private sealed class Factory(ITaskSchedulerMiddleware middleware) : ITaskSchedulerMiddlewareFactory diff --git a/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs b/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs index 65676e6..9fd9b10 100644 --- a/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs +++ b/TaskFlow.Tests/Extensions/LifetimeTrackingRegressionFixture.cs @@ -223,7 +223,8 @@ public async Task InlineTerminals_PreserveContinuationResultsAndCancellation(boo { await Assert.ThatAsync(async () => await task, Throws.Exception.SameAs(failure)); Assert.That(task.IsCanceled, Is.EqualTo(failure is OperationCanceledException)); - Assert.That(observer.Status, Is.EqualTo(failure is OperationCanceledException ? TaskStatus.Canceled : TaskStatus.Faulted)); + // A synchronous next rejection returns no continuation task for this observer to inspect. + Assert.That(observer.Status, Is.EqualTo(!faultedTask ? TaskStatus.Created : failure is OperationCanceledException ? TaskStatus.Canceled : TaskStatus.Faulted)); } } } diff --git a/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs new file mode 100644 index 0000000..f6e4bb7 --- /dev/null +++ b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs @@ -0,0 +1,269 @@ +namespace TaskFlow.Tests; + +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +[TestFixture] +[CancelAfter(10000)] +internal sealed class TaskSchedulerForwardingConformanceFixture +{ + [Test] + public async Task Enqueue_ImmediateTerminalRejection_ShouldThrowSynchronously( + [Values("Annotated", "Forwarding", "Execution", "Completion", "Nested", "Owned", "Throttle", "CancellationScope", "CancelPrevious", "Timeout")] string configuration, + [Values("disposed", "argument", "failure", "cancellation")] string failureType, + [Values("DirectGeneric", "Action", "Task", "ValueTask")] string overload) + { + var failure = CreateFailure(failureType); + var terminal = new Terminal(failure, throwSynchronously: true); + await using var lifetime = new TaskSchedulerMiddlewareLifetime(); + using var observer = new Observer(); + var scheduler = Configure(terminal, configuration, lifetime, observer); + Task? returned = null; + + Assert.That(() => { returned = TaskSchedulerDisposalConformance.Enqueue(scheduler, overload, () => { }, false); }, Throws.Exception.SameAs(failure)); + Assert.That(returned, Is.Null); + Assert.That(terminal.Calls, Is.EqualTo(1)); + Assert.That(observer.Completions, Is.Zero, "Immediate scheduling rejection must reach the caller before a task is returned."); + + await lifetime.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + if (configuration == "Owned") + Assert.That(observer.Disposed, Is.True, "Rejected forwarding must release the pipeline's lifetime entry."); + } + + [Test] + public async Task Enqueue_ReturnedFailureTask_ShouldStayTaskBased( + [Values("Annotated", "Forwarding", "Execution", "Completion", "Nested", "Owned", "Throttle", "CancellationScope", "CancelPrevious", "Timeout")] string configuration, + [Values("disposed", "argument", "failure", "cancellation")] string failureType, + [Values("DirectGeneric", "Action", "Task", "ValueTask")] string overload) + { + var failure = CreateFailure(failureType); + var terminal = new Terminal(failure, throwSynchronously: false); + await using var lifetime = new TaskSchedulerMiddlewareLifetime(); + using var observer = new Observer(); + var scheduler = Configure(terminal, configuration, lifetime, observer); + Task? returned = null; + + Assert.That(() => { returned = TaskSchedulerDisposalConformance.Enqueue(scheduler, overload, () => { }, false); }, Throws.Nothing); + await Assert.ThatAsync(async () => await returned!, Throws.Exception.SameAs(failure)); + Assert.That(terminal.Calls, Is.EqualTo(1)); + Assert.That(observer.Completions, Is.EqualTo(configuration is "Completion" or "Owned" ? 1 : 0)); + } + + [Test] + public async Task Enqueue_ShortCircuit_ShouldNotProbeOrCallRejectedTerminal() + { + var terminal = new Terminal(new ObjectDisposedException("inner"), throwSynchronously: true); + var scheduler = terminal.UseMiddleware(new ConstantAdmission()); + Assert.That(await scheduler.Enqueue(() => 42), Is.EqualTo(17)); + Assert.That(terminal.Calls, Is.Zero); + } + + [Test] + public async Task Enqueue_DeferredForwarding_ShouldReportLaterTerminalRejectionThroughTask() + { + var failure = new ObjectDisposedException("inner"); + var terminal = new Terminal(failure, throwSynchronously: true); + var admission = new DeferredAdmission(); + using var observer = new Observer(); + var scheduler = terminal.UseMiddleware((ITaskSchedulerCompletionMiddleware)observer).UseMiddleware(admission); + Task? returned = null; + Assert.That(() => { returned = scheduler.Enqueue((_, _) => new ValueTask(42), null, CancellationToken.None); }, Throws.Nothing); + Assert.That(returned!.IsCompleted, Is.False); + Assert.That(terminal.Calls, Is.Zero); + + admission.Release.SetResult(); + await Assert.ThatAsync(async () => await returned, Throws.Exception.SameAs(failure)); + Assert.That(terminal.Calls, Is.EqualTo(1)); + Assert.That(observer.Completions, Is.EqualTo(1)); + } + + [Test] + public async Task Enqueue_ClosedOwnLifetime_ShouldRejectWithoutCallingAvailableTerminal() + { + var terminal = new Terminal(new InvalidOperationException("terminal must not be reached"), throwSynchronously: true); + await using var lifetime = new TaskSchedulerMiddlewareLifetime(); + using var observer = new Observer(); + var scheduler = terminal.UseMiddleware(new Factory(observer), lifetime); + await lifetime.DisposeAsync(); + Assert.That(() => scheduler.Enqueue(() => 42), Throws.TypeOf()); + Assert.That(terminal.Calls, Is.Zero); + Assert.That(observer.Forwards, Is.Zero); + Assert.That(observer.Disposed, Is.True); + } + + [Test] + public async Task Enqueue_SynchronousFailureAfterForwarding_ShouldRetainAcceptedChildUntilQuiescent() + { + await using var lifetime = new TaskSchedulerMiddlewareLifetime(); + using var observer = new Observer(); + var failure = new InvalidOperationException("after forwarding"); + var forwarding = new ThrowAfterForward(failure); + var scheduler = new TaskFlowSchedulerAdapter(TaskScheduler.Default) + .UseMiddleware(new Factory(observer), lifetime).UseMiddleware(forwarding); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async ValueTask Work(object? state, CancellationToken token) + { + entered.SetResult(); + await release.Task.ConfigureAwait(false); + return 42; + } + try + { + Assert.That(() => scheduler.Enqueue(Work, null, CancellationToken.None), Throws.Exception.SameAs(failure)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var shutdown = lifetime.DisposeAsync().AsTask(); + Assert.That(shutdown.IsCompleted, Is.False); + Assert.That(observer.Disposed, Is.False); + release.SetResult(); + await forwarding.Child!.WaitAsync(TimeSpan.FromSeconds(5)); + await shutdown.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(observer.Completions, Is.EqualTo(1)); + Assert.That(observer.Disposed, Is.True); + } + finally + { + release.TrySetResult(); + if (forwarding.Child != null) + await forwarding.Child.WaitAsync(TimeSpan.FromSeconds(5)); + } + } + + [Test] + public async Task Enqueue_Timeout_ShouldIncludeTimeSpentForwarding() + { + var clock = new FakeTimeProvider(); + var terminal = new TimeAdvancingTerminal(clock); + var scheduler = terminal.WithTimeout(TimeSpan.FromSeconds(30), clock); + try + { + var task = scheduler.Enqueue(() => 42); + await Assert.ThatAsync(async () => await task.WaitAsync(TimeSpan.FromSeconds(5)), + Throws.TypeOf().With.Message.Contains("Operation has timed out in")); + } + finally { terminal.Release.TrySetResult(); } + } + + private static ITaskScheduler Configure(ITaskScheduler terminal, string configuration, + TaskSchedulerMiddlewareLifetime lifetime, Observer observer) + => configuration switch + { + "Annotated" => terminal.WithOperationName("forwarding"), + "Forwarding" => terminal.UseMiddleware(new Forwarding()).UseMiddleware(new Forwarding()), + "Execution" => terminal.UseMiddleware(new Execution()), + "Completion" => terminal.UseMiddleware((ITaskSchedulerCompletionMiddleware)observer), + "Nested" => new Opaque(terminal.UseMiddleware(new Forwarding())).UseMiddleware(new Forwarding()), + "Owned" => terminal.UseMiddleware(new Factory(observer), lifetime), + "Throttle" => terminal.WithThrottle(TimeSpan.FromHours(1)), + "CancellationScope" => terminal.CreateCancellationScope(CancellationToken.None), + "CancelPrevious" => terminal.CreateCancelPrevious(), + "Timeout" => terminal.WithTimeout(TimeSpan.FromHours(1)), + _ => throw new ArgumentOutOfRangeException(nameof(configuration)), + }; + + private static Exception CreateFailure(string failureType) + => failureType switch + { + "disposed" => new ObjectDisposedException("inner"), + "argument" => new ArgumentException("rejected"), + "failure" => new InvalidOperationException("rejected"), + "cancellation" => new OperationCanceledException(new CancellationToken(canceled: true)), + _ => throw new ArgumentOutOfRangeException(nameof(failureType)), + }; + + private sealed class Terminal(Exception failure, bool throwSynchronously) : ITaskScheduler + { + public int Calls { get; private set; } + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + { + Calls++; + if (throwSynchronously) + throw failure; + return Task.FromException(failure); + } + } + + private sealed class TimeAdvancingTerminal(FakeTimeProvider clock) : ITaskScheduler + { + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + { + clock.Advance(TimeSpan.FromSeconds(31)); + return AwaitRelease(); + } + private async Task AwaitRelease() + { + await Release.Task.ConfigureAwait(false); + return default!; + } + } + + private sealed class Opaque(ITaskScheduler inner) : ITaskScheduler + { + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + => inner.Enqueue(taskFunc, state, cancellationToken); + } + + private sealed class Forwarding : ITaskSchedulerEnqueueMiddleware + { + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) => next(context); + } + + private sealed class Execution : ITaskSchedulerExecutionMiddleware + { + public ValueTask InvokeAsync(TaskSchedulerOperationContext context, TaskSchedulerExecutionDelegate next) => next(context); + } + + private sealed class ConstantAdmission : ITaskSchedulerEnqueueMiddleware + { + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) + => Task.FromResult((T)(object)17); + } + + private sealed class ThrowAfterForward(Exception failure) : ITaskSchedulerEnqueueMiddleware + { + public Task? Child { get; private set; } + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) + { + Child = next(context); + throw failure; + } + } + + private sealed class DeferredAdmission : ITaskSchedulerEnqueueMiddleware + { + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) + { + await Release.Task.ConfigureAwait(false); + return await next(context).ConfigureAwait(false); + } + } + + private sealed class Observer : ITaskSchedulerEnqueueMiddleware, ITaskSchedulerCompletionMiddleware, IDisposable + { + public int Forwards { get; private set; } + public int Completions { get; private set; } + public bool Disposed { get; private set; } + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate next) + { + ObjectDisposedException.ThrowIf(Disposed, this); + Forwards++; + return next(context); + } + public ValueTask> InvokeAsync(TaskSchedulerOperationContext context, + TaskSchedulerOperationOutcome outcome, TaskSchedulerCompletionDelegate next) + { + ObjectDisposedException.ThrowIf(Disposed, this); + Completions++; + return next(context, outcome); + } + public void Dispose() => Disposed = true; + } + + private sealed class Factory(Observer observer) : ITaskSchedulerMiddlewareFactory + { + public ITaskSchedulerMiddleware Create(TaskSchedulerMiddlewareLifetime lifetime) => observer; + } +} \ No newline at end of file diff --git a/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs b/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs index 0eae183..a446d92 100644 --- a/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs @@ -196,15 +196,35 @@ public CancelPreviousMiddleware() _cancelAllTokensAllocator = new CancelAllTokensAllocator(); } - public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { _cancelAllTokensAllocator.Cancel(); + var (pending, linkedToken, allocation) = Forward(context, continuation); + return AwaitOperation(pending, linkedToken, allocation); + } - using (_cancelAllTokensAllocator.AllocateCancellationToken(out var allocatedToken)) + private (Task Pending, CancellationTokenSource LinkedToken, IDisposable Allocation) Forward(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + { + var allocation = _cancelAllTokensAllocator.AllocateCancellationToken(out var allocatedToken); + CancellationTokenSource? linkedToken = null; + try + { + linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, allocatedToken); + return (continuation(context.WithCancellationToken(linkedToken.Token)), linkedToken, allocation); + } + catch { - using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, allocatedToken); - return await continuation(context.WithCancellationToken(linkedToken.Token)).ConfigureAwait(false); + linkedToken?.Dispose(); + allocation.Dispose(); + throw; } } + + private static async Task AwaitOperation(Task pending, CancellationTokenSource linkedToken, IDisposable allocation) + { + using (allocation) + using (linkedToken) + return await pending.ConfigureAwait(false); + } } } \ No newline at end of file diff --git a/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs b/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs index bb3c267..428b737 100644 --- a/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs @@ -150,10 +150,28 @@ public CancellationScopeMiddleware(CancellationToken scopedCancellationToken) _scopedCancellationToken = scopedCancellationToken; } - public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, _scopedCancellationToken); - return await continuation(context.WithCancellationToken(linkedToken.Token)).ConfigureAwait(false); + var (pending, linkedToken) = Forward(context, continuation); + return AwaitOperation(pending, linkedToken); + } + + private (Task Pending, CancellationTokenSource LinkedToken) Forward(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + { + var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, _scopedCancellationToken); + try + { return (continuation(context.WithCancellationToken(linkedToken.Token)), linkedToken); } + catch + { + linkedToken.Dispose(); + throw; + } + } + + private static async Task AwaitOperation(Task pending, CancellationTokenSource linkedToken) + { + using (linkedToken) + return await pending.ConfigureAwait(false); } } } \ No newline at end of file diff --git a/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs b/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs index 610bd98..2d44ae5 100644 --- a/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/ThrottlingTaskSchedulerExtensions.cs @@ -161,7 +161,7 @@ public Task InvokeAsync(TaskSchedulerEnqueueContext c var elapsed = _timeProvider.GetElapsedTime(_lastAdmissionTimestamp, currentTimestamp); if (_hasAdmission && elapsed < _interval) { - throw new OperationThrottledException($"Operation did not execute because the throttle interval has not elapsed. Interval: {_interval}. Elapsed: {elapsed}."); + return Task.FromException(new OperationThrottledException($"Operation did not execute because the throttle interval has not elapsed. Interval: {_interval}. Elapsed: {elapsed}.")); } _lastAdmissionTimestamp = currentTimestamp; diff --git a/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs b/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs index 6b46dd2..849030b 100644 --- a/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs @@ -39,9 +39,9 @@ public TimeoutMiddleware(TimeSpan timeout, TimeProvider timeProvider) _timeProvider = timeProvider; } - public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - return await Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken).ConfigureAwait(false); + return Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken); async Task TimeoutAsync(CancellationToken token) { diff --git a/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs b/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs index ecd48d9..04f992d 100644 --- a/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs @@ -231,9 +231,9 @@ public TimeoutMiddleware(TimeSpan timeout) _timeout = timeout; } - public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - return await Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken).ConfigureAwait(false); + return Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken); async Task TimeoutAsync(CancellationToken token) { diff --git a/TaskFlow/ITaskScheduler.cs b/TaskFlow/ITaskScheduler.cs index 6da4a14..1128d22 100644 --- a/TaskFlow/ITaskScheduler.cs +++ b/TaskFlow/ITaskScheduler.cs @@ -43,6 +43,10 @@ public interface ITaskScheduler /// exceptions thrown by the function. /// /// + /// A forwarding scheduler preserves synchronous rejection from calls to its underlying scheduler. If admission defers forwarding, + /// later scheduling failures are reported through the returned task. A returned faulted task is not synchronous rejection. + /// + /// /// Implementations should ensure that: /// /// diff --git a/TaskFlow/Internal/TaskExtensions.cs b/TaskFlow/Internal/TaskExtensions.cs index c0ab1a6..3f9dd24 100644 --- a/TaskFlow/Internal/TaskExtensions.cs +++ b/TaskFlow/Internal/TaskExtensions.cs @@ -50,40 +50,71 @@ public static bool Await(this Task task, TimeSpan timeout) } } - public static async Task WhenAnyCancelRest(this IEnumerable>> taskFactories, CancellationToken cancellationToken) + public static Task WhenAnyCancelRest(this IEnumerable>> taskFactories, CancellationToken cancellationToken) { Argument.NotNull(taskFactories); + var (tasks, cts, linkedCancellationTokenSource) = StartTasks(taskFactories, cancellationToken); + return AwaitFirst(tasks, cts, linkedCancellationTokenSource); + } - using var cts = new CancellationTokenSource(); - using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); - + private static (Task[] Tasks, CancellationTokenSource Cancellation, CancellationTokenSource LinkedCancellation) StartTasks( + IEnumerable>> taskFactories, CancellationToken cancellationToken) + { + var cts = new CancellationTokenSource(); + CancellationTokenSource? linkedCancellationTokenSource = null; + var tasks = new List>(); try { - var tasks = taskFactories.Select(func => func(linkedCancellationTokenSource.Token)).ToArray(); - var firstFinishedTask = await Task.WhenAny(tasks).ConfigureAwait(false); - - foreach (var task in tasks.Where(task => task != firstFinishedTask)) + linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); + foreach (var factory in taskFactories) + tasks.Add(factory(linkedCancellationTokenSource.Token)); + return (tasks.ToArray(), cts, linkedCancellationTokenSource); + } + catch + { + foreach (var task in tasks) + ObserveFailure(task); + try + { cts.Cancel(throwOnFirstException: false); } + finally { - HandleException(task); + linkedCancellationTokenSource?.Dispose(); + cts.Dispose(); } - - return await firstFinishedTask.ConfigureAwait(false); + throw; } - finally + } + + private static async Task AwaitFirst(Task[] tasks, CancellationTokenSource cts, CancellationTokenSource linkedCancellationTokenSource) + { + using (cts) + using (linkedCancellationTokenSource) { + try + { + var firstFinishedTask = await Task.WhenAny(tasks).ConfigureAwait(false); + foreach (var task in tasks.Where(task => task != firstFinishedTask)) + { + ObserveFailure(task); + } + return await firstFinishedTask.ConfigureAwait(false); + } + finally + { #if NET8_0_OR_GREATER - await cts.CancelAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); #else - cts.Cancel(); + cts.Cancel(); #endif + } } + } - static void HandleException(Task task) - { - task.ContinueWith(t => t.Exception!, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } + private static void ObserveFailure(Task task) + { + _ = task.ContinueWith(t => t.Exception!, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); } } \ No newline at end of file diff --git a/TaskFlow/Internal/TaskSchedulerPipeline.cs b/TaskFlow/Internal/TaskSchedulerPipeline.cs index 9d45e5c..d009c91 100644 --- a/TaskFlow/Internal/TaskSchedulerPipeline.cs +++ b/TaskFlow/Internal/TaskSchedulerPipeline.cs @@ -115,39 +115,51 @@ private Task SubmitOperation(PipelineOperation operat { operation.AdmissionRequest = request; if (request != null) + { operation.OnQuiesced(request.OnQuiesced); - else if (Lifetime != null) + return EnqueueCore(operation); + } + return EnqueueOperation(operation); + } + + private Task EnqueueOperation(PipelineOperation operation) + { + try { - // Enqueue entry is synchronous and atomic with closing the owning lifetime. - // Explicit admission submissions keep their separate decision/completion contract. - try + if (Lifetime != null) { Lifetime.Enter(); operation.Lifetime = Lifetime; } - catch (ObjectDisposedException) - { - operation.ReleaseActivity(); - throw; - } + var pending = InvokeEnqueueCore(operation, _registrations.Length - 1, operation.CallerCancellationToken); + Annotations.Argument.NotNull(pending); + return EnqueueCore(operation, pending); + } + catch + { + operation.ReleaseActivity(); + throw; } - return EnqueueCore(operation); } - private async Task EnqueueCore(PipelineOperation operation) + private async Task EnqueueCore(PipelineOperation operation, Task? pending = null) { try { TResult result; try { - if (Lifetime != null && operation.Lifetime == null) + if (pending == null) { - Lifetime.Enter(); - operation.Lifetime = Lifetime; + if (Lifetime != null) + { + Lifetime.Enter(); + operation.Lifetime = Lifetime; + } + // Explicit submissions report admission and scheduling failures through their tasks. + pending = InvokeEnqueueCore(operation, _registrations.Length - 1, operation.CallerCancellationToken); } - // The root activity already covers this invocation, including completion middleware. - result = await InvokeEnqueueCore(operation, _registrations.Length - 1, operation.CallerCancellationToken).ConfigureAwait(false); + result = await pending.ConfigureAwait(false); } catch (Exception exception) { @@ -167,12 +179,25 @@ private async Task EnqueueCore(PipelineOperation oper finally { operation.ReleaseActivity(); } } - private async Task InvokeEnqueue(PipelineOperation operation, int index, CancellationToken cancellationToken) + private Task InvokeEnqueue(PipelineOperation operation, int index, CancellationToken cancellationToken) { operation.AddActivity(); try - { return await InvokeEnqueueCore(operation, index, cancellationToken).ConfigureAwait(false); } - finally { operation.ReleaseActivity(); } + { + return AwaitActivity(operation, InvokeEnqueueCore(operation, index, cancellationToken)); + } + catch + { + operation.ReleaseActivity(); + throw; + } + + static async Task AwaitActivity(PipelineOperation operation, Task pending) + { + try + { return await pending.ConfigureAwait(false); } + finally { operation.ReleaseActivity(); } + } } private Task InvokeEnqueueCore(PipelineOperation operation, int index, CancellationToken cancellationToken) diff --git a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs index cad97be..ae5bb59 100644 --- a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs +++ b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs @@ -19,7 +19,9 @@ public interface ITaskSchedulerEnqueueMiddleware : ITaskSchedulerMiddleware /// /// Return a completed, failed, or canceled task without calling to satisfy or reject the request without /// submitting a terminal delegate. Do not call the continuation more than once. - /// Exceptions returned or thrown by this method enter the completion pipeline even though terminal execution has not started. + /// Failures reported through the returned task enter the completion pipeline even though terminal execution has not started. + /// A synchronous exception thrown while forwarding propagates from the enqueue call. Middleware that admits or defers work by returning + /// its own task reports later forwarding failures through that task. /// Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation); } \ No newline at end of file diff --git a/docs/design/middleware-lifetime.md b/docs/design/middleware-lifetime.md index f4eb335..884833c 100644 --- a/docs/design/middleware-lifetime.md +++ b/docs/design/middleware-lifetime.md @@ -29,12 +29,13 @@ its explicit middleware lifetime has finished draining and cleaning up. ## Operation activities PipelineOperation starts with one activity. Plain Enqueue enters its lifetime -synchronously before starting EnqueueCore, so a closed owner throws directly from -the enqueue call. Entry and closure use the same lock: accepted work is counted -before shutdown can drain it. After successful entry, the operation stores its -owner directly and EnqueueCore owns the initial activity. If entry fails, the -submission releases that activity without invoking completion middleware or -exiting a lifetime it never entered. Explicit admission Submit retains its +synchronously to protect its own middleware resources before forwarding. It does +not probe the terminal scheduler's disposal state. Entry and closure use the same +lock: accepted work is counted before shutdown can drain it. After successful entry, the operation stores its +owner directly and EnqueueCore owns the initial activity once forwarding returns +a task. If entry or synchronous forwarding fails first, submission releases the +activity and propagates the exception without invoking completion middleware. +A failed entry never exits a lifetime it did not enter. Explicit admission Submit retains its separate asynchronous decision/completion contract. Every invocation of an enqueue or execution continuation acquires an activity @@ -56,9 +57,18 @@ can race to report it; the winning outcome drives completion middleware. Caller admission outcomes are recorded after completion middleware has processed failures or recovery, rather than from the raw terminal outcome. -Each enqueue continuation uses async try/finally to retain its activity until its -own returned task settles, even if middleware independently completes the -caller-facing task earlier. This also preserves normal async exception semantics. +Each enqueue continuation forwards synchronously before observing the returned +task. Immediate scheduling rejection releases its activity and propagates directly +through forwarding pipelines, preserving the original exception. Once a task is +returned, observation retains the activity until that task settles, even when +middleware completes the caller-facing task earlier. Returned task failures enter +completion middleware; they are never promoted into synchronous exceptions. + +Admission middleware can short-circuit or defer forwarding according to its own +state. It does not inspect terminal disposal to manufacture an early rejection. +If it returns a task and forwards later, a later terminal rejection faults that +task. Explicit Submit continues to report admission and scheduling outcomes +through its decision/completion tasks. ## Annotation-only forwarding From 5077ada0f62988c9b3705a377dbce9095cfe1ad6 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:38:22 +0000 Subject: [PATCH 09/10] Narrow forwarding contract and restore timeout task semantics --- .../CompletionContinuationLifetimeFixture.cs | 2 +- ...skSchedulerForwardingConformanceFixture.cs | 127 ++++++++++++++++-- ...eProviderTimeoutTaskSchedulerExtensions.cs | 4 +- .../TimeoutTaskSchedulerExtensions.cs | 4 +- TaskFlow/ITaskScheduler.cs | 4 +- TaskFlow/Internal/TaskExtensions.cs | 73 +++------- TaskFlow/Internal/TaskSchedulerPipeline.cs | 6 +- .../ITaskSchedulerEnqueueMiddleware.cs | 4 +- docs/design/middleware-lifetime.md | 15 ++- 9 files changed, 162 insertions(+), 77 deletions(-) diff --git a/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs b/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs index 86fc9e6..744b0a1 100644 --- a/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs +++ b/TaskFlow.Tests/Extensions/CompletionContinuationLifetimeFixture.cs @@ -90,7 +90,7 @@ public async Task IndependentChildren_DrainBeforeOwnedCleanup(bool explicitLifet } [Test] - public async Task SubmissionFailure_IndependentCompletionStillDrains() + public async Task ReturnedSubmissionFailure_IndependentCompletionStillDrains() { var events = new ConcurrentQueue(); await using var lifetime = new TaskSchedulerMiddlewareLifetime(); diff --git a/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs index f6e4bb7..1e1e8b1 100644 --- a/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs @@ -8,11 +8,20 @@ namespace TaskFlow.Tests; [CancelAfter(10000)] internal sealed class TaskSchedulerForwardingConformanceFixture { - [Test] + [TestCase("Annotated", "disposed", "DirectGeneric")] + [TestCase("Forwarding", "disposed", "DirectGeneric")] + [TestCase("Execution", "disposed", "Task")] + [TestCase("Completion", "disposed", "Action")] + [TestCase("Nested", "disposed", "ValueTask")] + [TestCase("Owned", "disposed", "DirectGeneric")] + [TestCase("Throttle", "disposed", "DirectGeneric")] + [TestCase("CancellationScope", "disposed", "DirectGeneric")] + [TestCase("CancelPrevious", "disposed", "DirectGeneric")] + [TestCase("Forwarding", "argument", "Action")] + [TestCase("Forwarding", "failure", "Task")] + [TestCase("Forwarding", "cancellation", "ValueTask")] public async Task Enqueue_ImmediateTerminalRejection_ShouldThrowSynchronously( - [Values("Annotated", "Forwarding", "Execution", "Completion", "Nested", "Owned", "Throttle", "CancellationScope", "CancelPrevious", "Timeout")] string configuration, - [Values("disposed", "argument", "failure", "cancellation")] string failureType, - [Values("DirectGeneric", "Action", "Task", "ValueTask")] string overload) + string configuration, string failureType, string overload) { var failure = CreateFailure(failureType); var terminal = new Terminal(failure, throwSynchronously: true); @@ -31,11 +40,14 @@ public async Task Enqueue_ImmediateTerminalRejection_ShouldThrowSynchronously( Assert.That(observer.Disposed, Is.True, "Rejected forwarding must release the pipeline's lifetime entry."); } - [Test] + [TestCase("Forwarding", "disposed", "DirectGeneric")] + [TestCase("Completion", "disposed", "Action")] + [TestCase("Owned", "argument", "DirectGeneric")] + [TestCase("CancellationScope", "failure", "Task")] + [TestCase("CancelPrevious", "cancellation", "ValueTask")] + [TestCase("Timeout", "disposed", "DirectGeneric")] public async Task Enqueue_ReturnedFailureTask_ShouldStayTaskBased( - [Values("Annotated", "Forwarding", "Execution", "Completion", "Nested", "Owned", "Throttle", "CancellationScope", "CancelPrevious", "Timeout")] string configuration, - [Values("disposed", "argument", "failure", "cancellation")] string failureType, - [Values("DirectGeneric", "Action", "Task", "ValueTask")] string overload) + string configuration, string failureType, string overload) { var failure = CreateFailure(failureType); var terminal = new Terminal(failure, throwSynchronously: false); @@ -50,6 +62,51 @@ public async Task Enqueue_ReturnedFailureTask_ShouldStayTaskBased( Assert.That(observer.Completions, Is.EqualTo(configuration is "Completion" or "Owned" ? 1 : 0)); } + [Test] + public async Task Enqueue_Timeout_ShouldReportImmediateTerminalRejectionThroughTask() + { + var failure = new ObjectDisposedException("inner"); + var terminal = new Terminal(failure, throwSynchronously: true); + using var observer = new Observer(); + var scheduler = terminal.UseMiddleware((ITaskSchedulerCompletionMiddleware)observer).WithTimeout(TimeSpan.FromHours(1)); + Task? returned = null; + + Assert.That(() => { returned = scheduler.Enqueue(() => 42); }, Throws.Nothing); + await Assert.ThatAsync(async () => await returned!, Throws.Exception.SameAs(failure)); + Assert.That(terminal.Calls, Is.EqualTo(1)); + Assert.That(observer.Completions, Is.EqualTo(1)); + } + + [TestCase("CancellationScope", true)] + [TestCase("CancellationScope", false)] + [TestCase("CancelPrevious", true)] + [TestCase("CancelPrevious", false)] + public async Task Enqueue_CancellationResources_ShouldLiveUntilCompletionOrSynchronousRejection(string configuration, bool reject) + { + var failure = new ObjectDisposedException("inner"); + var terminal = new TokenCapturingTerminal(failure, reject); + using var caller = new CancellationTokenSource(); + var scheduler = configuration == "CancellationScope" + ? terminal.CreateCancellationScope(CancellationToken.None) + : terminal.CreateCancelPrevious(); + try + { + if (reject) + Assert.That(() => scheduler.Enqueue((_, _) => new ValueTask(42), null, caller.Token), Throws.Exception.SameAs(failure)); + else + { + var pending = scheduler.Enqueue((_, _) => new ValueTask(42), null, caller.Token); + Assert.That(() => terminal.Token.WaitHandle, Throws.Nothing, "Accepted work must retain its linked source."); + await caller.CancelAsync(); + Assert.That(terminal.Token.IsCancellationRequested, Is.True); + terminal.Release.SetResult(); + await pending; + } + Assert.That(() => terminal.Token.WaitHandle, Throws.TypeOf(), "The linked source must be released."); + } + finally { terminal.Release.TrySetResult(); } + } + [Test] public async Task Enqueue_ShortCircuit_ShouldNotProbeOrCallRejectedTerminal() { @@ -145,6 +202,42 @@ await Assert.ThatAsync(async () => await task.WaitAsync(TimeSpan.FromSeconds(5)) finally { terminal.Release.TrySetResult(); } } + [Test] + public async Task Enqueue_Timeout_ShouldRetainAcceptedExecutionUntilLifetimeDrains() + { + await using var flow = new TaskFlow(); + using var observer = new Observer(); + var clock = new FakeTimeProvider(); + var scheduler = flow.UseMiddleware(new Factory(observer)).WithTimeout(TimeSpan.FromSeconds(30), clock); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + var task = scheduler.Enqueue(async (_, _) => + { + entered.SetResult(); + await release.Task.ConfigureAwait(false); + return 42; + }, null, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + clock.Advance(TimeSpan.FromSeconds(31)); + await Assert.ThatAsync(async () => await task, Throws.TypeOf()); + Assert.That(observer.Completions, Is.EqualTo(1)); + var shutdown = flow.DisposeAsync().AsTask(); + Assert.That(shutdown.IsCompleted, Is.False); + Assert.That(observer.Disposed, Is.False); + release.SetResult(); + await shutdown.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.That(observer.Completions, Is.EqualTo(1)); + Assert.That(observer.Disposed, Is.True); + } + finally + { + release.TrySetResult(); + await flow.DisposeAsync(); + } + } + private static ITaskScheduler Configure(ITaskScheduler terminal, string configuration, TaskSchedulerMiddlewareLifetime lifetime, Observer observer) => configuration switch @@ -184,6 +277,24 @@ public Task Enqueue(Func> taskFun } } + private sealed class TokenCapturingTerminal(Exception failure, bool reject) : ITaskScheduler + { + public CancellationToken Token { get; private set; } + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + { + Token = cancellationToken; + if (reject) + throw failure; + return AwaitRelease(); + } + private async Task AwaitRelease() + { + await Release.Task.ConfigureAwait(false); + return default!; + } + } + private sealed class TimeAdvancingTerminal(FakeTimeProvider clock) : ITaskScheduler { public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs b/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs index 849030b..6b46dd2 100644 --- a/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/TimeProviderTimeoutTaskSchedulerExtensions.cs @@ -39,9 +39,9 @@ public TimeoutMiddleware(TimeSpan timeout, TimeProvider timeProvider) _timeProvider = timeProvider; } - public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - return Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken); + return await Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken).ConfigureAwait(false); async Task TimeoutAsync(CancellationToken token) { diff --git a/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs b/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs index 04f992d..ecd48d9 100644 --- a/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/TimeoutTaskSchedulerExtensions.cs @@ -231,9 +231,9 @@ public TimeoutMiddleware(TimeSpan timeout) _timeout = timeout; } - public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - return Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken); + return await Internal.TaskExtensions.WhenAnyCancelRest(new[] { TimeoutAsync, EnqueueInternalAsync }, context.CancellationToken).ConfigureAwait(false); async Task TimeoutAsync(CancellationToken token) { diff --git a/TaskFlow/ITaskScheduler.cs b/TaskFlow/ITaskScheduler.cs index 1128d22..a179008 100644 --- a/TaskFlow/ITaskScheduler.cs +++ b/TaskFlow/ITaskScheduler.cs @@ -43,8 +43,8 @@ public interface ITaskScheduler /// exceptions thrown by the function. /// /// - /// A forwarding scheduler preserves synchronous rejection from calls to its underlying scheduler. If admission defers forwarding, - /// later scheduling failures are reported through the returned task. A returned faulted task is not synchronous rejection. + /// Transparent forwarding preserves synchronous exceptions from the underlying enqueue call. Middleware that owns asynchronous + /// admission or scheduling, such as timeout or queuing, may report scheduling failures through its returned task. /// /// /// Implementations should ensure that: diff --git a/TaskFlow/Internal/TaskExtensions.cs b/TaskFlow/Internal/TaskExtensions.cs index 3f9dd24..c0ab1a6 100644 --- a/TaskFlow/Internal/TaskExtensions.cs +++ b/TaskFlow/Internal/TaskExtensions.cs @@ -50,71 +50,40 @@ public static bool Await(this Task task, TimeSpan timeout) } } - public static Task WhenAnyCancelRest(this IEnumerable>> taskFactories, CancellationToken cancellationToken) + public static async Task WhenAnyCancelRest(this IEnumerable>> taskFactories, CancellationToken cancellationToken) { Argument.NotNull(taskFactories); - var (tasks, cts, linkedCancellationTokenSource) = StartTasks(taskFactories, cancellationToken); - return AwaitFirst(tasks, cts, linkedCancellationTokenSource); - } - private static (Task[] Tasks, CancellationTokenSource Cancellation, CancellationTokenSource LinkedCancellation) StartTasks( - IEnumerable>> taskFactories, CancellationToken cancellationToken) - { - var cts = new CancellationTokenSource(); - CancellationTokenSource? linkedCancellationTokenSource = null; - var tasks = new List>(); + using var cts = new CancellationTokenSource(); + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); + try { - linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); - foreach (var factory in taskFactories) - tasks.Add(factory(linkedCancellationTokenSource.Token)); - return (tasks.ToArray(), cts, linkedCancellationTokenSource); - } - catch - { - foreach (var task in tasks) - ObserveFailure(task); - try - { cts.Cancel(throwOnFirstException: false); } - finally + var tasks = taskFactories.Select(func => func(linkedCancellationTokenSource.Token)).ToArray(); + var firstFinishedTask = await Task.WhenAny(tasks).ConfigureAwait(false); + + foreach (var task in tasks.Where(task => task != firstFinishedTask)) { - linkedCancellationTokenSource?.Dispose(); - cts.Dispose(); + HandleException(task); } - throw; - } - } - private static async Task AwaitFirst(Task[] tasks, CancellationTokenSource cts, CancellationTokenSource linkedCancellationTokenSource) - { - using (cts) - using (linkedCancellationTokenSource) + return await firstFinishedTask.ConfigureAwait(false); + } + finally { - try - { - var firstFinishedTask = await Task.WhenAny(tasks).ConfigureAwait(false); - foreach (var task in tasks.Where(task => task != firstFinishedTask)) - { - ObserveFailure(task); - } - return await firstFinishedTask.ConfigureAwait(false); - } - finally - { #if NET8_0_OR_GREATER - await cts.CancelAsync().ConfigureAwait(false); + await cts.CancelAsync().ConfigureAwait(false); #else - cts.Cancel(); + cts.Cancel(); #endif - } } - } - private static void ObserveFailure(Task task) - { - _ = task.ContinueWith(t => t.Exception!, - CancellationToken.None, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + static void HandleException(Task task) + { + task.ContinueWith(t => t.Exception!, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } } } \ No newline at end of file diff --git a/TaskFlow/Internal/TaskSchedulerPipeline.cs b/TaskFlow/Internal/TaskSchedulerPipeline.cs index d009c91..03af9c1 100644 --- a/TaskFlow/Internal/TaskSchedulerPipeline.cs +++ b/TaskFlow/Internal/TaskSchedulerPipeline.cs @@ -119,11 +119,7 @@ private Task SubmitOperation(PipelineOperation operat operation.OnQuiesced(request.OnQuiesced); return EnqueueCore(operation); } - return EnqueueOperation(operation); - } - - private Task EnqueueOperation(PipelineOperation operation) - { + // Plain enqueue invokes forwarding before handing ownership to the task observer. try { if (Lifetime != null) diff --git a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs index ae5bb59..f238cc3 100644 --- a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs +++ b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs @@ -20,8 +20,8 @@ public interface ITaskSchedulerEnqueueMiddleware : ITaskSchedulerMiddleware /// Return a completed, failed, or canceled task without calling to satisfy or reject the request without /// submitting a terminal delegate. Do not call the continuation more than once. /// Failures reported through the returned task enter the completion pipeline even though terminal execution has not started. - /// A synchronous exception thrown while forwarding propagates from the enqueue call. Middleware that admits or defers work by returning - /// its own task reports later forwarding failures through that task. + /// Transparent forwarding preserves synchronous exceptions without converting them into completion outcomes. Middleware that owns + /// asynchronous admission or scheduling (for example, queuing or timeout) may report forwarding failures through its returned task. /// Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation); } \ No newline at end of file diff --git a/docs/design/middleware-lifetime.md b/docs/design/middleware-lifetime.md index 884833c..d1f4efe 100644 --- a/docs/design/middleware-lifetime.md +++ b/docs/design/middleware-lifetime.md @@ -66,9 +66,18 @@ completion middleware; they are never promoted into synchronous exceptions. Admission middleware can short-circuit or defer forwarding according to its own state. It does not inspect terminal disposal to manufacture an early rejection. -If it returns a task and forwards later, a later terminal rejection faults that -task. Explicit Submit continues to report admission and scheduling outcomes -through its decision/completion tasks. +Transparent wrappers preserve exceptions from synchronous forwarding. Middleware +that owns asynchronous admission or scheduling, including timeout and queuing, +may report forwarding failures through its returned task even if it invokes the +continuation immediately. Such task failures still enter completion middleware. +Explicit Submit continues to report admission and scheduling outcomes through +its decision/completion tasks. + +Cancellation scope and cancel-previous wrappers separate forwarding from task +observation so synchronous rejection disposes their linked tokens immediately, +while accepted operations retain those resources until their tasks settle. +Timeout retains its competing-task semantics and does not require a synchronous +startup path in the general-purpose task-racing helper. ## Annotation-only forwarding From 0632faa2e8ab011bd9b10ef70e77f878b37db4d6 Mon Sep 17 00:00:00 2001 From: Volodymyr Dombrovskyi <5788605+dombrovsky@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:04:27 +0000 Subject: [PATCH 10/10] Restore adapter and cancellation wrapper exception semantics --- .../TaskFlowSchedulerAdapterFixture.cs | 37 +++++++++++++++++++ ...TaskSchedulerDisposalConformanceFixture.cs | 1 - ...skSchedulerForwardingConformanceFixture.cs | 19 ++++++---- .../CancelPreviousTaskSchedulerExtensions.cs | 28 ++------------ ...ancellationScopeTaskSchedulerExtensions.cs | 24 ++---------- TaskFlow/ITaskScheduler.cs | 8 +--- TaskFlow/Internal/TaskSchedulerPipeline.cs | 9 +++-- .../ITaskSchedulerEnqueueMiddleware.cs | 4 +- TaskFlow/TaskFlowSchedulerAdapter.cs | 24 ++---------- docs/design/middleware-lifetime.md | 11 +++--- 10 files changed, 75 insertions(+), 90 deletions(-) create mode 100644 TaskFlow.Tests/TaskFlowSchedulerAdapterFixture.cs diff --git a/TaskFlow.Tests/TaskFlowSchedulerAdapterFixture.cs b/TaskFlow.Tests/TaskFlowSchedulerAdapterFixture.cs new file mode 100644 index 0000000..f719593 --- /dev/null +++ b/TaskFlow.Tests/TaskFlowSchedulerAdapterFixture.cs @@ -0,0 +1,37 @@ +namespace TaskFlow.Tests; + +using NUnit.Framework; +using System.Threading.Tasks.Flow; + +[TestFixture] +internal sealed class TaskFlowSchedulerAdapterFixture +{ + [TestCase(false)] + [TestCase(true)] + public async Task Enqueue_QueueTaskFailure_ShouldPreserveStartNewException(bool disposed) + { + Exception failure = disposed ? new ObjectDisposedException("scheduler") : new InvalidOperationException("rejected"); + var scheduler = new TaskFlowSchedulerAdapter(new RejectingScheduler(failure)); + Task? pending = null; + + Assert.That(() => { pending = scheduler.Enqueue((_, _) => new ValueTask(42), null, CancellationToken.None); }, Throws.Nothing); + await Assert.ThatAsync(async () => await pending!, + Throws.TypeOf().With.Property(nameof(Exception.InnerException)).SameAs(failure)); + } + + [Test] + public async Task Enqueue_NullDelegate_ShouldRetainTaskBasedValidation() + { + var scheduler = new TaskFlowSchedulerAdapter(TaskScheduler.Default); + Task? pending = null; + Assert.That(() => { pending = scheduler.Enqueue(null!, null, CancellationToken.None); }, Throws.Nothing); + await Assert.ThatAsync(async () => await pending!, Throws.TypeOf()); + } + + private sealed class RejectingScheduler(Exception failure) : TaskScheduler + { + protected override void QueueTask(Task task) => throw failure; + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + protected override IEnumerable GetScheduledTasks() => []; + } +} \ No newline at end of file diff --git a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs index 93123ab..5a44ff8 100644 --- a/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerDisposalConformanceFixture.cs @@ -29,7 +29,6 @@ public static IEnumerable Scenarios() yield return new TestFixtureData(implementation, configuration); foreach (var configuration in new[] { "Concurrent", "Keyed", "KeyedBounded" }) yield return new TestFixtureData("TaskFlow", configuration); - yield return new TestFixtureData("Adapter", "Plain"); yield return new TestFixtureData("Adapter", "ExplicitLifetime"); } diff --git a/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs index 1e1e8b1..093f834 100644 --- a/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs +++ b/TaskFlow.Tests/TaskSchedulerForwardingConformanceFixture.cs @@ -15,8 +15,6 @@ internal sealed class TaskSchedulerForwardingConformanceFixture [TestCase("Nested", "disposed", "ValueTask")] [TestCase("Owned", "disposed", "DirectGeneric")] [TestCase("Throttle", "disposed", "DirectGeneric")] - [TestCase("CancellationScope", "disposed", "DirectGeneric")] - [TestCase("CancelPrevious", "disposed", "DirectGeneric")] [TestCase("Forwarding", "argument", "Action")] [TestCase("Forwarding", "failure", "Task")] [TestCase("Forwarding", "cancellation", "ValueTask")] @@ -62,13 +60,16 @@ public async Task Enqueue_ReturnedFailureTask_ShouldStayTaskBased( Assert.That(observer.Completions, Is.EqualTo(configuration is "Completion" or "Owned" ? 1 : 0)); } - [Test] - public async Task Enqueue_Timeout_ShouldReportImmediateTerminalRejectionThroughTask() + [TestCase("Timeout")] + [TestCase("CancellationScope")] + [TestCase("CancelPrevious")] + public async Task Enqueue_ExistingAsyncWrapper_ShouldReportImmediateTerminalRejectionThroughTask(string configuration) { var failure = new ObjectDisposedException("inner"); var terminal = new Terminal(failure, throwSynchronously: true); using var observer = new Observer(); - var scheduler = terminal.UseMiddleware((ITaskSchedulerCompletionMiddleware)observer).WithTimeout(TimeSpan.FromHours(1)); + await using var lifetime = new TaskSchedulerMiddlewareLifetime(); + var scheduler = Configure(terminal.UseMiddleware((ITaskSchedulerCompletionMiddleware)observer), configuration, lifetime, observer); Task? returned = null; Assert.That(() => { returned = scheduler.Enqueue(() => 42); }, Throws.Nothing); @@ -81,7 +82,7 @@ public async Task Enqueue_Timeout_ShouldReportImmediateTerminalRejectionThroughT [TestCase("CancellationScope", false)] [TestCase("CancelPrevious", true)] [TestCase("CancelPrevious", false)] - public async Task Enqueue_CancellationResources_ShouldLiveUntilCompletionOrSynchronousRejection(string configuration, bool reject) + public async Task Enqueue_CancellationResources_ShouldLiveUntilCompletionOrRejection(string configuration, bool reject) { var failure = new ObjectDisposedException("inner"); var terminal = new TokenCapturingTerminal(failure, reject); @@ -92,7 +93,11 @@ public async Task Enqueue_CancellationResources_ShouldLiveUntilCompletionOrSynch try { if (reject) - Assert.That(() => scheduler.Enqueue((_, _) => new ValueTask(42), null, caller.Token), Throws.Exception.SameAs(failure)); + { + Task? pending = null; + Assert.That(() => { pending = scheduler.Enqueue((_, _) => new ValueTask(42), null, caller.Token); }, Throws.Nothing); + await Assert.ThatAsync(async () => await pending!, Throws.Exception.SameAs(failure)); + } else { var pending = scheduler.Enqueue((_, _) => new ValueTask(42), null, caller.Token); diff --git a/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs b/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs index a446d92..0eae183 100644 --- a/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/CancelPreviousTaskSchedulerExtensions.cs @@ -196,35 +196,15 @@ public CancelPreviousMiddleware() _cancelAllTokensAllocator = new CancelAllTokensAllocator(); } - public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { _cancelAllTokensAllocator.Cancel(); - var (pending, linkedToken, allocation) = Forward(context, continuation); - return AwaitOperation(pending, linkedToken, allocation); - } - private (Task Pending, CancellationTokenSource LinkedToken, IDisposable Allocation) Forward(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) - { - var allocation = _cancelAllTokensAllocator.AllocateCancellationToken(out var allocatedToken); - CancellationTokenSource? linkedToken = null; - try - { - linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, allocatedToken); - return (continuation(context.WithCancellationToken(linkedToken.Token)), linkedToken, allocation); - } - catch + using (_cancelAllTokensAllocator.AllocateCancellationToken(out var allocatedToken)) { - linkedToken?.Dispose(); - allocation.Dispose(); - throw; + using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, allocatedToken); + return await continuation(context.WithCancellationToken(linkedToken.Token)).ConfigureAwait(false); } } - - private static async Task AwaitOperation(Task pending, CancellationTokenSource linkedToken, IDisposable allocation) - { - using (allocation) - using (linkedToken) - return await pending.ConfigureAwait(false); - } } } \ No newline at end of file diff --git a/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs b/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs index 428b737..bb3c267 100644 --- a/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs +++ b/TaskFlow/Extensions/CancellationScopeTaskSchedulerExtensions.cs @@ -150,28 +150,10 @@ public CancellationScopeMiddleware(CancellationToken scopedCancellationToken) _scopedCancellationToken = scopedCancellationToken; } - public Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) + public async Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) { - var (pending, linkedToken) = Forward(context, continuation); - return AwaitOperation(pending, linkedToken); - } - - private (Task Pending, CancellationTokenSource LinkedToken) Forward(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation) - { - var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, _scopedCancellationToken); - try - { return (continuation(context.WithCancellationToken(linkedToken.Token)), linkedToken); } - catch - { - linkedToken.Dispose(); - throw; - } - } - - private static async Task AwaitOperation(Task pending, CancellationTokenSource linkedToken) - { - using (linkedToken) - return await pending.ConfigureAwait(false); + using var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, _scopedCancellationToken); + return await continuation(context.WithCancellationToken(linkedToken.Token)).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/TaskFlow/ITaskScheduler.cs b/TaskFlow/ITaskScheduler.cs index a179008..08cd881 100644 --- a/TaskFlow/ITaskScheduler.cs +++ b/TaskFlow/ITaskScheduler.cs @@ -43,10 +43,6 @@ public interface ITaskScheduler /// exceptions thrown by the function. /// /// - /// Transparent forwarding preserves synchronous exceptions from the underlying enqueue call. Middleware that owns asynchronous - /// admission or scheduling, such as timeout or queuing, may report scheduling failures through its returned task. - /// - /// /// Implementations should ensure that: /// /// @@ -55,8 +51,6 @@ public interface ITaskScheduler /// Resources are properly managed when the scheduler is disposed /// /// - /// Thrown synchronously if is null. - /// Thrown synchronously if the scheduler or its owning lifetime has been disposed. - /// An exception from an accepted operation is instead reported through its returned task. + /// Thrown if the scheduler has been disposed. Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/TaskFlow/Internal/TaskSchedulerPipeline.cs b/TaskFlow/Internal/TaskSchedulerPipeline.cs index 03af9c1..eb6dc09 100644 --- a/TaskFlow/Internal/TaskSchedulerPipeline.cs +++ b/TaskFlow/Internal/TaskSchedulerPipeline.cs @@ -120,6 +120,7 @@ private Task SubmitOperation(PipelineOperation operat return EnqueueCore(operation); } // Plain enqueue invokes forwarding before handing ownership to the task observer. + Task pending; try { if (Lifetime != null) @@ -127,15 +128,15 @@ private Task SubmitOperation(PipelineOperation operat Lifetime.Enter(); operation.Lifetime = Lifetime; } - var pending = InvokeEnqueueCore(operation, _registrations.Length - 1, operation.CallerCancellationToken); + pending = InvokeEnqueueCore(operation, _registrations.Length - 1, operation.CallerCancellationToken); Annotations.Argument.NotNull(pending); - return EnqueueCore(operation, pending); } catch { operation.ReleaseActivity(); throw; } + return EnqueueCore(operation, pending); } private async Task EnqueueCore(PipelineOperation operation, Task? pending = null) @@ -178,15 +179,17 @@ private async Task EnqueueCore(PipelineOperation oper private Task InvokeEnqueue(PipelineOperation operation, int index, CancellationToken cancellationToken) { operation.AddActivity(); + Task pending; try { - return AwaitActivity(operation, InvokeEnqueueCore(operation, index, cancellationToken)); + pending = InvokeEnqueueCore(operation, index, cancellationToken); } catch { operation.ReleaseActivity(); throw; } + return AwaitActivity(operation, pending); static async Task AwaitActivity(PipelineOperation operation, Task pending) { diff --git a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs index f238cc3..2d38795 100644 --- a/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs +++ b/TaskFlow/Middleware/ITaskSchedulerEnqueueMiddleware.cs @@ -20,8 +20,8 @@ public interface ITaskSchedulerEnqueueMiddleware : ITaskSchedulerMiddleware /// Return a completed, failed, or canceled task without calling to satisfy or reject the request without /// submitting a terminal delegate. Do not call the continuation more than once. /// Failures reported through the returned task enter the completion pipeline even though terminal execution has not started. - /// Transparent forwarding preserves synchronous exceptions without converting them into completion outcomes. Middleware that owns - /// asynchronous admission or scheduling (for example, queuing or timeout) may report forwarding failures through its returned task. + /// Exceptions thrown by this method before it returns a task propagate from enqueue without being converted into completion outcomes. + /// Middleware and wrappers that return their own tasks retain their exception timing and resource ownership. /// Task InvokeAsync(TaskSchedulerEnqueueContext context, TaskSchedulerEnqueueDelegate continuation); } \ No newline at end of file diff --git a/TaskFlow/TaskFlowSchedulerAdapter.cs b/TaskFlow/TaskFlowSchedulerAdapter.cs index a0109fc..35fa0e2 100644 --- a/TaskFlow/TaskFlowSchedulerAdapter.cs +++ b/TaskFlow/TaskFlowSchedulerAdapter.cs @@ -1,6 +1,5 @@ namespace System.Threading.Tasks.Flow; -using System.Runtime.ExceptionServices; using System.Threading.Tasks.Flow.Annotations; /// @@ -82,7 +81,7 @@ public TaskFlowSchedulerAdapter(TaskScheduler taskScheduler) /// A representing the result of the enqueued task function. /// Thrown when is null. /// Thrown if the underlying task scheduler rejects the task. - /// Thrown synchronously if the underlying task scheduler rejects scheduling because it has been disposed. + /// Thrown if the underlying task scheduler has been disposed. /// /// /// This method uses Task.Factory.StartNew @@ -110,26 +109,11 @@ public TaskFlowSchedulerAdapter(TaskScheduler taskScheduler) /// scheduler starts the task, the task function may not be invoked. /// /// - public Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) + public async Task Enqueue(Func> taskFunc, object? state, CancellationToken cancellationToken) { Argument.NotNull(taskFunc); - try - { - var scheduled = Task.Factory.StartNew(() => taskFunc(state, cancellationToken), cancellationToken, TaskCreationOptions.None, _taskScheduler); - return AwaitOperation(scheduled); - } - catch (TaskSchedulerException exception) when (exception.InnerException is ObjectDisposedException disposed) - { - // Task.Factory.StartNew wraps exceptions from TaskScheduler.QueueTask. - ExceptionDispatchInfo.Capture(disposed).Throw(); - throw; - } - - static async Task AwaitOperation(Task> scheduled) - { - var operation = await scheduled.ConfigureAwait(false); - return await operation.ConfigureAwait(false); - } + var task = await Task.Factory.StartNew(() => taskFunc(state, cancellationToken), cancellationToken, TaskCreationOptions.None, _taskScheduler).ConfigureAwait(false); + return await task.ConfigureAwait(false); } } \ No newline at end of file diff --git a/docs/design/middleware-lifetime.md b/docs/design/middleware-lifetime.md index d1f4efe..975f449 100644 --- a/docs/design/middleware-lifetime.md +++ b/docs/design/middleware-lifetime.md @@ -73,11 +73,12 @@ continuation immediately. Such task failures still enter completion middleware. Explicit Submit continues to report admission and scheduling outcomes through its decision/completion tasks. -Cancellation scope and cancel-previous wrappers separate forwarding from task -observation so synchronous rejection disposes their linked tokens immediately, -while accepted operations retain those resources until their tasks settle. -Timeout retains its competing-task semantics and does not require a synchronous -startup path in the general-purpose task-racing helper. +Cancellation scope and cancel-previous retain their existing async wrappers and +linked-token ownership. An immediate forwarding failure is represented by their +returned task, and their using scopes release resources when that task settles. +Timeout likewise retains its competing-task semantics. The pipeline does not +impose a new exception-timing contract on these wrappers or reinterpret the +exceptions produced by Task.Factory.StartNew in TaskFlowSchedulerAdapter. ## Annotation-only forwarding