Skip to content

Commit 13a030b

Browse files
authored
Merge branch 'main' into repo-assist/improve-forall2-exists2-20260429-5fd2d38a847baee0
2 parents cb36265 + 07e5c25 commit 13a030b

7 files changed

Lines changed: 506 additions & 152 deletions

File tree

.github/aw/actions-lock.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
"version": "v6.0.2",
66
"sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd"
77
},
8+
"actions/github-script@v9": {
9+
"repo": "actions/github-script",
10+
"version": "v9",
11+
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
12+
},
813
"actions/github-script@v9.0.0": {
914
"repo": "actions/github-script",
1015
"version": "v9.0.0",
1116
"sha": "d746ffe35508b1917358783b479e04febd2b8f71"
1217
},
13-
"github/gh-aw-actions/setup@v0.68.3": {
18+
"github/gh-aw-actions/setup@v0.71.3": {
1419
"repo": "github/gh-aw-actions/setup",
15-
"version": "v0.68.3",
16-
"sha": "ba90f2186d7ad780ec640f364005fa24e797b360"
20+
"version": "v0.71.3",
21+
"sha": "07c7335cd76c4d4d9f00dd7874f85ff55ed71f24"
1722
},
1823
"github/gh-aw/actions/setup@v0.68.7": {
1924
"repo": "github/gh-aw/actions/setup",

.github/workflows/repo-assist.lock.yml

Lines changed: 280 additions & 104 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/repo-assist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ description: |
1414
Always polite, constructive, and mindful of the project's goals.
1515
1616
on:
17-
schedule: every 2d
17+
schedule: weekly
1818
workflow_dispatch:
1919
slash_command:
2020
name: repo-assist

RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
* Added `AsyncSeq.exists2Async` — asynchronous-predicate variant of `exists2`.
55
* Added `AsyncSeq.forall2` — asynchronously tests whether all corresponding pairs of elements in two async sequences satisfy the predicate. Evaluates pairwise up to the shorter sequence; short-circuits on first failure. Mirrors `Seq.forall2`.
66
* Added `AsyncSeq.forall2Async` — asynchronous-predicate variant of `forall2`.
7+
* Performance: Optimised `AsyncSeq.pairwise` to use a `hasPrev` flag and a direct `mutable` field instead of wrapping the previous element in `Some`. Previously, each iteration allocated a new `'T option` object on the heap; the new implementation eliminates that allocation entirely, reducing GC pressure for long sequences.
8+
* Bug fix: `AsyncSeq.splitAt` and `AsyncSeq.tryTail` now correctly dispose the underlying enumerator when an exception or cancellation occurs during the initial `MoveNext` call. Previously the enumerator could leak if the source sequence threw during the first few steps.
79

810
### 4.16.0
911

1012
* Performance: Replaced `ref` cells with `mutable` locals in the `ofSeq`, `tryWith`, and `tryFinally` enumerator state machines. Each call to `ofSeq` (or any async CE block using `try...with` / `try...finally` / `use`) previously heap-allocated a `Ref<T>` wrapper object per enumerator; it now uses a direct mutable field in the generated class, reducing GC pressure. The change is equivalent to the `mutable`-for-`ref` improvement introduced in 4.11.0 for other enumerators.
13+
* Added `AsyncSeq.unzip` — splits an async sequence of pairs into two arrays. Mirrors `List.unzip`.
14+
* Added `AsyncSeq.unzip3` — splits an async sequence of triples into three arrays. Mirrors `List.unzip3`.
15+
* Added `AsyncSeq.map2` — applies a function to corresponding elements of two async sequences; stops when either is exhausted. Mirrors `Seq.map2`.
16+
* Added `AsyncSeq.map3` — applies a function to corresponding elements of three async sequences; stops when any is exhausted. Mirrors `List.map3`.
1117

1218
### 4.15.0
1319

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,13 +1306,14 @@ module AsyncSeq =
13061306
use ie = source.GetEnumerator()
13071307
let! v = ie.MoveNext()
13081308
let mutable b = v
1309-
let mutable prev = None
1309+
// Use a flag + mutable field instead of Option to avoid per-element heap allocation
1310+
let mutable hasPrev = false
1311+
let mutable prev = Unchecked.defaultof<'T>
13101312
while b.IsSome do
13111313
let v = b.Value
1312-
match prev with
1313-
| None -> ()
1314-
| Some p -> yield (p, v)
1315-
prev <- Some v
1314+
if hasPrev then yield (prev, v)
1315+
hasPrev <- true
1316+
prev <- v
13161317
let! moven = ie.MoveNext()
13171318
b <- moven }
13181319

@@ -2044,6 +2045,42 @@ module AsyncSeq =
20442045
let! next1 = ie1.MoveNext()
20452046
b1 <- next1 }
20462047

