Skip to content

Commit 93ca1b3

Browse files
authored
Merge branch 'main' into repo-assist/test-ofxxx-sideeffects-20260424-c044cc685e66a474
2 parents b8e8e11 + 6f1eae7 commit 93ca1b3

11 files changed

Lines changed: 500 additions & 133 deletions

File tree

.github/aw/actions-lock.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
{
22
"entries": {
3+
"actions/github-script@v9": {
4+
"repo": "actions/github-script",
5+
"version": "v9",
6+
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
7+
},
38
"actions/github-script@v9.0.0": {
49
"repo": "actions/github-script",
510
"version": "v9.0.0",
611
"sha": "d746ffe35508b1917358783b479e04febd2b8f71"
712
},
8-
"github/gh-aw-actions/setup@v0.68.3": {
13+
"github/gh-aw-actions/setup@v0.71.3": {
914
"repo": "github/gh-aw-actions/setup",
10-
"version": "v0.68.3",
11-
"sha": "ba90f2186d7ad780ec640f364005fa24e797b360"
15+
"version": "v0.71.3",
16+
"sha": "07c7335cd76c4d4d9f00dd7874f85ff55ed71f24"
1217
},
1318
"github/gh-aw/actions/setup@v0.68.7": {
1419
"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 48 hours
17+
schedule: weekly
1818
workflow_dispatch:
1919
slash_command:
2020
name: repo-assist

release-notes.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Release notes:
33

44
Unreleased
55
- test: add SideEffects module to TaskSeq.OfXXX.Tests.fs documenting re-iteration semantics (ofSeq re-evaluates source, ofTaskArray re-awaits cached tasks)
6+
- feat: add `TaskSeq.toChannelAsync` and `TaskSeq.ofChannel` for bidirectional `System.Threading.Channels` integration, closing #415
7+
- eng: update PackageValidationBaselineVersion from 0.4.0 to 1.1.1 to enforce binary compatibility checks against the current stable release
68
- test: add SideEffects module and ImmTaskSeq variant tests to TaskSeq.ChunkBy.Tests.fs, improving coverage for chunkBy and chunkByAsync
79
- fixes: `Async.bind` signature corrected from `(Async<'T> -> Async<'U>)` to `('T -> Async<'U>)` to match standard monadic bind semantics (same as `Task.bind`); the previous signature made the function effectively equivalent to direct application
810
- refactor: simplify splitAt 'rest' taskSeq to use while!, removing redundant go2 mutable and manual MoveNextAsync pre-advance

src/FSharp.Control.TaskSeq.SmokeTests/FSharp.Control.TaskSeq.SmokeTests.fsproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
highest version available.
2020
This ensures that, if we have a forwards compat issue, we will get an error.
2121
-->
22-
<PackageReference Include="FSharp.Control.TaskSeq" Version="0.4.0" />
23-
<PackageReference Include="FsToolkit.ErrorHandling.TaskResult" Version="4.15.1" />
22+
<PackageReference Include="FSharp.Control.TaskSeq" Version="1.1.1" />
23+
<PackageReference Include="FsToolkit.ErrorHandling.TaskResult" Version="4.18.0" />
2424
<PackageReference Include="FsUnit.xUnit" Version="6.0.1" />
2525
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
2626
<PackageReference Include="xunit" Version="2.9.3" />

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,81 @@ module Immutable =
9191
let! result = TaskSeq.singleton 42 |> TaskSeq.lastOrDefault 0
9292
result |> should equal 42
9393
}
94+
95+
96+
module SideEffects =
97+
[<Fact>]
98+
let ``TaskSeq-firstOrDefault __special-case__ prove it does not read beyond first yield`` () = task {
99+
let mutable x = 42
100+
101+
let ts = taskSeq {
102+
yield x
103+
x <- x + 1 // we never get here
104+
}
105+
106+
let! fortyTwo = ts |> TaskSeq.firstOrDefault 0
107+
let! stillFortyTwo = ts |> TaskSeq.firstOrDefault 0 // the statement after 'yield' will never be reached
108+
109+
fortyTwo |> should equal 42
110+
stillFortyTwo |> should equal 42
111+
}
112+
113+
[<Fact>]
114+
let ``TaskSeq-firstOrDefault __special-case__ prove early side effect is executed`` () = task {
115+
let mutable x = 42
116+
117+
let ts = taskSeq {
118+
x <- x + 1
119+
x <- x + 1
120+
yield 42
121+
x <- x + 200 // we won't get here!
122+
}
123+
124+
let! result = ts |> TaskSeq.firstOrDefault 0
125+
result |> should equal 42
126+
x |> should equal 44
127+
128+
let! result = ts |> TaskSeq.firstOrDefault 0
129+
result |> should equal 42
130+
x |> should equal 46
131+
}
132+
133+
[<Fact>]
134+
let ``TaskSeq-lastOrDefault __special-case__ prove it reads the entire sequence`` () = task {
135+
let mutable x = 42
136+
137+
let ts = taskSeq {
138+
yield x
139+
x <- x + 1 // will be executed
140+
yield x
141+
x <- x + 1 // will be executed
142+
}
143+
144+
let! result = ts |> TaskSeq.lastOrDefault -1
145+
result |> should equal 43
146+
x |> should equal 44
147+
}
148+
149+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
150+
let ``TaskSeq-firstOrDefault returns first item in a side-effect sequence`` variant = task {
151+
let ts = Gen.getSeqWithSideEffect variant
152+
153+
let! first = ts |> TaskSeq.firstOrDefault 0
154+
first |> should equal 1
155+
156+
// side effect: re-enumerating changes the first item
157+
let! secondFirst = ts |> TaskSeq.firstOrDefault 0
158+
secondFirst |> should not' (equal 1)
159+
}
160+
161+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
162+
let ``TaskSeq-lastOrDefault returns last item and exhausts the sequence`` variant = task {
163+
let ts = Gen.getSeqWithSideEffect variant
164+
165+
let! last = ts |> TaskSeq.lastOrDefault 0
166+
last |> should equal 10
167+
168+
// side effect: re-enumerating continues from mutated state
169+
let! secondLast = ts |> TaskSeq.lastOrDefault 0
170+
secondLast |> should equal 20
171+
}

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module TaskSeq.Tests.``Conversion-To``
22

33
open System.Collections.Generic
4+
open System.Threading.Channels
45

56
open Xunit
67
open FsUnit.Xunit
@@ -186,3 +187,72 @@ module SideEffects =
186187
let (results2: seq<_>) = tq |> TaskSeq.toSeq
187188
results1 |> Seq.toArray |> should equal [| 1..10 |]
188189
results2 |> Seq.toArray |> should equal [| 11..20 |]
190+
191+
module Channel =
192+
193+
[<Fact>]
194+
let ``TaskSeq-toChannelAsync with null writer raises`` () =
195+
assertNullArg
196+
<| fun () ->
197+
TaskSeq.toChannelAsync null (TaskSeq.ofArray [| 1 |])
198+
|> ignore
199+
200+
[<Fact>]
201+
let ``TaskSeq-toChannelAsync with null source raises`` () =
202+
let ch = Channel.CreateUnbounded<int>()
203+
204+
assertNullArg
205+
<| fun () -> TaskSeq.toChannelAsync ch.Writer null |> ignore
206+
207+
[<Fact>]
208+
let ``TaskSeq-ofChannel with null reader raises`` () =
209+
assertNullArg
210+
<| fun () -> TaskSeq.ofChannel<int> null |> ignore
211+
212+
[<Fact>]
213+
let ``TaskSeq-toChannelAsync with empty source completes the channel`` () = task {
214+
let ch = Channel.CreateUnbounded<int>()
215+
do! TaskSeq.toChannelAsync ch.Writer TaskSeq.empty
216+
ch.Reader.Completion.IsCompleted |> should be True
217+
let! results = TaskSeq.ofChannel ch.Reader |> TaskSeq.toArrayAsync
218+
results |> should be Empty
219+
}
220+
221+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
222+
let ``TaskSeq-toChannelAsync writes all elements and completes the channel`` variant = task {
223+
let tq = Gen.getSeqImmutable variant
224+
let ch = Channel.CreateUnbounded<int>()
225+
do! TaskSeq.toChannelAsync ch.Writer tq
226+
let! results = TaskSeq.ofChannel ch.Reader |> TaskSeq.toArrayAsync
227+
results |> should equal [| 1..10 |]
228+
// Completion resolves once the channel is marked done and the buffer is drained
229+
do! ch.Reader.Completion
230+
}
231+
232+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
233+
let ``TaskSeq-ofChannel yields all elements written to the channel`` variant = task {
234+
let tq = Gen.getSeqImmutable variant
235+
let ch = Channel.CreateUnbounded<int>()
236+
do! TaskSeq.toChannelAsync ch.Writer tq
237+
let! results = TaskSeq.ofChannel ch.Reader |> TaskSeq.toArrayAsync
238+
results |> should equal [| 1..10 |]
239+
}
240+
241+
[<Fact>]
242+
let ``TaskSeq-ofChannel ends when channel is completed and drained`` () = task {
243+
let ch = Channel.CreateUnbounded<int>()
244+
do! ch.Writer.WriteAsync 42
245+
do! ch.Writer.WriteAsync 99
246+
ch.Writer.Complete()
247+
let! results = TaskSeq.ofChannel ch.Reader |> TaskSeq.toArrayAsync
248+
results |> should equal [| 42; 99 |]
249+
}
250+
251+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
252+
let ``TaskSeq-toChannelAsync executes side effects`` variant = task {
253+
let tq = Gen.getSeqWithSideEffect variant
254+
let ch = Channel.CreateUnbounded<int>()
255+
do! TaskSeq.toChannelAsync ch.Writer tq
256+
let! results = TaskSeq.ofChannel ch.Reader |> TaskSeq.toArrayAsync
257+
results |> should equal [| 1..10 |]
258+
}

src/FSharp.Control.TaskSeq/CompatibilitySuppressions.xml

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,4 @@
88
<Right>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Right>
99
<IsBaselineSuppression>true</IsBaselineSuppression>
1010
</Suppression>
11-
<Suppression>
12-
<DiagnosticId>CP0002</DiagnosticId>
13-
<Target>M:FSharp.Control.LowPriority.TaskSeqBuilder#Bind``5(FSharp.Control.TaskSeqBuilder,``0,Microsoft.FSharp.Core.FSharpFunc{``1,Microsoft.FSharp.Core.CompilerServices.ResumableCode{FSharp.Control.TaskSeqStateMachineData{``2},Microsoft.FSharp.Core.Unit}})</Target>
14-
<Left>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Left>
15-
<Right>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Right>
16-
<IsBaselineSuppression>true</IsBaselineSuppression>
17-
</Suppression>
18-
<Suppression>
19-
<DiagnosticId>CP0002</DiagnosticId>
20-
<Target>M:FSharp.Control.LowPriority.TaskSeqBuilder#Bind$W``5(Microsoft.FSharp.Core.FSharpFunc{``0,``3},Microsoft.FSharp.Core.FSharpFunc{``3,``1},Microsoft.FSharp.Core.FSharpFunc{``3,System.Boolean},FSharp.Control.TaskSeqBuilder,``0,Microsoft.FSharp.Core.FSharpFunc{``1,Microsoft.FSharp.Core.CompilerServices.ResumableCode{FSharp.Control.TaskSeqStateMachineData{``2},Microsoft.FSharp.Core.Unit}})</Target>
21-
<Left>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Left>
22-
<Right>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Right>
23-
<IsBaselineSuppression>true</IsBaselineSuppression>
24-
</Suppression>
25-
<Suppression>
26-
<DiagnosticId>CP0002</DiagnosticId>
27-
<Target>M:FSharp.Control.TaskExtensions.TaskBuilder#For``2(Microsoft.FSharp.Control.TaskBuilder,System.Collections.Generic.IAsyncEnumerable{``0},Microsoft.FSharp.Core.FSharpFunc{``0,Microsoft.FSharp.Core.CompilerServices.ResumableCode{Microsoft.FSharp.Control.TaskStateMachineData{``1},Microsoft.FSharp.Core.Unit}})</Target>
28-
<Left>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Left>
29-
<Right>lib/netstandard2.1/FSharp.Control.TaskSeq.dll</Right>
30-
<IsBaselineSuppression>true</IsBaselineSuppression>
31-
</Suppression>
3211
</Suppressions>

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ Generates optimized IL code through resumable state machines, and comes with a c
3030
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
3131
<!-- Validate package structure and (when PackageValidationBaselineVersion is set) binary compatibility -->
3232
<EnablePackageValidation>true</EnablePackageValidation>
33-
<!-- Set this to the last published version to enforce binary compatibility, e.g. 0.4.0 -->
34-
<PackageValidationBaselineVersion>0.4.0</PackageValidationBaselineVersion>
33+
<!-- Set this to the last published version to enforce binary compatibility -->
34+
<PackageValidationBaselineVersion>1.1.1</PackageValidationBaselineVersion>
3535
</PropertyGroup>
3636

3737
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -74,5 +74,7 @@ Generates optimized IL code through resumable state machines, and comes with a c
7474
<!-- if using "remove unused references", this prevents FSharp.Core from being shown in that list -->
7575
<TreatAsUsed>true</TreatAsUsed>
7676
</PackageReference>
77+
<!-- Provides System.Threading.Channels for netstandard2.1 consumers; built into net5.0+ -->
78+
<PackageReference Include="System.Threading.Channels" Version="8.0.0" />
7779
</ItemGroup>
7880
</Project>

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace FSharp.Control
22

33
open System.Collections.Generic
44
open System.Threading
5+
open System.Threading.Channels
56
open System.Threading.Tasks
67

78
// Just for convenience
@@ -180,6 +181,23 @@ type TaskSeq private () =
180181

181182
static member toIListAsync source = Internal.toResizeArrayAndMapAsync (fun x -> x :> IList<_>) source
182183

184+
static member toChannelAsync (writer: ChannelWriter<'T>) (source: TaskSeq<'T>) : Task =
185+
Internal.checkNonNull (nameof writer) writer
186+
Internal.checkNonNull (nameof source) source
187+
188+
task {
189+
try
190+
use e = source.GetAsyncEnumerator CancellationToken.None
191+
192+
while! e.MoveNextAsync() do
193+
do! writer.WriteAsync e.Current
194+
195+
writer.TryComplete() |> ignore
196+
with exn ->
197+
writer.TryComplete exn |> ignore
198+
}
199+
:> Task
200+
183201
//
184202
// Convert 'OfXXX' functions
185203
//
@@ -261,6 +279,17 @@ type TaskSeq private () =
261279
yield c
262280
}
263281

282+
static member ofChannel(reader: ChannelReader<'T>) : TaskSeq<'T> =
283+
Internal.checkNonNull (nameof reader) reader
284+
285+
taskSeq {
286+
while! reader.WaitToReadAsync() do
287+
let mutable item = Unchecked.defaultof<_>
288+
289+
while reader.TryRead &item do
290+
yield item
291+
}
292+
264293
static member withCancellation (cancellationToken: CancellationToken) (source: TaskSeq<'T>) =
265294
Internal.checkNonNull (nameof source) source
266295

0 commit comments

Comments
 (0)