Summary
With FSharp.Control.TaskSeq 1.1.1, a taskSeq that awaits between yields (do! Task.Yield() or do! Task.Delay n) stops after the first await when the consuming project is compiled in the Debug configuration. The same code enumerates every item in Release. TaskSeq.toListAsync, a manual GetAsyncEnumerator / MoveNextAsync loop and an ASP.NET Core response writer consuming the IAsyncEnumerable are all affected. A taskSeq without awaits between yields is fine.
Impact: dotnet test builds Debug by default, so tests that stream through a taskSeq silently observe only the first item. That is how I ran into it (an xunit test of a chunked HTTP writer that takes an IAsyncEnumerable).
Repro
Repro.fsproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.Control.TaskSeq" Version="1.1.1" />
</ItemGroup>
</Project>
Program.fs
open System.Threading.Tasks
open FSharp.Control
let awaitBetweenYields () =
taskSeq {
yield 1
do! Task.Yield()
yield 2
do! Task.Delay 5
yield 3
}
let delayOnly () =
taskSeq {
yield 1
do! Task.Delay 5
yield 2
}
let forLoopWithDelay () =
taskSeq {
for i in 1..3 do
do! Task.Delay 1
yield i
}
let noAwaits () =
taskSeq {
yield 1
yield 2
yield 3
}
let manualEnumeration (source: taskSeq<int>) =
task {
let e = source.GetAsyncEnumerator()
let results = ResizeArray()
while! e.MoveNextAsync() do
results.Add e.Current
return List.ofSeq results
}
printfn "awaitBetweenYields toListAsync : %A" (awaitBetweenYields() |> TaskSeq.toListAsync).Result
printfn "awaitBetweenYields manual loop : %A" (manualEnumeration(awaitBetweenYields())).Result
printfn "delayOnly toListAsync : %A" (delayOnly() |> TaskSeq.toListAsync).Result
printfn "forLoopWithDelay toListAsync : %A" (forLoopWithDelay() |> TaskSeq.toListAsync).Result
printfn "noAwaits toListAsync : %A" (noAwaits() |> TaskSeq.toListAsync).Result
Output:
$ dotnet run -c Debug
awaitBetweenYields toListAsync : [1]
awaitBetweenYields manual loop : [1]
delayOnly toListAsync : [1]
forLoopWithDelay toListAsync : []
noAwaits toListAsync : [1; 2; 3]
$ dotnet run -c Release
awaitBetweenYields toListAsync : [1; 2; 3]
awaitBetweenYields manual loop : [1; 2; 3]
delayOnly toListAsync : [1; 2]
forLoopWithDelay toListAsync : [1; 2; 3]
noAwaits toListAsync : [1; 2; 3]
No exception is thrown; MoveNextAsync simply returns false after the first await.
Environment
- Windows 11 (10.0.26200), x64
- .NET SDK 10.0.400,
net10.0, FSharp.Core 10.1.400 (assembly 10.1.0.0, file version 10.104.26.38015)
- Also reproduces with .NET SDK 11.0.100-preview.6,
net11.0, FSharp.Core 11.0.0.0 (file version 11.1.126.36018)
- FSharp.Control.TaskSeq 1.1.1
What I could narrow down
- The
awaitBetweenYields result is deterministic (3 of 3 runs each: Debug [1], Release [1; 2; 3]). The for shape flips between [] and [1] across Debug runs, so a race looks likely.
- It is not the runtime or the dependencies:
bin/Debug and bin/Release contain byte-identical FSharp.Core.dll and FSharp.Control.TaskSeq.dll, the deps.json files are identical and the runtimeconfig.json files differ only by MetadataUpdater.IsSupported. Copying the Release-compiled Repro.dll into the Debug output folder works; copying the Debug-compiled Repro.dll into the Release output folder fails. So it depends on how the consuming assembly (the one containing the taskSeq { } block) is compiled.
- The full
fsc argument diff between the two configurations is only: Debug --define:DEBUG --optimize- --tailcalls- -g versus Release --define:RELEASE --optimize+.
- Building Release with the Debug flag set (
dotnet run -c Release -p:Optimize=false -p:Tailcalls=false -p:DebugSymbols=true -p:DebugType=portable -p:DefineConstants=DEBUG) reproduces the bug. Building Debug with the Release flag set (dotnet run -c Debug -p:Optimize=true -p:Tailcalls=true -p:DebugSymbols=false -p:DebugType=none -p:DefineConstants=RELEASE) makes it work.
- Flipping a single flag was not enough for me: Debug with
-p:Optimize=true -p:Tailcalls=true (still -g) fails, Release with -p:Optimize=false -p:Tailcalls=false (no -g) works, and Debug with -p:DebugSymbols=false -p:DebugType=none (still --optimize-) fails.
- The failing binaries carry
DebuggableAttribute(IsJITOptimizerDisabled = true); the working ones do not.
Happy to provide anything else that helps.
Summary
With
FSharp.Control.TaskSeq1.1.1, ataskSeqthat awaits between yields (do! Task.Yield()ordo! Task.Delay n) stops after the first await when the consuming project is compiled in the Debug configuration. The same code enumerates every item in Release.TaskSeq.toListAsync, a manualGetAsyncEnumerator/MoveNextAsyncloop and an ASP.NET Core response writer consuming theIAsyncEnumerableare all affected. AtaskSeqwithout awaits between yields is fine.Impact:
dotnet testbuilds Debug by default, so tests that stream through ataskSeqsilently observe only the first item. That is how I ran into it (an xunit test of a chunked HTTP writer that takes anIAsyncEnumerable).Repro
Repro.fsprojProgram.fsOutput:
No exception is thrown;
MoveNextAsyncsimply returnsfalseafter the first await.Environment
net10.0, FSharp.Core 10.1.400 (assembly 10.1.0.0, file version 10.104.26.38015)net11.0, FSharp.Core 11.0.0.0 (file version 11.1.126.36018)What I could narrow down
awaitBetweenYieldsresult is deterministic (3 of 3 runs each: Debug[1], Release[1; 2; 3]). Theforshape flips between[]and[1]across Debug runs, so a race looks likely.bin/Debugandbin/Releasecontain byte-identicalFSharp.Core.dllandFSharp.Control.TaskSeq.dll, thedeps.jsonfiles are identical and theruntimeconfig.jsonfiles differ only byMetadataUpdater.IsSupported. Copying the Release-compiledRepro.dllinto the Debug output folder works; copying the Debug-compiledRepro.dllinto the Release output folder fails. So it depends on how the consuming assembly (the one containing thetaskSeq { }block) is compiled.fscargument diff between the two configurations is only: Debug--define:DEBUG --optimize- --tailcalls- -gversus Release--define:RELEASE --optimize+.dotnet run -c Release -p:Optimize=false -p:Tailcalls=false -p:DebugSymbols=true -p:DebugType=portable -p:DefineConstants=DEBUG) reproduces the bug. Building Debug with the Release flag set (dotnet run -c Debug -p:Optimize=true -p:Tailcalls=true -p:DebugSymbols=false -p:DebugType=none -p:DefineConstants=RELEASE) makes it work.-p:Optimize=true -p:Tailcalls=true(still-g) fails, Release with-p:Optimize=false -p:Tailcalls=false(no-g) works, and Debug with-p:DebugSymbols=false -p:DebugType=none(still--optimize-) fails.DebuggableAttribute(IsJITOptimizerDisabled = true); the working ones do not.Happy to provide anything else that helps.