2048+
let unzip (source: AsyncSeq<'T1 * 'T2>) : Async<'T1[] * 'T2[]> = async {
2049+
let as1 = System.Collections.Generic.List<'T1>()
2050+
let as2 = System.Collections.Generic.List<'T2>()
2051+
use ie = source.GetEnumerator()
2052+
let! move = ie.MoveNext()
2053+
let mutable cur = move
2054+
while cur.IsSome do
2055+
let (a, b) = cur.Value
2056+
as1.Add(a)
2057+
as2.Add(b)
2058+
let! next = ie.MoveNext()
2059+
cur <- next
2060+
return (as1.ToArray(), as2.ToArray()) }
2061+
2062+
let unzip3 (source: AsyncSeq<'T1 * 'T2 * 'T3>) : Async<'T1[] * 'T2[] * 'T3[]> = async {
2063+
let as1 = System.Collections.Generic.List<'T1>()
2064+
let as2 = System.Collections.Generic.List<'T2>()
2065+
let as3 = System.Collections.Generic.List<'T3>()
2066+
use ie = source.GetEnumerator()
2067+
let! move = ie.MoveNext()
2068+
let mutable cur = move
2069+
while cur.IsSome do
2070+
let (a, b, c) = cur.Value
2071+
as1.Add(a)
2072+
as2.Add(b)
2073+
as3.Add(c)
2074+
let! next = ie.MoveNext()
2075+
cur <- next
2076+
return (as1.ToArray(), as2.ToArray(), as3.ToArray()) }
2077+
2078+
let map2 (mapping: 'T1 -> 'T2 -> 'U) (source1: AsyncSeq<'T1>) (source2: AsyncSeq<'T2>) : AsyncSeq<'U> =
2079+
zipWith mapping source1 source2
2080+
2081+
let map3 (mapping: 'T1 -> 'T2 -> 'T3 -> 'U) (source1: AsyncSeq<'T1>) (source2: AsyncSeq<'T2>) (source3: AsyncSeq<'T3>) : AsyncSeq<'U> =
2082+
zipWith3 mapping source1 source2 source3
2083+
20472084
let zappAsync (fs:AsyncSeq<'T -> Async<'U>>) (s:AsyncSeq<'T>) : AsyncSeq<'U> =
20482085
zipWithAsync (|>) s fs
20492086

@@ -2202,51 +2239,59 @@ module AsyncSeq =
22022239

22032240
let tryTail (source: AsyncSeq<'T>) : Async<AsyncSeq<'T> option> = async {
22042241
let ie = source.GetEnumerator()
2205-
let! first = ie.MoveNext()
2206-
match first with
2207-
| None ->
2242+
try
2243+
let! first = ie.MoveNext()
2244+
match first with
2245+
| None ->
2246+
ie.Dispose()
2247+
return None
2248+
| Some _ ->
2249+
return Some (asyncSeq {
2250+
try
2251+
let! next = ie.MoveNext()
2252+
let mutable b = next
2253+
while b.IsSome do
2254+
yield b.Value
2255+
let! moven = ie.MoveNext()
2256+
b <- moven
2257+
finally
2258+
ie.Dispose() })
2259+
with ex ->
22082260
ie.Dispose()
2209-
return None
2210-
| Some _ ->
2211-
return Some (asyncSeq {
2212-
try
2213-
let! next = ie.MoveNext()
2214-
let mutable b = next
2215-
while b.IsSome do
2216-
yield b.Value
2217-
let! moven = ie.MoveNext()
2218-
b <- moven
2219-
finally
2220-
ie.Dispose() }) }
2261+
return raise ex }
22212262

22222263
/// Splits an async sequence at the given index, returning the first `count` elements as an array
22232264
/// and the remaining elements as a new AsyncSeq. The source is enumerated once.
22242265
let splitAt (count: int) (source: AsyncSeq<'T>) : Async<'T array * AsyncSeq<'T>> = async {
22252266
if count < 0 then invalidArg "count" "must be non-negative"
22262267
let ie = source.GetEnumerator()
2227-
let ra = ResizeArray<'T>()
2228-
let! m = ie.MoveNext()
2229-
let mutable b = m
2230-
while b.IsSome && ra.Count < count do
2231-
ra.Add b.Value
2232-
let! next = ie.MoveNext()
2233-
b <- next
2234-
let first = ra.ToArray()
2235-
let rest =
2236-
if b.IsNone then
2237-
ie.Dispose()
2238-
empty<'T>
2239-
else
2240-
let cur = ref b
2241-
asyncSeq {
2242-
try
2243-
while cur.Value.IsSome do
2244-
yield cur.Value.Value
2245-
let! next = ie.MoveNext()
2246-
cur.Value <- next
2247-
finally
2248-
ie.Dispose() }
2249-
return first, rest }
2268+
try
2269+
let ra = ResizeArray<'T>()
2270+
let! m = ie.MoveNext()
2271+
let mutable b = m
2272+
while b.IsSome && ra.Count < count do
2273+
ra.Add b.Value
2274+
let! next = ie.MoveNext()
2275+
b <- next
2276+
let first = ra.ToArray()
2277+
let rest =
2278+
if b.IsNone then
2279+
ie.Dispose()
2280+
empty<'T>
2281+
else
2282+
let mutable cur = b
2283+
asyncSeq {
2284+
try
2285+
while cur.IsSome do
2286+
yield cur.Value
2287+
let! next = ie.MoveNext()
2288+
cur <- next
2289+
finally
2290+
ie.Dispose() }
2291+
return first, rest
2292+
with ex ->
2293+
ie.Dispose()
2294+
return raise ex }
22502295

22512296
let toArrayAsync (source : AsyncSeq<'T>) : Async<'T[]> = async {
22522297
let ra = (new ResizeArray<_>())

src/FSharp.Control.AsyncSeq/AsyncSeq.fsi

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,22 @@ module AsyncSeq =
594594
/// The second sequence is fully buffered before iteration begins, mirroring Seq.allPairs.
595595
val allPairs : source1:AsyncSeq<'T1> -> source2:AsyncSeq<'T2> -> AsyncSeq<'T1 * 'T2>
596596

597+
/// Splits an async sequence of pairs into two arrays. Mirrors List.unzip.
598+
val unzip : source:AsyncSeq<'T1 * 'T2> -> Async<'T1[] * 'T2[]>
599+
600+
/// Splits an async sequence of triples into three arrays. Mirrors List.unzip3.
601+
val unzip3 : source:AsyncSeq<'T1 * 'T2 * 'T3> -> Async<'T1[] * 'T2[] * 'T3[]>
602+
603+
/// Builds a new async sequence whose elements are the results of applying the given
604+
/// function to the corresponding elements of the two sequences. Stops when either
605+
/// sequence is exhausted. Mirrors Seq.map2.
606+
val map2 : mapping:('T1 -> 'T2 -> 'U) -> source1:AsyncSeq<'T1> -> source2:AsyncSeq<'T2> -> AsyncSeq<'U>
607+
608+
/// Builds a new async sequence whose elements are the results of applying the given
609+
/// function to the corresponding elements of three sequences. Stops when any
610+
/// sequence is exhausted. Mirrors List.map3.
611+
val map3 : mapping:('T1 -> 'T2 -> 'T3 -> 'U) -> source1:AsyncSeq<'T1> -> source2:AsyncSeq<'T2> -> source3:AsyncSeq<'T3> -> AsyncSeq<'U>
612+
597613
/// Builds a new asynchronous sequence whose elements are generated by
598614
/// applying the specified function to all elements of the input sequence.
599615
///

tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2816,6 +2816,13 @@ let ``AsyncSeq.pairwise with three elements should produce two pairs`` () =
28162816
let result = AsyncSeq.pairwise source |> AsyncSeq.toListSynchronously
28172817
Assert.AreEqual([(1, 2); (2, 3)], result)
28182818

2819+
[<Test>]
2820+
let ``AsyncSeq.pairwise with many elements produces correct pairs`` () =
2821+
let source = AsyncSeq.ofSeq [1..10]
2822+
let result = AsyncSeq.pairwise source |> AsyncSeq.toListSynchronously
2823+
let expected = [(1,2); (2,3); (3,4); (4,5); (5,6); (6,7); (7,8); (8,9); (9,10)]
2824+
Assert.AreEqual(expected, result)
2825+
28192826
[<Test>]
28202827
let ``AsyncSeq.windowed empty sequence returns empty`` () =
28212828
let result = AsyncSeq.windowed 3 AsyncSeq.empty<int> |> AsyncSeq.toListSynchronously
@@ -3684,7 +3691,79 @@ let ``AsyncSeq.allPairs returns empty when second source is empty`` () =
36843691
|> AsyncSeq.toArrayAsync |> Async.RunSynchronously
36853692
Assert.AreEqual([||], result)
36863693

3687-
// ── AsyncSeq.rev ─────────────────────────────────────────────────────────────
3694+
// ── AsyncSeq.unzip ───────────────────────────────────────────────────────────
3695+
3696+
[<Test>]
3697+
let ``AsyncSeq.unzip splits pairs into two arrays`` () =
3698+
let source = asyncSeq { yield (1, 'a'); yield (2, 'b'); yield (3, 'c') }
3699+
let (lefts, rights) = AsyncSeq.unzip source |> Async.RunSynchronously
3700+
Assert.AreEqual([| 1; 2; 3 |], lefts)
3701+
Assert.AreEqual([| 'a'; 'b'; 'c' |], rights)
3702+
3703+
[<Test>]
3704+
let ``AsyncSeq.unzip empty source returns two empty arrays`` () =
3705+
let (lefts, rights) = AsyncSeq.unzip AsyncSeq.empty<int * string> |> Async.RunSynchronously
3706+
Assert.AreEqual([||], lefts)
3707+
Assert.AreEqual([||], rights)
3708+
3709+
[<Test>]
3710+
let ``AsyncSeq.unzip mirrors List.unzip`` () =
3711+
let pairs = [ (1, 'x'); (2, 'y'); (3, 'z') ]
3712+
let (expL, expR) = List.unzip pairs
3713+
let (actL, actR) = AsyncSeq.unzip (AsyncSeq.ofList pairs) |> Async.RunSynchronously
3714+
Assert.AreEqual(expL |> Array.ofList, actL)
3715+
Assert.AreEqual(expR |> Array.ofList, actR)
3716+
3717+
// ── AsyncSeq.unzip3 ──────────────────────────────────────────────────────────
3718+
3719+
[<Test>]
3720+
let ``AsyncSeq.unzip3 splits triples into three arrays`` () =
3721+
let source = asyncSeq { yield (1, 'a', true); yield (2, 'b', false); yield (3, 'c', true) }
3722+
let (as1, as2, as3) = AsyncSeq.unzip3 source |> Async.RunSynchronously
3723+
Assert.AreEqual([| 1; 2; 3 |], as1)
3724+
Assert.AreEqual([| 'a'; 'b'; 'c' |], as2)
3725+
Assert.AreEqual([| true; false; true |], as3)
3726+
3727+
[<Test>]
3728+
let ``AsyncSeq.unzip3 empty source returns three empty arrays`` () =
3729+
let (as1, as2, as3) = AsyncSeq.unzip3 AsyncSeq.empty<int * string * bool> |> Async.RunSynchronously
3730+
Assert.AreEqual([||], as1)
3731+
Assert.AreEqual([||], as2)
3732+
Assert.AreEqual([||], as3)
3733+
3734+
// ── AsyncSeq.map2 ────────────────────────────────────────────────────────────
3735+
3736+
[<Test>]
3737+
let ``AsyncSeq.map2 applies function pairwise`` () =
3738+
let s1 = asyncSeq { yield 1; yield 2; yield 3 }
3739+
let s2 = asyncSeq { yield 10; yield 20; yield 30 }
3740+
let result = AsyncSeq.map2 (+) s1 s2 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
3741+
Assert.AreEqual([| 11; 22; 33 |], result)
3742+
3743+
[<Test>]
3744+
let ``AsyncSeq.map2 stops at shorter sequence`` () =
3745+
let s1 = asyncSeq { yield 1; yield 2; yield 3 }
3746+
let s2 = asyncSeq { yield 10; yield 20 }
3747+
let result = AsyncSeq.map2 (+) s1 s2 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
3748+
Assert.AreEqual([| 11; 22 |], result)
3749+
3750+
// ── AsyncSeq.map3 ────────────────────────────────────────────────────────────
3751+
3752+
[<Test>]
3753+
let ``AsyncSeq.map3 applies function to three sequences`` () =
3754+
let s1 = asyncSeq { yield 1; yield 2; yield 3 }
3755+
let s2 = asyncSeq { yield 10; yield 20; yield 30 }
3756+
let s3 = asyncSeq { yield 100; yield 200; yield 300 }
3757+
let result = AsyncSeq.map3 (fun a b c -> a + b + c) s1 s2 s3 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
3758+
Assert.AreEqual([| 111; 222; 333 |], result)
3759+
3760+
[<Test>]
3761+
let ``AsyncSeq.map3 stops at shortest sequence`` () =
3762+
let s1 = asyncSeq { yield 1; yield 2; yield 3 }
3763+
let s2 = asyncSeq { yield 10; yield 20; yield 30 }
3764+
let s3 = asyncSeq { yield 100 }
3765+
let result = AsyncSeq.map3 (fun a b c -> a + b + c) s1 s2 s3 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously
3766+
Assert.AreEqual([| 111 |], result)
36883767

36893768
[<Test>]
36903769
let ``AsyncSeq.rev reverses a sequence`` () =
@@ -3746,6 +3825,19 @@ let ``AsyncSeq.splitAt with negative count throws ArgumentException`` () =
37463825
Assert.Throws<System.ArgumentException>(fun () ->
37473826
AsyncSeq.splitAt -1 AsyncSeq.empty<int> |> Async.RunSynchronously |> ignore) |> ignore
37483827

3828+
[<Test>]
3829+
let ``AsyncSeq.splitAt disposes enumerator when source throws during collection`` () =
3830+
let mutable disposed = false
3831+
let source = asyncSeq {
3832+
use _ = { new System.IDisposable with member _.Dispose() = disposed <- true }
3833+
yield 1
3834+
yield 2
3835+
failwith "source error"
3836+
}
3837+
try AsyncSeq.splitAt 10 source |> Async.RunSynchronously |> ignore
3838+
with _ -> ()
3839+
Assert.IsTrue(disposed, "enumerator should be disposed after exception during collection")
3840+
37493841
// ===== removeAt =====
37503842

37513843
[<Test>]
@@ -4190,6 +4282,20 @@ let ``AsyncSeq.tryTail returns all-but-first elements`` () =
41904282
let tail = result.Value |> AsyncSeq.toListAsync |> Async.RunSynchronously
41914283
Assert.AreEqual([2;3;4;5], tail)
41924284

4285+
[<Test>]
4286+
let ``AsyncSeq.tryTail disposes enumerator when source throws on first MoveNext`` () =
4287+
let mutable disposed = false
4288+
// Use a pre-failed task so the exception occurs during MoveNext() (async), not during GetEnumerator()
4289+
let failedTask = System.Threading.Tasks.Task.FromException<unit>(System.Exception("source error"))
4290+
let source = asyncSeq {
4291+
use _ = { new System.IDisposable with member _.Dispose() = disposed <- true }
4292+
let! _ = failedTask |> Async.AwaitTask
4293+
yield 1
4294+
}
4295+
try AsyncSeq.tryTail source |> Async.RunSynchronously |> ignore
4296+
with _ -> ()
4297+
Assert.IsTrue(disposed, "enumerator should be disposed after exception on first MoveNext")
4298+
41934299
[<Test>]
41944300
let ``AsyncSeq.where is alias for filter`` () =
41954301
let result =

0 commit comments

Comments
 (0)