From a3fc88729ccd50bb72b109041e6083d2b9348c16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:24:03 +0000 Subject: [PATCH 1/2] test: add SideEffects module to TaskSeq.Unfold.Tests.fs Adds 6 new tests covering: - Generator side-effects accumulate across re-iterations (both sync and async) - take stops the generator after the requested number of elements - Exceptions thrown inside sync and async generators propagate correctly Before: 14 test runs After: 20 test runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- release-notes.txt | 1 + .../TaskSeq.Unfold.Tests.fs | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/release-notes.txt b/release-notes.txt index 72c765f9..e18f1ac5 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - test: add SideEffects module to TaskSeq.Unfold.Tests.fs, verifying generator call counts, re-iteration behaviour, early-termination via take, and exception propagation - test: add SideEffects module and ImmTaskSeq variant tests to TaskSeq.ChunkBy.Tests.fs, improving coverage for chunkBy and chunkByAsync - fixes: `Async.bind` signature corrected from `(Async<'T> -> Async<'U>)` to `('T -> Async<'U>)` to match standard monadic bind semantics (same as `Task.bind`); the previous signature made the function effectively equivalent to direct application - refactor: simplify splitAt 'rest' taskSeq to use while!, removing redundant go2 mutable and manual MoveNextAsync pre-advance diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs index 6cbec400..de8ffb90 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Unfold.Tests.fs @@ -158,3 +158,114 @@ module Functionality = first |> should equal second first |> should equal [| 0..4 |] } + +module SideEffects = + [] + let ``TaskSeq-unfold generator side-effects accumulate across re-iterations`` () = task { + // The generator closes over mutable external state. Each re-iteration starts fresh from + // the initial seed (0), but the external counter keeps climbing — demonstrating that + // the IAsyncEnumerable itself is stateless but the captured state is shared. + let mutable totalCalls = 0 + + let ts = + TaskSeq.unfold + (fun n -> + totalCalls <- totalCalls + 1 + if n < 3 then Some(n, n + 1) else None) + 0 + + let! first = ts |> TaskSeq.toArrayAsync + first |> should equal [| 0; 1; 2 |] + totalCalls |> should equal 4 // 3 Some + 1 None + + let! second = ts |> TaskSeq.toArrayAsync + second |> should equal [| 0; 1; 2 |] + totalCalls |> should equal 8 // called 4 more times for the second iteration + } + + [] + let ``TaskSeq-unfoldAsync generator side-effects accumulate across re-iterations`` () = task { + let mutable totalCalls = 0 + + let ts = + TaskSeq.unfoldAsync + (fun n -> task { + totalCalls <- totalCalls + 1 + return if n < 3 then Some(n, n + 1) else None + }) + 0 + + let! first = ts |> TaskSeq.toArrayAsync + first |> should equal [| 0; 1; 2 |] + totalCalls |> should equal 4 + + let! second = ts |> TaskSeq.toArrayAsync + second |> should equal [| 0; 1; 2 |] + totalCalls |> should equal 8 + } + + [] + let ``TaskSeq-unfold with take stops generator calls at the limit`` () = task { + let mutable callCount = 0 + + // Infinite generator: always returns Some + let ts = + TaskSeq.unfold + (fun n -> + callCount <- callCount + 1 + Some(n, n + 1)) + 0 + + let! result = ts |> TaskSeq.take 5 |> TaskSeq.toArrayAsync + result |> should equal [| 0; 1; 2; 3; 4 |] + + // take 5 pulls exactly 5 elements; with an always-Some generator no + // extra sentinel call is needed, so callCount should be exactly 5. + callCount |> should equal 5 + } + + [] + let ``TaskSeq-unfoldAsync with take stops generator calls at the limit`` () = task { + let mutable callCount = 0 + + let ts = + TaskSeq.unfoldAsync + (fun n -> task { + callCount <- callCount + 1 + return Some(n, n + 1) + }) + 0 + + let! result = ts |> TaskSeq.take 5 |> TaskSeq.toArrayAsync + result |> should equal [| 0; 1; 2; 3; 4 |] + callCount |> should equal 5 + } + + [] + let ``TaskSeq-unfold propagates exception thrown inside the generator`` () = + let ts = + TaskSeq.unfold + (fun n -> + if n = 3 then + failwith "generator-boom" + + Some(n, n + 1)) + 0 + + fun () -> ts |> consumeTaskSeq + |> should throwAsyncExact typeof + + [] + let ``TaskSeq-unfoldAsync propagates exception thrown inside the async generator`` () = + let ts = + TaskSeq.unfoldAsync + (fun n -> task { + if n = 3 then + failwith "async-generator-boom" + + return Some(n, n + 1) + }) + 0 + + fun () -> ts |> consumeTaskSeq + |> should throwAsyncExact typeof From 299d61d794f7bf17e7a736d3cfa15114cb4a53e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 01:24:05 +0000 Subject: [PATCH 2/2] ci: trigger checks