Skip to content
Merged
1 change: 1 addition & 0 deletions release-notes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Release notes:

Unreleased
- test: add SideEffects module to TaskSeq.Using.Tests.fs; 7 new tests verify Dispose/DisposeAsync call counts, re-iteration semantics, and early-termination disposal for use and use! CE bindings
- perf: pairwise, distinctUntilChanged, distinctUntilChangedWith, distinctUntilChangedWithAsync now use explicit enumerator + while! instead of ValueOption tracking + for-in loop, eliminating per-element struct match overhead
- 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 to TaskSeq.OfXXX.Tests.fs documenting re-iteration semantics (ofSeq re-evaluates source, ofTaskArray re-awaits cached tasks)
Expand Down
118 changes: 118 additions & 0 deletions src/FSharp.Control.TaskSeq.Test/TaskSeq.Using.Tests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ type private MultiDispose(disposed: int ref) =
interface IAsyncDisposable with
member _.DisposeAsync() = ValueTask(task { do disposed.Value <- -1 })

/// Tracks how many times Dispose/DisposeAsync has been called.
type private CountingDisposable(disposeCount: int ref) =
interface IDisposable with
member _.Dispose() = disposeCount.Value <- disposeCount.Value + 1

/// Tracks how many times DisposeAsync has been called.
type private CountingAsyncDisposable(disposeCount: int ref) =
interface IAsyncDisposable with
member _.DisposeAsync() =
disposeCount.Value <- disposeCount.Value + 1
ValueTask.CompletedTask

let private check = TaskSeq.length >> Task.map (should equal 1)

[<Fact>]
Expand Down Expand Up @@ -105,3 +117,109 @@ let ``CE taskSeq: Using! when type implements IDisposable and IAsyncDisposable``

check ts
|> Task.map (fun _ -> disposed.Value |> should equal -1) // should prefer IAsyncDisposable, which returns -1

module SideEffects =
[<Fact>]
let ``CE taskSeq: use - Dispose called exactly once per full iteration`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use _ = new CountingDisposable(disposeCount)
yield 1
}

do! ts |> TaskSeq.iter ignore
disposeCount.Value |> should equal 1
}

[<Fact>]
let ``CE taskSeq: use - Dispose called on each re-iteration`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use _ = new CountingDisposable(disposeCount)
yield 1
}

do! ts |> TaskSeq.iter ignore
do! ts |> TaskSeq.iter ignore
do! ts |> TaskSeq.iter ignore
disposeCount.Value |> should equal 3
}

[<Fact>]
let ``CE taskSeq: use! - DisposeAsync called exactly once per full iteration`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use! _ = task { return new CountingAsyncDisposable(disposeCount) }
yield 1
}

do! ts |> TaskSeq.iter ignore
disposeCount.Value |> should equal 1
}

[<Fact>]
let ``CE taskSeq: use! - DisposeAsync called on each re-iteration`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use! _ = task { return new CountingAsyncDisposable(disposeCount) }
yield 1
}

do! ts |> TaskSeq.iter ignore
do! ts |> TaskSeq.iter ignore
do! ts |> TaskSeq.iter ignore
disposeCount.Value |> should equal 3
}

[<Fact>]
let ``CE taskSeq: use - Dispose called on early termination via take`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use _ = new CountingDisposable(disposeCount)
yield 1
yield 2
yield 3
}

// Only take 1 item β€” enumerator is disposed before the rest of the sequence runs
do! ts |> TaskSeq.take 1 |> TaskSeq.iter ignore
disposeCount.Value |> should equal 1
}

[<Fact>]
let ``CE taskSeq: use - multiple use bindings each get their own Dispose`` () = task {
let disposeCount = ref 0

let ts = taskSeq {
use _ = new CountingDisposable(disposeCount)
use _ = new CountingDisposable(disposeCount)
yield 1
}

do! ts |> TaskSeq.iter ignore
disposeCount.Value |> should equal 2
}

[<Fact>]
let ``CE taskSeq: use - each re-iteration creates and disposes a fresh resource`` () = task {
let createCount = ref 0

let ts = taskSeq {
createCount.Value <- createCount.Value + 1
use _ = new CountingDisposable(ref 0) // fresh ref each time
yield createCount.Value
}

let! first = ts |> TaskSeq.toListAsync
let! second = ts |> TaskSeq.toListAsync

// Each re-iteration re-runs the CE body and creates a new resource
first |> should equal [ 1 ]
second |> should equal [ 2 ]
createCount.Value |> should equal 2
}
Loading