diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index bb34c94..cfd430c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,7 @@ +### 4.16.0 + +* 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` 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. + ### 4.15.0 * Bug fix: `AsyncSeq.removeAt` and `AsyncSeq.updateAt` now raise `ArgumentException` when the index is greater than or equal to the sequence length, consistent with `List.removeAt`, `Array.removeAt`, and `AsyncSeq.insertAt`. Previously they silently returned the sequence unchanged. diff --git a/src/FSharp.Control.AsyncSeq/AsyncSeq.fs b/src/FSharp.Control.AsyncSeq/AsyncSeq.fs index 26536a7..06291a0 100644 --- a/src/FSharp.Control.AsyncSeq/AsyncSeq.fs +++ b/src/FSharp.Control.AsyncSeq/AsyncSeq.fs @@ -548,35 +548,35 @@ module AsyncSeq = let tryWith (inp: AsyncSeq<'T>) (handler : exn -> AsyncSeq<'T>) : AsyncSeq<'T> = // Note: this is put outside the object deliberately, so the object doesn't permanently capture inp1 and inp2 AsyncSeqImpl(fun () -> - let state = ref (TryWithState.NotStarted inp) + let mutable state = TryWithState.NotStarted inp { new IAsyncSeqEnumerator<'T> with member x.MoveNext() = - async { match state.Value with + async { match state with | TryWithState.NotStarted inp -> - let res = ref Unchecked.defaultof<_> + let mutable res = Unchecked.defaultof<_> try - res.Value <- Choice1Of2 (inp.GetEnumerator()) + res <- Choice1Of2 (inp.GetEnumerator()) with exn -> - res.Value <- Choice2Of2 exn - match res.Value with + res <- Choice2Of2 exn + match res with | Choice1Of2 r -> return! - (state.Value <- TryWithState.HaveBodyEnumerator r + (state <- TryWithState.HaveBodyEnumerator r x.MoveNext()) | Choice2Of2 exn -> return! (x.Dispose() let enum = (handler exn).GetEnumerator() - state.Value <- TryWithState.HaveHandlerEnumerator enum + state <- TryWithState.HaveHandlerEnumerator enum x.MoveNext()) | TryWithState.HaveBodyEnumerator e -> - let res = ref Unchecked.defaultof<_> + let mutable res = Unchecked.defaultof<_> try let! r = e.MoveNext() - res.Value <- Choice1Of2 r + res <- Choice1Of2 r with exn -> - res.Value <- Choice2Of2 exn - match res.Value with + res <- Choice2Of2 exn + match res with | Choice1Of2 res -> return (match res with @@ -587,7 +587,7 @@ module AsyncSeq = return! (x.Dispose() let e = (handler exn).GetEnumerator() - state.Value <- TryWithState.HaveHandlerEnumerator e + state <- TryWithState.HaveHandlerEnumerator e x.MoveNext()) | TryWithState.HaveHandlerEnumerator e -> let! res = e.MoveNext() @@ -597,9 +597,9 @@ module AsyncSeq = | _ -> return None } member x.Dispose() = - match state.Value with + match state with | TryWithState.HaveBodyEnumerator e | TryWithState.HaveHandlerEnumerator e -> - state.Value <- TryWithState.Finished + state <- TryWithState.Finished dispose e | _ -> () }) :> AsyncSeq<'T> @@ -614,14 +614,14 @@ module AsyncSeq = // The (synchronous) compensation is run when the Dispose() is called let tryFinally (inp: AsyncSeq<'T>) (compensation : unit -> unit) : AsyncSeq<'T> = AsyncSeqImpl(fun () -> - let state = ref (TryFinallyState.NotStarted inp) + let mutable state = TryFinallyState.NotStarted inp { new IAsyncSeqEnumerator<'T> with member x.MoveNext() = - async { match state.Value with + async { match state with | TryFinallyState.NotStarted inp -> return! (let e = inp.GetEnumerator() - state.Value <- TryFinallyState.HaveBodyEnumerator e + state <- TryFinallyState.HaveBodyEnumerator e x.MoveNext()) | TryFinallyState.HaveBodyEnumerator e -> let! res = e.MoveNext() @@ -633,9 +633,9 @@ module AsyncSeq = | _ -> return None } member x.Dispose() = - match state.Value with + match state with | TryFinallyState.HaveBodyEnumerator e-> - state.Value <- TryFinallyState.Finished + state <- TryFinallyState.Finished dispose e compensation() | _ -> () }) :> AsyncSeq<'T> @@ -767,13 +767,13 @@ module AsyncSeq = let ofSeq (inp: seq<'T>) : AsyncSeq<'T> = AsyncSeqImpl(fun () -> - let state = ref (MapState.NotStarted inp) + let mutable state = MapState.NotStarted inp { new IAsyncSeqEnumerator<'T> with member x.MoveNext() = - async { match state.Value with + async { match state with | MapState.NotStarted inp -> let e = inp.GetEnumerator() - state.Value <- MapState.HaveEnumerator e + state <- MapState.HaveEnumerator e return! x.MoveNext() | MapState.HaveEnumerator e -> return @@ -784,9 +784,9 @@ module AsyncSeq = None) | _ -> return None } member x.Dispose() = - match state.Value with + match state with | MapState.HaveEnumerator e -> - state.Value <- MapState.Finished + state <- MapState.Finished dispose e | _ -> () }) :> AsyncSeq<'T> diff --git a/tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs b/tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs index ae14eb3..6c640d9 100644 --- a/tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs +++ b/tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs @@ -4585,3 +4585,40 @@ let ``AsyncSeq.cycle on singleton repeats single element`` () = |> AsyncSeq.toArrayAsync |> Async.RunSynchronously Assert.AreEqual([| 42; 42; 42; 42; 42 |], result) + +// ===== ofSeq: re-enumeration and empty-sequence edge cases ===== + +[] +let ``AsyncSeq.ofSeq empty returns empty`` () = + let result = AsyncSeq.ofSeq Seq.empty |> AsyncSeq.toArrayAsync |> Async.RunSynchronously + Assert.AreEqual([||], result) + +[] +let ``AsyncSeq.ofSeq can be enumerated multiple times`` () = + let s = AsyncSeq.ofSeq [1; 2; 3] + let r1 = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously + let r2 = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously + Assert.AreEqual([| 1; 2; 3 |], r1) + Assert.AreEqual([| 1; 2; 3 |], r2) + +// ===== tryFinally: compensation runs even when downstream stops early ===== + +[] +let ``asyncSeq use releases resource on early termination`` () = + let disposed = ref false + let resource = { new System.IDisposable with member _.Dispose() = disposed := true } + let s = asyncSeq { + use _r = resource + yield 1; yield 2; yield 3 } + s |> AsyncSeq.take 1 |> AsyncSeq.toArrayAsync |> Async.RunSynchronously |> ignore + Assert.IsTrue(disposed.Value) + +// ===== tryWith: handler receives exception and yields elements ===== + +[] +let ``asyncSeq try-with handler can yield elements`` () = + let s = asyncSeq { + try failwith "boom" + with _ -> yield 42 } + let result = s |> AsyncSeq.toArrayAsync |> Async.RunSynchronously + Assert.AreEqual([| 42 |], result)