Skip to content

Commit fab16e5

Browse files
authored
Merge branch 'main' into repo-assist/ci-test-perf-2026-03-647061314c105ab1
2 parents 8343508 + d731039 commit fab16e5

12 files changed

+394
-61
lines changed

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,7 @@ All workflows are in `.github/workflows/`:
9999
- File ordering matters in F# — the `<Compile>` order in `.fsproj` files defines compilation order.
100100
- The library targets `netstandard2.1`; tests target `net6.0` with `FSharp.Core` pinned to `6.0.1`.
101101
- NuGet packages are output to the `packages/` directory.
102+
103+
## Release Notes
104+
105+
When making changes, update the release notes and bump the version appropriately

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<Compile Include="TaskSeq.Empty.Tests.fs" />
2020
<Compile Include="TaskSeq.ExactlyOne.Tests.fs" />
2121
<Compile Include="TaskSeq.Except.Tests.fs" />
22+
<Compile Include="TaskSeq.DistinctUntilChanged.Tests.fs" />
2223
<Compile Include="TaskSeq.Exists.Tests.fs" />
2324
<Compile Include="TaskSeq.Filter.Tests.fs" />
2425
<Compile Include="TaskSeq.FindIndex.Tests.fs" />

src/FSharp.Control.TaskSeq.Test/TaskSeq.AsyncExtensions.Tests.fs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,84 @@ module Other =
141141
disposed.Value |> should equal 1
142142
sum |> should equal 42
143143
}
144+
145+
// Tests for nested for loops in the async CE with IAsyncEnumerable as the outer sequence.
146+
// Related to: https://github.com/fsprojects/FSharp.Control.TaskSeq/issues/269
147+
module NestedLoops =
148+
[<Fact>]
149+
let ``Async-for CE with nested regular list inside taskSeq loop`` () = async {
150+
// outer: IAsyncEnumerable<int list>, inner: regular list
151+
let outer = taskSeq {
152+
yield [ 1; 2; 3 ]
153+
yield [ 4; 5 ]
154+
yield [ 6; 7; 8; 9; 10 ]
155+
}
156+
157+
let mutable sum = 0
158+
159+
for inner in outer do
160+
for x in inner do
161+
sum <- sum + x
162+
163+
sum |> should equal 55
164+
}
165+
166+
[<Fact>]
167+
let ``Async-for CE with nested array inside taskSeq loop`` () = async {
168+
// outer: IAsyncEnumerable<int[]>, inner: regular array
169+
let outer = taskSeq {
170+
yield [| 1; 2; 3 |]
171+
yield [| 4; 5 |]
172+
}
173+
174+
let mutable sum = 0
175+
176+
for inner in outer do
177+
for x in inner do
178+
sum <- sum + x
179+
180+
sum |> should equal 15
181+
}
182+
183+
[<Fact>]
184+
let ``Async-for CE with nested tuple-destructuring array inside taskSeq loop`` () = async {
185+
// outer: IAsyncEnumerable<int[]>, inner: zipped array with tuple destructuring
186+
// this pattern reproduces the scenario from issue #269
187+
let outer = taskSeq { yield [| 1; 2; 3 |] }
188+
let mutable sum = 0
189+
190+
for arr in outer do
191+
for (a, b) in Array.zip arr arr do
192+
sum <- sum + a + b
193+
194+
// (1+1) + (2+2) + (3+3) = 12
195+
sum |> should equal 12
196+
}
197+
198+
[<Fact>]
199+
let ``Async-for CE with nested taskSeq inside taskSeq loop`` () = async {
200+
// outer: IAsyncEnumerable<IAsyncEnumerable<int>>, inner: taskSeq
201+
let inner1 = taskSeq {
202+
yield 1
203+
yield 2
204+
yield 3
205+
}
206+
207+
let inner2 = taskSeq {
208+
yield 4
209+
yield 5
210+
}
211+
212+
let outer = taskSeq {
213+
yield inner1
214+
yield inner2
215+
}
216+
217+
let mutable sum = 0
218+
219+
for inner in outer do
220+
for x in inner do
221+
sum <- sum + x
222+
223+
sum |> should equal 15
224+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module TaskSeq.Tests.DistinctUntilChanged
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.distinctUntilChanged
10+
//
11+
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``TaskSeq-distinctUntilChanged with null source raises`` () = assertNullArg <| fun () -> TaskSeq.distinctUntilChanged null
16+
17+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
18+
let ``TaskSeq-distinctUntilChanged has no effect`` variant = task {
19+
do!
20+
Gen.getEmptyVariant variant
21+
|> TaskSeq.distinctUntilChanged
22+
|> TaskSeq.toListAsync
23+
|> Task.map (List.isEmpty >> should be True)
24+
}
25+
26+
module Functionality =
27+
[<Fact>]
28+
let ``TaskSeq-distinctUntilChanged should return no consecutive duplicates`` () = task {
29+
let ts =
30+
[ 'A'; 'A'; 'B'; 'Z'; 'C'; 'C'; 'Z'; 'C'; 'D'; 'D'; 'D'; 'Z' ]
31+
|> TaskSeq.ofList
32+
33+
let! xs = ts |> TaskSeq.distinctUntilChanged |> TaskSeq.toListAsync
34+
35+
xs
36+
|> List.map string
37+
|> String.concat ""
38+
|> should equal "ABZCZCDZ"
39+
}

src/FSharp.Control.TaskSeq.Test/TaskSeq.Indexed.Tests.fs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,68 @@ module Immutable =
3434
|> TaskSeq.toArrayAsync
3535
|> Task.map (Array.forall (fun (x, y) -> x + 1 = y))
3636
|> Task.map (should be True)
37+
38+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
39+
let ``TaskSeq-indexed returns all 10 pairs with correct zero-based indices`` variant = task {
40+
let! pairs =
41+
Gen.getSeqImmutable variant
42+
|> TaskSeq.indexed
43+
|> TaskSeq.toArrayAsync
44+
45+
pairs |> should be (haveLength 10)
46+
47+
pairs
48+
|> Array.iteri (fun pos (idx, _) -> idx |> should equal pos)
49+
}
50+
51+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
52+
let ``TaskSeq-indexed returns values 1 to 10 unchanged`` variant = task {
53+
let! pairs =
54+
Gen.getSeqImmutable variant
55+
|> TaskSeq.indexed
56+
|> TaskSeq.toArrayAsync
57+
58+
pairs |> Array.map snd |> should equal [| 1..10 |]
59+
}
60+
61+
module SideEffects =
62+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
63+
let ``TaskSeq-indexed on side-effect sequence returns correct pairs`` variant = task {
64+
let ts = Gen.getSeqWithSideEffect variant
65+
let! pairs = ts |> TaskSeq.indexed |> TaskSeq.toArrayAsync
66+
pairs |> should be (haveLength 10)
67+
68+
pairs
69+
|> Array.iteri (fun pos (idx, _) -> idx |> should equal pos)
70+
}
71+
72+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
73+
let ``TaskSeq-indexed on side-effect sequence is re-evaluated on second iteration`` variant = task {
74+
let ts = Gen.getSeqWithSideEffect variant
75+
76+
let! firstPairs = ts |> TaskSeq.indexed |> TaskSeq.toArrayAsync
77+
let! secondPairs = ts |> TaskSeq.indexed |> TaskSeq.toArrayAsync
78+
79+
// indices always start at 0
80+
firstPairs |> Array.map fst |> should equal [| 0..9 |]
81+
secondPairs |> Array.map fst |> should equal [| 0..9 |]
82+
83+
// values advance due to side effects
84+
firstPairs |> Array.map snd |> should equal [| 1..10 |]
85+
secondPairs |> Array.map snd |> should equal [| 11..20 |]
86+
}
87+
88+
[<Fact>]
89+
let ``TaskSeq-indexed prove index starts at zero even after side effects`` () = task {
90+
let mutable counter = 0
91+
92+
let ts = taskSeq {
93+
for _ in 1..5 do
94+
counter <- counter + 1
95+
yield counter
96+
}
97+
98+
let! pairs = ts |> TaskSeq.indexed |> TaskSeq.toArrayAsync
99+
pairs |> Array.map fst |> should equal [| 0..4 |]
100+
pairs |> Array.map snd |> should equal [| 1..5 |]
101+
}

src/FSharp.Control.TaskSeq.Test/TaskSeq.TaskExtensions.Tests.fs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,102 @@ module Other =
140140
disposed.Value |> should equal 1
141141
sum |> should equal 42
142142
}
143+
144+
// Tests for nested for loops in the task CE with IAsyncEnumerable as the outer sequence.
145+
// Related to: https://github.com/fsprojects/FSharp.Control.TaskSeq/issues/269
146+
module NestedLoops =
147+
[<Fact>]
148+
let ``Task-for CE with nested regular list inside taskSeq loop`` () = task {
149+
// outer: IAsyncEnumerable<int list>, inner: regular list
150+
let outer = taskSeq {
151+
yield [ 1; 2; 3 ]
152+
yield [ 4; 5 ]
153+
yield [ 6; 7; 8; 9; 10 ]
154+
}
155+
156+
let mutable sum = 0
157+
158+
for inner in outer do
159+
for x in inner do
160+
sum <- sum + x
161+
162+
sum |> should equal 55
163+
}
164+
165+
[<Fact>]
166+
let ``Task-for CE with nested array inside taskSeq loop`` () = task {
167+
// outer: IAsyncEnumerable<int[]>, inner: regular array
168+
let outer = taskSeq {
169+
yield [| 1; 2; 3 |]
170+
yield [| 4; 5 |]
171+
}
172+
173+
let mutable sum = 0
174+
175+
for inner in outer do
176+
for x in inner do
177+
sum <- sum + x
178+
179+
sum |> should equal 15
180+
}
181+
182+
[<Fact>]
183+
let ``Task-for CE with nested tuple-destructuring array inside taskSeq loop`` () = task {
184+
// outer: IAsyncEnumerable<int[]>, inner: zipped array with tuple destructuring
185+
// this pattern reproduces the scenario from issue #269
186+
let outer = taskSeq { yield [| 1; 2; 3 |] }
187+
let mutable sum = 0
188+
189+
for arr in outer do
190+
for (a, b) in Array.zip arr arr do
191+
sum <- sum + a + b
192+
193+
// (1+1) + (2+2) + (3+3) = 12
194+
sum |> should equal 12
195+
}
196+
197+
[<Fact>]
198+
let ``Task-for CE with nested taskSeq inside taskSeq loop`` () = task {
199+
// outer: IAsyncEnumerable<IAsyncEnumerable<int>>, inner: taskSeq
200+
let inner1 = taskSeq {
201+
yield 1
202+
yield 2
203+
yield 3
204+
}
205+
206+
let inner2 = taskSeq {
207+
yield 4
208+
yield 5
209+
}
210+
211+
let outer = taskSeq {
212+
yield inner1
213+
yield inner2
214+
}
215+
216+
let mutable sum = 0
217+
218+
for inner in outer do
219+
for x in inner do
220+
sum <- sum + x
221+
222+
sum |> should equal 15
223+
}
224+
225+
[<Fact>]
226+
let ``Task-for CE with three levels of nesting`` () = task {
227+
// outer: IAsyncEnumerable<int[][]>, mid: array, inner: array
228+
let outer = taskSeq {
229+
yield [| [| 1; 2 |]; [| 3; 4 |] |]
230+
yield [| [| 5 |] |]
231+
}
232+
233+
let mutable sum = 0
234+
235+
for mid in outer do
236+
for inner in mid do
237+
for x in inner do
238+
sum <- sum + x
239+
240+
sum |> should equal 15
241+
}

