From a768c5ec9af352e4e47bbc3e695c3ae4587eff6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:06:05 +0000 Subject: [PATCH 1/2] test: add SideEffects module to ReplicateInfinite and ThreadState test files Adds tests verifying that TaskSeq.replicateInfinite/replicateInfiniteAsync/ replicateUntilNoneAsync re-run their generator functions from scratch on each independent enumeration (consistent with taskSeq's re-entrant semantics), and that TaskSeq.threadState/threadStateAsync invoke their folder exactly once per source item and re-drive both the folder and the underlying source on re-enumeration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- release-notes.txt | 1 + .../TaskSeq.ReplicateInfinite.Tests.fs | 68 +++++++++++++++++++ .../TaskSeq.ThreadState.Tests.fs | 68 +++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/release-notes.txt b/release-notes.txt index f0f7386b..5debb470 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - test: add SideEffects module to TaskSeq.ReplicateInfinite.Tests.fs and TaskSeq.ThreadState.Tests.fs, verifying computation/generator/folder call counts and re-enumeration semantics - 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 diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs index 34e40fe2..6312346f 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs @@ -156,3 +156,71 @@ module ReplicateUntilNoneAsync = arr[0] |> should equal 1 arr[count - 1] |> should equal count } + + +module SideEffects = + [] + let ``TaskSeq-replicateInfiniteAsync re-runs the computation on each fresh enumeration`` () = task { + let mutable calls = 0 + + let comp () = task { + calls <- calls + 1 + return calls + } + + let ts = TaskSeq.replicateInfiniteAsync comp + + let! arr1 = ts |> TaskSeq.take 3 |> TaskSeq.toArrayAsync + arr1 |> should equal [| 1; 2; 3 |] + calls |> should equal 3 + + // a fresh enumeration starts the computation from scratch; side effects + // (here, the call counter) keep accumulating across enumerations + let! arr2 = ts |> TaskSeq.take 2 |> TaskSeq.toArrayAsync + arr2 |> should equal [| 4; 5 |] + calls |> should equal 5 + } + + [] + let ``TaskSeq-replicateUntilNoneAsync re-runs the computation from its initial state on each fresh enumeration`` () = task { + let mutable totalCalls = 0 + + let comp () = task { + let mutable n = 0 + totalCalls <- totalCalls + 1 + + if n <= 1 then + n <- n + 1 + return Some n + else + return None + } + + let ts = TaskSeq.replicateUntilNoneAsync comp + + let! arr1 = ts |> TaskSeq.toArrayAsync + arr1 |> should equal [| 1 |] + totalCalls |> should equal 2 + + // re-enumerating re-invokes the generator function itself (state is local + // to each call), so side effects on shared state accumulate further + let! arr2 = ts |> TaskSeq.toArrayAsync + arr2 |> should equal [| 1 |] + totalCalls |> should equal 4 + } + + [] + let ``TaskSeq-replicateInfinite abandoning enumeration early does not affect a later fresh enumeration`` () = task { + let ts = TaskSeq.replicateInfinite 3 + + // partially enumerate and abandon (dispose) without reaching a natural end + use enum1 = ts.GetAsyncEnumerator System.Threading.CancellationToken.None + let! hasNext = enum1.MoveNextAsync() + hasNext |> should be True + enum1.Current |> should equal 3 + do! enum1.DisposeAsync() + + // a fresh enumerator over the same taskSeq starts cleanly from the beginning + let! arr = ts |> TaskSeq.take 3 |> TaskSeq.toArrayAsync + arr |> should equal [| 3; 3; 3 |] + } diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs index c62189fe..d37718dd 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ThreadState.Tests.fs @@ -147,3 +147,71 @@ module Functionality = viaThread |> should equal viaScan } + + +module SideEffects = + [] + let ``TaskSeq-threadState folder is invoked exactly once per source item`` () = task { + let mutable folderCalls = 0 + + let source = taskSeq { + for i in 1..5 do + yield i + } + + let folder state x = + folderCalls <- folderCalls + 1 + x + state, state + 1 + + let! result = TaskSeq.threadState folder 0 source |> TaskSeq.toArrayAsync + result |> should haveLength 5 + folderCalls |> should equal 5 + } + + [] + let ``TaskSeq-threadState re-enumerating the result re-runs source side effects and folder calls`` () = task { + let mutable itemsProduced = 0 + let mutable folderCalls = 0 + + let source = taskSeq { + for i in 1..3 do + itemsProduced <- itemsProduced + 1 + yield i + } + + let folder state x = + folderCalls <- folderCalls + 1 + x + state, state + 1 + + let ts = TaskSeq.threadState folder 0 source + + let! result1 = ts |> TaskSeq.toArrayAsync + result1 |> should equal [| 1; 3; 5 |] + itemsProduced |> should equal 3 + folderCalls |> should equal 3 + + // threadState produces a fresh taskSeq that re-drives the source and the + // folder from scratch on each independent enumeration + let! result2 = ts |> TaskSeq.toArrayAsync + result2 |> should equal result1 + itemsProduced |> should equal 6 + folderCalls |> should equal 6 + } + + [] + let ``TaskSeq-threadStateAsync folder is invoked exactly once per source item`` () = task { + let mutable folderCalls = 0 + let source = taskSeq { yield! [ 1..4 ] } + + let folder state x = task { + folderCalls <- folderCalls + 1 + return x + state, state + 1 + } + + let! result = + TaskSeq.threadStateAsync folder 0 source + |> TaskSeq.toArrayAsync + + result |> should haveLength 4 + folderCalls |> should equal 4 + } From f07af00c994c3425ebd1442ac34dcc5ec46ff361 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:31:57 +0000 Subject: [PATCH 2/2] fix: prevent test hang in replicateUntilNoneAsync SideEffects test The test declared its loop counter n inside the computation function passed to TaskSeq.replicateUntilNoneAsync, so it reset to 0 on every invocation. The condition n <= 1 was therefore always true and the computation never returned None, causing replicateUntilNoneAsync to loop forever. This hung the CI test host until the 60s Blame inactivity timeout killed it, crashing the test run (issue behind PR #470's failing CI). Moved n to the enclosing scope so it persists across calls and the terminating None branch is reached, matching the test's intent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- release-notes.txt | 1 + .../TaskSeq.ReplicateInfinite.Tests.fs | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/release-notes.txt b/release-notes.txt index 3d05af05..6f309b9b 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,7 @@ Release notes: Unreleased + - fix: `TaskSeq-replicateUntilNoneAsync re-runs the computation from its initial state on each fresh enumeration` test hung CI by declaring its loop counter `n` inside the computation function, so it reset to 0 on every call and the terminating `None` branch was never reached; `n` is now shared across calls as intended - test: add SideEffects module to TaskSeq.ReplicateInfinite.Tests.fs and TaskSeq.ThreadState.Tests.fs, verifying computation/generator/folder call counts and re-enumeration semantics - test: add direct unit test coverage for the `ValueTask` helper module (`fromResult`, `ofSource`, `ofTask`, `ignore`, `True`/`False`, plus their deprecated aliases), closing a gap where these functions had no dedicated tests despite the `Task`/`Async` equivalents being covered - tests: add coverage for side-effect re-execution semantics when re-enumerating a `taskSeq` with independent `CancellationToken`s diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs index 6312346f..1dbbe2bd 100644 --- a/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.ReplicateInfinite.Tests.fs @@ -184,29 +184,31 @@ module SideEffects = [] let ``TaskSeq-replicateUntilNoneAsync re-runs the computation from its initial state on each fresh enumeration`` () = task { let mutable totalCalls = 0 + let mutable n = 0 let comp () = task { - let mutable n = 0 totalCalls <- totalCalls + 1 if n <= 1 then n <- n + 1 return Some n else + n <- 0 return None } let ts = TaskSeq.replicateUntilNoneAsync comp let! arr1 = ts |> TaskSeq.toArrayAsync - arr1 |> should equal [| 1 |] - totalCalls |> should equal 2 + arr1 |> should equal [| 1; 2 |] + totalCalls |> should equal 3 - // re-enumerating re-invokes the generator function itself (state is local - // to each call), so side effects on shared state accumulate further + // re-enumerating re-invokes the generator function (its enclosing `n` + // state was reset to 0 by the prior enumeration), so side effects on + // shared state (the call counter) keep accumulating across enumerations let! arr2 = ts |> TaskSeq.toArrayAsync - arr2 |> should equal [| 1 |] - totalCalls |> should equal 4 + arr2 |> should equal [| 1; 2 |] + totalCalls |> should equal 6 } []