Skip to content

feat(Async): Add exception-unwrapping Await - #19785

Open
bartelink wants to merge 31 commits into
dotnet:mainfrom
bartelink:async-await
Open

feat(Async): Add exception-unwrapping Await#19785
bartelink wants to merge 31 commits into
dotnet:mainfrom
bartelink:async-await

Conversation

@bartelink

@bartelink bartelink commented May 21, 2026

Copy link
Copy Markdown

Implements Async.Await for Task, Task<'T>, ValueTask and ValueTask<'T> (aka a polished version of the
community AwaitTaskCorrect implementation). Includes a fallback SRTP based type augmentation that uses the GetAwaiter protocol to support custom waits a la C# await.

Key differentiation from Async.AwaitTask is that AggregateExceptions are unwrapped such that a try ... with <ExceptionType> -> will type-match correctly.

Key distinction from the canonical implementation (which derives from https://www.fssnip.net/7Rc/title/AsyncAwaitTaskCorrect) is that the implementation is intended to have 1:1 matching of all stacktrace preservation properties borne by AwaitTask (and continue to track that over time).

NOTE one key implementation decision is that this PR does NOT attempt to resolve #2127 so:

  1. if the computation workflow's CT is cancelled at the point where Await is invoked, normal cancellation semantics as per AwaitTask apply:
    • exception continuation is passed an OperationCanceledException
    • the Task in question's Result will go unobserved
  2. if a cancellation of the computation workflow via it's ambient cancellation token is triggered during the course of the Await, it will (like AwaitTask):
    • NOT abort and abandon the observation Task
    • instead, it will wait [as a C# await would] until such time as the Task completes (either successfully, with a fault, or via cancellation)

Checklist

  • Test cases added
  • Release notes entry updated
  • Add documentation cross-links and strongly-implied deprecation flagging conveying that:
    • AwaitTask will always yield an AggregateException
    • Await should be the used in preference for new code
    • Await can technically still propagate an AggregateException

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/FSharp.Core` docs/release-notes/.FSharp.Core/11.0.100.md

@bartelink
bartelink force-pushed the async-await branch 7 times, most recently from b2b2b6a to b710cc1 Compare May 21, 2026 14:58
@bartelink

bartelink commented May 21, 2026

Copy link
Copy Markdown
Author

@T-Gro if you and/or others can give this a quick scan please, I'd like to confirm:

  1. nobody else has work in flight (yes, a bit late for that!)
  2. the rough approach is viable
  3. the key impl decision is reasonable:
    • no attempt to get too clever and have the API react to Async cancellation by abandoning waits, i.e. if you have code that does not propagate Async.CancellationToken to a Task start, Await will hang just as AwaitTask
      • BUT this is OK as abandoning in-flight tasks and/or the controlled disposal of associated resources/compute would not be strictly better
      • It aligns with e.g. how Async.Parallel waits for correct teardown/completion of all in-flight executions before yielding a result and/or completing the honoring of cancellation
    • Best practice recommendation will instead be to use Async.StartTaskImmediate, which will
  4. a rough indication of whether I should leave the xmldoc as is, or attempt to complete the rough tasks I've laid out in the checklist in the OP

TL;DR the overall proposition

  1. Await is just AwaitTask with unwrapping, zero other semantic change. => A better default to use where you'd otherwise use AwaitTask
  2. Usage of AwaitTask and Await are both smells - can you be sure all Tasks that have been started were correctly wired into the computation tree's CT?
  3. In general, usage of Async.Await should be replaced with/migrated to Async.StartTaskImmediate, which surfaces the problem
  4. if there was an analyzer flagging Async.Await/AwaitTask -> Async.StartTaskImmediate migration opportunities, a closely related one would be flagging cases of task { flows that use let! and/or do! bindings against Async<'T>) rather than using Task.startAsyncImmediate (which forces passing a CT to Async.StartImmediateAsTask

CC @TheAngryByrd @gusty @njlr who have provided useful feedback/review on stuff in this space in recent times

@bartelink
bartelink marked this pull request as ready for review May 22, 2026 13:37
Copilot AI review requested due to automatic review settings May 22, 2026 13:37
@bartelink
bartelink requested a review from a team as a code owner May 22, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Async.Await API to FSharp.Core for Task/Task<'T> (and ValueTask/ValueTask<'T> on netstandard2.1) that unwraps “egregious” single-inner AggregateExceptions so exception matching works as expected, while aiming to preserve existing AwaitTask stacktrace behavior.

Changes:

  • Implement Async.Await in async.fs by sharing the existing AwaitTask continuation machinery and selectively unwrapping single-inner AggregateExceptions.
  • Expand unit tests to exercise both AwaitTask and Await behavior (including AggregateException cases) and add ValueTask coverage under #if NETSTANDARD2_1.
  • Update FSharp.Core surface area baseline (partially) and add a release-notes entry.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncType.fs Converts many tests to run against both AwaitTask and new Await; adds new behavior-focused tests for AggregateException unwrapping and ValueTask.
tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl Adds Async.Await entries for netstandard2.1 Debug surface area baseline.
src/FSharp.Core/async.fsi Updates AwaitTask XML docs and adds new Async.Await API docs (including ValueTask overloads under netstandard2.1).
src/FSharp.Core/async.fs Implements Async.Await and refactors task-await internals to optionally unwrap single-inner AggregateExceptions.
docs/release-notes/.FSharp.Core/11.0.100.md Adds release note entry for new Async.Await.

Comment thread src/FSharp.Core/async.fsi
Comment thread src/FSharp.Core/async.fs Outdated
@bartelink
bartelink force-pushed the async-await branch 2 times, most recently from 7aec92d to 9ab202c Compare May 22, 2026 14:03
@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label May 22, 2026
Comment thread src/FSharp.Core/async.fs Outdated
Comment thread src/FSharp.Core/async.fs Outdated
@majocha

majocha commented May 22, 2026

Copy link
Copy Markdown
Contributor

I'm thinking about naming. Async.Await name does not suggest that it applies to Tasks only. Since it is already 4 times overloaded, maybe it makes sense to add an overload working with any awaitable, to have parity with C#? This can be discussed and done separately from this PR, of course. (Also SRTP can be a can of worms).

@bartelink

Copy link
Copy Markdown
Author

maybe it makes sense to add an overload working with any awaitable, to have parity with C#? This can be discussed and done separately from this PR, of course. (Also SRTP can be a can of worms).

@majocha Yes, this was already part of the brief as per the comment from @dsyme.

I'd personally need to research what it would entail, so if you or anyone wants to contribute an impl, feel free to hang a PR off this one.

But bottom line I agree it would be good for the support for tasklike things to be done either as part of this PR or as a very fast follow so the world only needs to validate the overloading works out cleanly once.

@bartelink
bartelink force-pushed the async-await branch 2 times, most recently from a887f40 to 885c40f Compare May 23, 2026 11:11
@bartelink

Copy link
Copy Markdown
Author

I'd personally need to research what it would entail, so if you or anyone wants to contribute an impl, feel free to hang a PR off this one.

. @majocha Added a commit:

  • as demonstrated by the test, the GetResult() already unwraps, so it would seem to me that doing deeper integration with the internals of the specific-type overloads won't have any benefit
  • open question is whether all 4 overloads of the existing implementations are still earning their keep in terms of:
    • perf and allocations (Benchmark tests are probably the real answer to that)
    • extending platform reach (does the GetAwaiter protocol work for downlevel TFMs?)

@bartelink
bartelink force-pushed the async-await branch 2 times, most recently from 93badc9 to 50814f5 Compare May 25, 2026 12:03
@bartelink

Copy link
Copy Markdown
Author

@T-Gro Thanks for the thorough review, and especially for catching the erroneous UnsafeOnCompleted

Let me know if any of my doc changes overreach

One potential clone noted: #19785 (comment)

Please mark any threads as resolved; I'll address any follow-ups you desire later and/or tomorrow

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — every review thread is addressed.

Only thing keeping CI red is a compile error in the new regression test:

// AsyncType.fs:765-766
Assert.True(completion.Wait(TimeSpan.FromSeconds 5L), ...)
Assert.True(t.Wait(TimeSpan.FromSeconds 5L), ...)

TimeSpan.FromSeconds takes float (net472/ns2.0 have no long overload) → FS0001. 5L5.0 and it goes green.

@T-Gro

T-Gro commented Aug 4, 2026

Copy link
Copy Markdown
Member

🤖🕵️
@bartelink Re the FSharpPlus CI failure: not a cancellation bug on either side. Your intrinsic Async.Await mirrors Async.AwaitTask (canceled → econt/TaskCanceledException, async.fs:1218); FSharpPlus's extension Async.Await deliberately routes canceled → ccont, which testAsyncZip asserts. The new intrinsic silently shadows that extension on recompile, flipping the semantics — a source-compat break. @gusty should be made aware that a newly added Async member takes over library extensions of the same name.

@bartelink

Copy link
Copy Markdown
Author

@T-Gro do we feel we need to find a way to make CI green in the context of this PR, or should I consider it Somebody Else's Problem?

@T-Gro

T-Gro commented Aug 4, 2026

Copy link
Copy Markdown
Member

We shall make it green, otherwise it will be read on every single PR (and if we ignore it there, we might miss real product bugs).

It anyway surfaces a problem FSharpPlus users will experience - same API name, but different take on how it works in case of cancellation.

(the FsharpPlus version is chosen by commit has - so this PR could e.g. target a commit hash of FSharpPlus which is not in main, and has the overload renamed/removed/...)

@bartelink

Copy link
Copy Markdown
Author

@gusty would you/FSharpPlus take a PR to align the Await impl there with fsharp/fslang-suggestions#840, calling ec (TaskCanceledException()) instead of cc ? All other impls I'm aware of follow that implementation, for better or worse...

@gusty

gusty commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@gusty would you/FSharpPlus take a PR to align the Await impl there with fsharp/fslang-suggestions#840, calling ec (TaskCanceledException()) instead of cc ? All other impls I'm aware of follow that implementation, for better or worse...

Thanks @bartelink
Issue created fsprojects/FSharpPlus#676

@T-Gro

T-Gro commented Aug 10, 2026

Copy link
Copy Markdown
Member

@bartelink The FSharpPlus side is ready: fsprojects/FSharpPlus#677 aligns Async.Await to raise TaskCanceledException through the exception continuation, per fsharp/fslang-suggestions#840. The diff is minimal — just the Await change plus the two tests it touches.

To exercise it from here, point the FsharpPlus_NET10_* legs in azure-pipelines-PR.yml at 747e3f16fe50c925185d39beae193db71935b37f (currently 2648efe). Pinning the regression matrix to that branch commit rather than F#+ master is fine and expected — it's the head of #677 and won't move unless I repush.

@github-actions github-actions Bot added ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure labels Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Compiler-Output
Affects-Build-Infra: modifies azure-pipelines-PR.yml
Affects-Compiler-Output: adds new FSharp.Core public API (async.fs/fsi)

Generated by PR Tooling Safety Check · opus46 5.7M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Async.AwaitTask does not cancel on workflow cancellation

6 participants