Skip to content

Commit c4fbcf6

Browse files
authored
Merge pull request #311 from fsprojects/repo-assist/fix-cancellation-token-179-2026-03-65d8a31d8f19eaa2
[Repo Assist] fix: honor CancellationToken in MoveNextAsync (closes #179)
2 parents dea5779 + ed19d3e commit c4fbcf6

File tree

4 files changed

+161
-0
lines changed

4 files changed

+161
-0
lines changed

release-notes.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Release notes:
55
- update engineering to .NET 9/10
66
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
77
- adds TaskSeq.pairwise, #289
8+
- fixes: CancellationToken passed to GetAsyncEnumerator is now honored in MoveNextAsync, #179
89

910
0.4.0
1011
- overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
<Compile Include="TaskSeq.Do.Tests.fs" />
6363
<Compile Include="TaskSeq.Let.Tests.fs" />
6464
<Compile Include="TaskSeq.Using.Tests.fs" />
65+
<Compile Include="TaskSeq.CancellationToken.Tests.fs" />
6566
</ItemGroup>
6667

6768
<ItemGroup>
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
module TaskSeq.Tests.CancellationToken
2+
3+
open System
4+
open System.Threading
5+
open System.Threading.Tasks
6+
7+
open Xunit
8+
open FsUnit.Xunit
9+
10+
open FSharp.Control
11+
12+
/// An infinite taskSeq that yields 1 forever
13+
let private infiniteOnes () = taskSeq {
14+
while true do
15+
yield 1
16+
}
17+
18+
/// A finite taskSeq with a few items
19+
let private fiveItems () = taskSeq {
20+
yield 1
21+
yield 2
22+
yield 3
23+
yield 4
24+
yield 5
25+
}
26+
27+
module Cancellation =
28+
29+
[<Fact>]
30+
let ``GetAsyncEnumerator with pre-cancelled token: first MoveNextAsync throws OperationCanceledException`` () = task {
31+
use cts = new CancellationTokenSource()
32+
cts.Cancel()
33+
use enum = (infiniteOnes ()).GetAsyncEnumerator(cts.Token)
34+
35+
fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore
36+
|> should throwAsync typeof<OperationCanceledException>
37+
}
38+
39+
[<Fact>]
40+
let ``GetAsyncEnumerator with pre-cancelled token: MoveNextAsync on finite seq also throws`` () = task {
41+
use cts = new CancellationTokenSource()
42+
cts.Cancel()
43+
use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token)
44+
45+
fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore
46+
|> should throwAsync typeof<OperationCanceledException>
47+
}
48+
49+
[<Fact>]
50+
let ``GetAsyncEnumerator with non-cancelled token: iteration proceeds normally`` () = task {
51+
use cts = new CancellationTokenSource()
52+
use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token)
53+
let mutable count = 0
54+
let mutable canContinue = true
55+
56+
while canContinue do
57+
let! hasNext = enum.MoveNextAsync()
58+
59+
if hasNext then count <- count + 1 else canContinue <- false
60+
61+
count |> should equal 5
62+
}
63+
64+
[<Fact>]
65+
let ``GetAsyncEnumerator with CancellationToken.None: iteration proceeds normally`` () = task {
66+
use enum = (fiveItems ()).GetAsyncEnumerator(CancellationToken.None)
67+
let mutable count = 0
68+
let mutable canContinue = true
69+
70+
while canContinue do
71+
let! hasNext = enum.MoveNextAsync()
72+
73+
if hasNext then count <- count + 1 else canContinue <- false
74+
75+
count |> should equal 5
76+
}
77+
78+
[<Fact>]
79+
let ``Token cancelled after partial iteration: next MoveNextAsync throws OperationCanceledException`` () = task {
80+
use cts = new CancellationTokenSource()
81+
use enum = (fiveItems ()).GetAsyncEnumerator(cts.Token)
82+
83+
// Consume first two items normally
84+
let! _ = enum.MoveNextAsync()
85+
let! _ = enum.MoveNextAsync()
86+
87+
// Cancel the token
88+
cts.Cancel()
89+
90+
// Next call should throw
91+
fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore
92+
|> should throwAsync typeof<OperationCanceledException>
93+
}
94+
95+
[<Fact>]
96+
let ``Infinite sequence with pre-cancelled token: throws immediately without consuming any items`` () = task {
97+
use cts = new CancellationTokenSource()
98+
cts.Cancel()
99+
let mutable itemsConsumed = 0
100+
101+
let seq = taskSeq {
102+
while true do
103+
itemsConsumed <- itemsConsumed + 1
104+
yield itemsConsumed
105+
}
106+
107+
use enum = seq.GetAsyncEnumerator(cts.Token)
108+
109+
fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore
110+
|> should throwAsync typeof<OperationCanceledException>
111+
112+
// The body should not have run (cancellation checked before advancing state machine)
113+
itemsConsumed |> should equal 0
114+
}
115+
116+
[<Fact>]
117+
let ``Token cancelled mid-iteration of infinite sequence terminates with OperationCanceledException`` () = task {
118+
use cts = new CancellationTokenSource()
119+
use enum = (infiniteOnes ()).GetAsyncEnumerator(cts.Token)
120+
121+
// Iterate a few steps without cancellation
122+
for _ in 1..5 do
123+
let! hasNext = enum.MoveNextAsync()
124+
hasNext |> should be True
125+
126+
// Now cancel
127+
cts.Cancel()
128+
129+
// Next call should throw
130+
fun () -> enum.MoveNextAsync().AsTask() |> Task.ignore
131+
|> should throwAsync typeof<OperationCanceledException>
132+
}
133+
134+
[<Fact>]
135+
let ``Multiple enumerators of same sequence respect independent cancellation tokens`` () = task {
136+
let source = fiveItems ()
137+
use cts1 = new CancellationTokenSource()
138+
use cts2 = new CancellationTokenSource()
139+
140+
// Cancel only the first token
141+
cts1.Cancel()
142+
143+
use enum1 = source.GetAsyncEnumerator(cts1.Token)
144+
use enum2 = source.GetAsyncEnumerator(cts2.Token)
145+
146+
// enum1 should throw (cancelled)
147+
fun () -> enum1.MoveNextAsync().AsTask() |> Task.ignore
148+
|> should throwAsync typeof<OperationCanceledException>
149+
150+
// enum2 should work normally (not cancelled)
151+
let! hasNext = enum2.MoveNextAsync()
152+
hasNext |> should be True
153+
enum2.Current |> should equal 1
154+
}

src/FSharp.Control.TaskSeq/TaskSeqBuilder.fs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ and [<NoComparison; NoEquality>] TaskSeq<'Machine, 'T
242242
Debug.logInfo "at MoveNextAsync: normal resumption scenario"
243243

244244
let data = this._machine.Data
245+
246+
// Honor the cancellation token passed to GetAsyncEnumerator (fixes #179).
247+
// ThrowIfCancellationRequested() is a no-op for CancellationToken.None.
248+
data.cancellationToken.ThrowIfCancellationRequested()
249+
245250
data.promiseOfValueOrEnd.Reset()
246251
let mutable ts = this
247252

0 commit comments

Comments
 (0)