diff --git a/release-notes.txt b/release-notes.txt index 43967b3..602d3dd 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -5,6 +5,7 @@ Release notes: - update engineering to .NET 9/10 - adds TaskSeq.scan and TaskSeq.scanAsync, #289 - adds TaskSeq.pairwise, #289 + - fixes: CancellationToken passed to GetAsyncEnumerator is now honored in MoveNextAsync, #179 0.4.0 - overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234 diff --git a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj index 2a6d808..4b57dda 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -62,6 +62,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs new file mode 100644 index 0000000..ed33db2 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs @@ -0,0 +1,154 @@ +module TaskSeq.Tests.CancellationToken + +open System +open System.Threading +open System.Threading.Tasks + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +/// An infinite taskSeq that yields 1 forever +let private infiniteOnes () = taskSeq { + while true do + yield 1 +} + +/// A finite taskSeq with a few items +let private fiveItems () = taskSeq { + yield 1 + yield 2 + yield 3 + yield 4 + yield 5 +} + +module Cancellation = + + [] + let ``GetAsyncEnumerator with pre-cancelled token: first MoveNextAsync throws OperationCanceledException`` () = task { + use cts = new CancellationTokenSource() + cts.Cancel() + use enum = (infiniteOnes ()).GetAsyncEnumerator(cts.Token) + + fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + } + + [] + let ``GetAsyncEnumerator with pre-cancelled token: MoveNextAsync on finite seq also throws`` () = task { + use cts = new CancellationTokenSource() + cts.Cancel() + use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token) + + fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + } + + [] + let ``GetAsyncEnumerator with non-cancelled token: iteration proceeds normally`` () = task { + use cts = new CancellationTokenSource() + use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token) + let mutable count = 0 + let mutable canContinue = true + + while canContinue do + let! hasNext = enum.MoveNextAsync() + + if hasNext then count <- count + 1 else canContinue <- false + + count |> should equal 5 + } + + [] + let ``GetAsyncEnumerator with CancellationToken.None: iteration proceeds normally`` () = task { + use enum = (fiveItems ()).GetAsyncEnumerator(CancellationToken.None) + let mutable count = 0 + let mutable canContinue = true + + while canContinue do + let! hasNext = enum.MoveNextAsync() + + if hasNext then count <- count + 1 else canContinue <- false + + count |> should equal 5 + } + + [] + let ``Token cancelled after partial iteration: next MoveNextAsync throws OperationCanceledException`` () = task { + use cts = new CancellationTokenSource() + use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token) + + // Consume first two items normally + let! _ = enum.MoveNextAsync() + let! _ = enum.MoveNextAsync() + + // Cancel the token + cts.Cancel() + + // Next call should throw + fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + } + + [] + let ``Infinite sequence with pre-cancelled token: throws immediately without consuming any items`` () = task { + use cts = new CancellationTokenSource() + cts.Cancel() + let mutable itemsConsumed = 0 + + let seq = taskSeq { + while true do + itemsConsumed <- itemsConsumed + 1 + yield itemsConsumed + } + + use enum = seq.GetAsyncEnumerator(cts.Token) + + fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + + // The body should not have run (cancellation checked before advancing state machine) + itemsConsumed |> should equal 0 + } + + [] + let ``Token cancelled mid-iteration of infinite sequence terminates with OperationCanceledException`` () = task { + use cts = new CancellationTokenSource() + use enum = (infiniteOnes ()).GetAsyncEnumerator(cts.Token) + + // Iterate a few steps without cancellation + for _ in 1..5 do + let! hasNext = enum.MoveNextAsync() + hasNext |> should be True + + // Now cancel + cts.Cancel() + + // Next call should throw + fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + } + + [] + let ``Multiple enumerators of same sequence respect independent cancellation tokens`` () = task { + let source = fiveItems () + use cts1 = new CancellationTokenSource() + use cts2 = new CancellationTokenSource() + + // Cancel only the first token + cts1.Cancel() + + use enum1 = source.GetAsyncEnumerator(cts1.Token) + use enum2 = source.GetAsyncEnumerator(cts2.Token) + + // enum1 should throw (cancelled) + fun () -> enum1.MoveNextAsync().AsTask() |> Task.ignore + |> should throwAsync typeof + + // enum2 should work normally (not cancelled) + let! hasNext = enum2.MoveNextAsync() + hasNext |> should be True + enum2.Current |> should equal 1 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeqBuilder.fs b/src/FSharp.Control.TaskSeq/TaskSeqBuilder.fs index 6ac6d0e..2268544 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqBuilder.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqBuilder.fs @@ -242,6 +242,11 @@ and [] TaskSeq<'Machine, 'T Debug.logInfo "at MoveNextAsync: normal resumption scenario" let data = this._machine.Data + + // Honor the cancellation token passed to GetAsyncEnumerator (fixes #179). + // ThrowIfCancellationRequested() is a no-op for CancellationToken.None. + data.cancellationToken.ThrowIfCancellationRequested() + data.promiseOfValueOrEnd.Reset() let mutable ts = this