Skip to content

Runtime async via intrinsic - #20235

Draft
majocha wants to merge 11 commits into
dotnet:mainfrom
majocha:runtime-async-intrinsic
Draft

Runtime async via intrinsic #20235
majocha wants to merge 11 commits into
dotnet:mainfrom
majocha:runtime-async-intrinsic

Conversation

@majocha

@majocha majocha commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Yet another proof of concept (see also #19449)

The experiment here is to explore viability of working CE builders with just a minimal compiler support.

consider

__runtimeAsync: 't -> Task<'t>

intrinsic, which when used as a member or function body, promotes it to managed async.

We can have a fully inlining builder which wraps the code in it's Run method:

member inline _.Run([<InlineIfLambda>] code) = __runtimeAsync (code())

Because resumption is handled by the runtime, the builder is effectively just a sync builder with

member inline _.Bind(task, [<InlineIfLambda>] continuation) = AsyncHelpers.Await task |> continuation

There are more concerns than suspension / resumption that the runtime apparently does not handle and they would have to be covered either by the compiler or the builder implementations:

  • awaits in EH blocks (IAsyncDisposable) - this has a PoC in the tests here.

  • byref locals and execution context / sync context, see C# docs below.

runtime spec :
Runtime-async specification

interesting docs on C# implementation

@github-actions

github-actions Bot commented Aug 8, 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
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
`src/Compiler/Facilities/LanguageFeatures.fsi` docs/release-notes/.Language/preview.md

majocha and others added 5 commits August 8, 2026 09:20
…c; add Language preview release notes

The features dictionary lost ImplicitDIMCoverage, MethodOverloadsCache,
ErrorOnMissingSignatureAttribute, DirectDelegateConstruction,
AccessProtectedBaseFieldFromClosure and RecordSpreads entries, causing
54 CI test failures ('Unable to find feature' internal errors and
preview features not enabled).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Microsoft.FSharp.Core.CompilerServices.SetStateMachineMethodImpl`1[TData]: Void Invoke(Microsoft.FSharp.Core.CompilerServices.ResumableStateMachine`1[TData] ByRef, System.Runtime.CompilerServices.IAsyncStateMachine)
Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: Boolean __useResumableCode[T]()
Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: Microsoft.FSharp.Core.FSharpOption`1[System.Int32] __resumableEntry()
Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: System.Threading.Tasks.Task`1[T] __runtimeAsync[T](T)

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.

So this would be the only public addition and initial usage would come via library provided CE ?
(I agree its better to keep it out of dotnet/fsharp to iterate faster, and only move once stabilized 👍 )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, I guess we cannot just replace current task builder implementation while the library is ns21. (not sure about it).

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.

From agility perspective, I feel like its better to start with it outside of dotnet/fsharp.
Btw. this intrinsic might be a good candidate for - #20229 .

That eliminates a class of issues of people targeting it with an unsupported runtime.

With #20229 , the implementation of the library addition (now just the slim intrinsic) might also be doing calls to the BCL apis for AsyncHelpers (or better leave those to the CE implementation ?)

"__stateMachine should always be guarded by __useResumableCode and only used in valid state machine implementations"

[<MethodImpl(MethodImplOptions.NoInlining)>]
let __runtimeAsync<'T> (value: 'T) : Task<'T> =

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.

Why a fail and not a retype?

I also wonder about the "older compiler, newer Fsharp.Core" consequences here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Retype as in different nominal type than actual resulting type? The idea here is to have it typecheck correctly with no extra compiler work. By the spec we need the resulting method to actually return 't, but have a Task<'t> nominal return type, so this covers it. During typecheck we got Task<'t> just from the signature here. Optimizer erases __runtimeAsync and we emit async method which returns 't just like the runtime expects.

As for FSharp.Core / compiler mismatch, this is, kind of like __stateMachine, an "expert" feature for authoring CE builders or low level runtime async methods. The fail with unsupported compiler is same behavior as __stateMachine.

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.

Got it, this is just for the final return/Run.

Maybe the name could indicate it?
Like __return_from_runtimeAsync ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's the intent . more ideas: __asyncReturn, __runtimeAsyncReturn

@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.

🕵️🤖 A couple more notes without inline anchors:

  • Non-generic Task/ValueTask returns are silently unsupported — worth a diagnostic so it errors instead of miscompiling (docs L31).
  • ILVerify can't verify async-returning methods yet, so this codegen stays unverified in CI — known gap.

