diff --git a/release-notes.txt b/release-notes.txt index 0762fd2..f0f7386 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - tests: add coverage for side-effect re-execution semantics when re-enumerating a `taskSeq` with independent `CancellationToken`s - test: add TaskSeq.Issue452.Tests.fs, regression tests wrapping an externally-produced IAsyncEnumerable<'T> (TaskSeq.map and `taskSeq { for .. in .. do yield .. }`) while running on a custom, single-threaded TaskScheduler; investigates #452's reported duplicated-final-item bug, which could not be reproduced outside of Orleans, see #452 - adds TaskSeq.tryMax and TaskSeq.tryMin: safe variants of TaskSeq.max and TaskSeq.min that return None instead of raising ArgumentException when the input sequence is empty - test: rename `SideEffect` module to `SideEffects` in TaskSeq.Concat.Tests.fs, TaskSeq.Delay.Tests.fs, and TaskSeq.Item.Tests.fs for consistency with the rest of the test suite (50+ files already use the plural form) diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs index ed33db2..59b6bd3 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.CancellationToken.Tests.fs @@ -1,6 +1,7 @@ module TaskSeq.Tests.CancellationToken open System +open System.Collections.Generic open System.Threading open System.Threading.Tasks @@ -152,3 +153,82 @@ module Cancellation = hasNext |> should be True enum2.Current |> should equal 1 } + +module SideEffects = + + [] + let ``Cancelling one enumerator does not affect side effects of a fresh enumerator over the same taskSeq`` () = task { + let mutable itemsProduced = 0 + + let source = taskSeq { + for i in 1..5 do + itemsProduced <- itemsProduced + 1 + yield i + } + + // fully consume with a first, never-cancelled enumerator + use cts1 = new CancellationTokenSource() + use enum1 = source.GetAsyncEnumerator(cts1.Token) + let mutable canContinue = true + + while canContinue do + let! hasNext = enum1.MoveNextAsync() + + if not hasNext then + canContinue <- false + + itemsProduced |> should equal 5 + + // cancel and dispose that first enumerator explicitly, then re-enumerate the same + // taskSeq from scratch with a fresh, non-cancelled token + cts1.Cancel() + do! enum1.DisposeAsync() + + use cts2 = new CancellationTokenSource() + use enum2 = source.GetAsyncEnumerator(cts2.Token) + let! hasNext = enum2.MoveNextAsync() + + // re-enumeration re-runs the body from scratch: side effects accumulate further, + // and the previous enumerator's cancellation has no bearing on this fresh one + hasNext |> should be True + enum2.Current |> should equal 1 + itemsProduced |> should equal 6 + } + + [] + let ``A CancellationToken passed to GetAsyncEnumerator does not prevent re-iteration with a different token`` () = task { + let mutable totalCalls = 0 + + let source = taskSeq { + for i in 1..3 do + totalCalls <- totalCalls + 1 + yield i + } + + let drain (enum: IAsyncEnumerator) = task { + let items = ResizeArray() + let mutable canContinue = true + + while canContinue do + let! hasNext = enum.MoveNextAsync() + + if hasNext then + items.Add enum.Current + else + canContinue <- false + + return List.ofSeq items + } + + use cts = new CancellationTokenSource() + use enum1 = source.GetAsyncEnumerator(cts.Token) + let! first = drain enum1 + first |> should equal [ 1; 2; 3 ] + totalCalls |> should equal 3 + + // re-iterate using CancellationToken.None: side effects re-run independently + use enum2 = source.GetAsyncEnumerator(CancellationToken.None) + let! second = drain enum2 + second |> should equal [ 1; 2; 3 ] + totalCalls |> should equal 6 + }