Skip to content

Commit a52c969

Browse files
authored
Merge branch 'main' into repo-assist/test-unfold-sideeffects-20260423-240f686bd62cbcbc
2 parents 299d61d + defa712 commit a52c969

10 files changed

Lines changed: 498 additions & 131 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.Unfold.Tests.fs, verifying generator call counts, re-iteration behaviour, early-termination via take, and exception propagation
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.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

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 26 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
[<AutoOpen>]
@@ -529,6 +530,20 @@ type TaskSeq =
529530
/// <exception cref="T:ArgumentNullException">Thrown when the input sequence is null.</exception>
530531
static member toIListAsync: source: TaskSeq<'T> -> Task<IList<'T>>
531532

533+
/// <summary>
534+
/// Writes all elements of the input task sequence <paramref name="source" /> to a
535+
/// <see cref="ChannelWriter&lt;'T>" /> and marks the writer as complete when the sequence
536+
/// is exhausted. If an exception is raised during iteration, the writer is completed with
537+
/// that exception so that downstream readers observe it.
538+
/// This function is non-blocking while it writes to the channel.
539+
/// </summary>
540+
///
541+
/// <param name="writer">The channel writer to write elements into.</param>
542+
/// <param name="source">The input task sequence.</param>
543+
/// <returns>A <see cref="Task" /> that completes when all elements have been written.</returns>
544+
/// <exception cref="T:ArgumentNullException">Thrown when <paramref name="writer" /> or <paramref name="source" /> is null.</exception>
545+
static member toChannelAsync: writer: ChannelWriter<'T> -> source: TaskSeq<'T> -> Task
546+
532547
/// <summary>
533548
/// Views the given <see cref="array" /> as a task sequence, that is, as an <see cref="IAsyncEnumerable&lt;'T>" />.
534549
/// </summary>
@@ -642,6 +657,17 @@ type TaskSeq =
642657
/// <exception cref="T:ArgumentNullException">Thrown when the input sequence is null.</exception>
643658
static member ofAsyncArray: source: Async<'T> array -> TaskSeq<'T>
644659

660+
/// <summary>
661+
/// Views a <see cref="ChannelReader&lt;'T>" /> as a task sequence. Elements are yielded as they
662+
/// become available; the sequence ends when the channel is completed and all buffered elements
663+
/// have been consumed.
664+
/// </summary>
665+
///
666+
/// <param name="reader">The channel reader to read elements from.</param>
667+
/// <returns>A task sequence that yields elements from the channel.</returns>
668+
/// <exception cref="T:ArgumentNullException">Thrown when <paramref name="reader" /> is null.</exception>
669+
static member ofChannel: reader: ChannelReader<'T> -> TaskSeq<'T>
670+
645671
/// <summary>
646672
/// Returns a task sequence that, when iterated, passes the given <paramref name="cancellationToken" /> to the
647673
/// underlying <see cref="IAsyncEnumerable&lt;'T&gt;" />. This is the equivalent of calling

0 commit comments

Comments
 (0)