Backed by a small runtime-async edge-case suite (runtime behavior + emitted IL) driven through a test-only runtimeTask CE.

.WithSynchronized(hasSynchronizedImplFlag)
.WithNoInlining(hasNoInliningFlag)
.WithAggressiveInlining(hasAggressiveInliningImplFlag)
.WithAsync(isRuntimeAsync)

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.

🕵️🤖 With the method marked async here, remember to also disallow .tail in the body — ilverify flags TailRetType on a tail-call after Await.

F# test
[<Fact>]
let ``runtime async currently emits a forbidden tail prefix (C1)`` () =
    compileDirect "let f (g: int -> int) (x: int) : Task<int> = StateMachineHelpers.__runtimeAsync (AsyncHelpers.Await(Task.Delay(1)); g x)"
    |> verifyILContains [ "tail." ]   // .tail is emitted today; ilverify -> TailRetType
    |> shouldSucceed

Comment thread docs/runtime-async.md

* `tail.` and `localloc` are forbidden.
* suspension cannot occur inside exception-handling regions. Awaiting in a
`try` body now works on the current runtime; awaiting inside a `finally`

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.

🕵️🤖 await in finally/catch compiles with no diagnostic and fail-fasts at runtime (0xC0000409) — wants a real error eventually.

F# test
[<Theory>]
[<InlineData("await-in-finally",
    "let f () : Task<int> = StateMachineHelpers.__runtimeAsync (try 1 finally AsyncHelpers.Await(Task.Delay(1)))")>]   // runtime: fail-fast 0xC0000409 / SIGSEGV
[<InlineData("await-in-catch",
    "let f () : Task<int> = StateMachineHelpers.__runtimeAsync (try failwith \"boom\" with _ -> AsyncHelpers.Await(Task.Delay(1)); 7)")>] // runtime: crash
let ``contract-forbidden suspension pattern compiles with no diagnostic`` (_label: string) (body: string) =
    compileDirect body |> shouldSucceed   // NO diagnostic today

Comment thread docs/runtime-async.md
`try`, then restores a pending exception. This makes `use` on an
`IAsyncDisposable` work under runtime async (`testUsingAsyncDisposableSync`
executes).
* Byref, byref-like, and pinned locals cannot be preserved across suspension.

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.

🕵️🤖 Same undiagnosed gap for ref-struct/byref across a suspension (→ IndexOutOfRange at runtime); C# gives CS4007/CS1988.

F# test
[<Theory>]
[<InlineData("refstruct-across-suspension",
    "let f () : Task<int> = StateMachineHelpers.__runtimeAsync (let data = [| 10; 20; 30 |] in let span = ReadOnlySpan<int>(data) in AsyncHelpers.Await(Task.Delay(1)); span[0] + span[1] + span[2])")>] // runtime: IndexOutOfRange (C14)
[<InlineData("byref-param-across-suspension",
    "let f (x: byref<int>) : Task<int> = StateMachineHelpers.__runtimeAsync (AsyncHelpers.Await(Task.Delay(1)); x)")>] // C# gives CS1988
let ``contract-forbidden suspension pattern compiles with no diagnostic`` (_label: string) (body: string) =
    compileDirect body |> shouldSucceed   // NO diagnostic today

@majocha

majocha commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Another thing to think through is inlining. Currently there are no checks at all for use of suspending AsyncHelpers members outside of an async method. According to spec, this is illegal but the idea is that any AsyncHelpers.Await calls should be contained by or inlined into the resulting async method (see the sample runtimeTask builder in the tests here). It seems to get an efficient single method from a CE the builder needs to declare every method inline and make use of InlineIfLambda.

Currently it is up to the "expert" user to not misuse AsyncHelpers. Ideally the compiler should check for any such illegal calls only after inlining.

This is still a sketch, but it successfully compiles runtimeTask builder. The builder passes ported Tasks.fs tests, which is promising.

@T-Gro

T-Gro commented Aug 14, 2026

Copy link
Copy Markdown
Member

Ideally the compiler should check for any such illegal calls only after inlining.

We could have a notion of PostIlxGen checks.
Agree it must run after all optimizations.

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

Labels

None yet

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

2 participants