src/FSharp.Control.TaskSeq.Test/TaskSeq.Zip.Tests.fs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,61 @@ module Performance =
118118
combined |> Array.last |> should equal (length, length)
119119
}
120120

121+
module UnequalLength =
122+
[<Fact>]
123+
let ``TaskSeq-zip stops at shorter first sequence`` () = task {
124+
// documented: "when one sequence is exhausted any remaining elements in the other sequence are ignored"
125+
let short = taskSeq { yield! [ 1..5 ] }
126+
let long = taskSeq { yield! [ 1..10 ] }
127+
let! combined = TaskSeq.zip short long |> TaskSeq.toArrayAsync
128+
combined |> should be (haveLength 5)
129+
130+
combined
131+
|> should equal (Array.init 5 (fun i -> i + 1, i + 1))
132+
}
133+
134+
[<Fact>]
135+
let ``TaskSeq-zip stops at shorter second sequence`` () = task {
136+
// documented: "when one sequence is exhausted any remaining elements in the other sequence are ignored"
137+
let long = taskSeq { yield! [ 1..10 ] }
138+
let short = taskSeq { yield! [ 1..3 ] }
139+
let! combined = TaskSeq.zip long short |> TaskSeq.toArrayAsync
140+
combined |> should be (haveLength 3)
141+
142+
combined
143+
|> should equal (Array.init 3 (fun i -> i + 1, i + 1))
144+
}
145+
146+
[<Fact>]
147+
let ``TaskSeq-zip with first sequence empty returns empty`` () =
148+
// documented: remaining elements in the longer sequence are ignored
149+
let empty = taskSeq { yield! ([]: int list) }
150+
let nonEmpty = taskSeq { yield! [ 1..10 ] }
151+
TaskSeq.zip empty nonEmpty |> verifyEmpty
152+
153+
[<Fact>]
154+
let ``TaskSeq-zip with second sequence empty returns empty`` () =
155+
// documented: remaining elements in the longer sequence are ignored
156+
let nonEmpty = taskSeq { yield! [ 1..10 ] }
157+
let empty = taskSeq { yield! ([]: int list) }
158+
TaskSeq.zip nonEmpty empty |> verifyEmpty
159+
160+
[<Fact>]
161+
let ``TaskSeq-zip with singleton first and longer second returns singleton`` () = task {
162+
let one = taskSeq { yield 42 }
163+
let many = taskSeq { yield! [ 1..10 ] }
164+
let! combined = TaskSeq.zip one many |> TaskSeq.toArrayAsync
165+
combined |> should equal [| (42, 1) |]
166+
}
167+
168+
[<Fact>]
169+
let ``TaskSeq-zip with longer first and singleton second returns singleton`` () = task {
170+
let many = taskSeq { yield! [ 1..10 ] }
171+
let one = taskSeq { yield 99 }
172+
let! combined = TaskSeq.zip many one |> TaskSeq.toArrayAsync
173+
combined |> should equal [| (1, 99) |]
174+
}
175+
121176
module Other =
122177
[<Fact>]
123178
let ``TaskSeq-zip zips different types`` () = task {

src/FSharp.Control.TaskSeq/TaskExtensions.fs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ open Microsoft.FSharp.Core.LanguagePrimitives.IntrinsicOperators
1414
[<AutoOpen>]
1515
module TaskExtensions =
1616

17-
// Add asynchronous for loop to the 'task' computation builder
18-
type Microsoft.FSharp.Control.TaskBuilder with
17+
// Add asynchronous for loop to the 'task' and 'backgroundTask' computation builders
18+
type TaskBuilderBase with
1919

2020
/// Used by `For`. F# currently doesn't support `while!`, so this cannot be called directly from the task CE
2121
/// This code is mostly a copy of TaskSeq.WhileAsync.

src/FSharp.Control.TaskSeq/TaskExtensions.fsi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace FSharp.Control
55
[<AutoOpen>]
66
module TaskExtensions =
77

8-
type TaskBuilder with
8+
type TaskBuilderBase with
99

1010
/// <summary>
1111
/// Inside <see cref="task" />, iterate over all values of a <see cref="taskSeq" />.

0 commit comments

Comments
 (0)