Skip to content

Runtime Async Feature#19449

Draft
TheAngryByrd wants to merge 38 commits intodotnet:mainfrom
TheAngryByrd:19056-methodimpl-async-2
Draft

Runtime Async Feature#19449
TheAngryByrd wants to merge 38 commits intodotnet:mainfrom
TheAngryByrd:19056-methodimpl-async-2

Conversation

@TheAngryByrd
Copy link
Contributor

Description

Fixes #19056

This was 99% written by Oh-My-Opencode planner/executor with Opus 4.6. I'm not claiming it's good but it did make it work.

docs/samples/runtime-async-library/README.md is where to look at how to run the sample library.

Checklist

  • Test cases added

  • Performance benchmarks added in case of performance changes

  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

    Release notes files:

    • If anything under src/Compiler has been changed, please make sure to make an entry in docs/release-notes/.FSharp.Compiler.Service/<version>.md, where <version> is usually "highest" one, e.g. 42.8.200
    • If language feature was added (i.e. LanguageFeatures.fsi was changed), please add it to docs/release-notes/.Language/preview.md
    • If a change to FSharp.Core was made, please make sure to edit docs/release-notes/.FSharp.Core/<version>.md where version is "highest" one, e.g. 8.0.200.

    Information about the release notes entries format can be found in the documentation.
    Example:

    If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

TheAngryByrd and others added 30 commits February 27, 2026 23:49
…dDef.IsAsync

- Add RuntimeAsync DU case to LanguageFeature, mapped to previewVersion
- Add ILMethodDef.IsAsync property and WithAsync method using enum<MethodImplAttributes>(0x2000)
- Add 'async' keyword output in ilprint.fs goutput_mbody
- Add error messages 3884-3887 to FSComp.txt (tcRuntimeAsync*)
- Bump MicrosoftNETCoreILDAsmVersion to 10.0.0 in eng/Versions.props
- Add Task/ValueTask type refs to TcGlobals (system_Task_tcref, system_GenericTask_tcref, system_ValueTask_tcref, system_GenericValueTask_tcref)
- Update all 13 XLF translation files
- Add hasAsyncImplFlag extraction in ComputeMethodImplAttribs (bit 0x2000)
- Extend return tuple to 6-tuple with hasAsyncImplFlag
- Update tuple destructuring in GenMethodForBinding and GenAbstractBinding
- Add .WithAsync(hasAsyncImplFlag) to method def builder chain in both paths
- Add HasMethodImplAsyncAttribute helper (detects 0x2000 flag)
- Add HasMethodImplSynchronizedAttribute helper (detects 0x20 flag)
- Add IsTaskLikeType helper using TcGlobals Task/ValueTask type refs
- Add validation in TcNormalizedBinding gated behind LanguageFeature.RuntimeAsync:
  - Error 3885 if async+synchronized combo
  - Error 3884 if async method doesn't return Task/ValueTask
  - Error 3886 if async method returns byref
- Add isRuntimeFeatureAsyncSupported lazy check in InfoReader.fs
- Wire into IsLanguageFeatureRuntimeSupported for LanguageFeature.RuntimeAsync
- Add error 3887 (tcRuntimeAsyncNotSupported) in CheckExpressions.fs validation
  when runtime doesn't support RuntimeFeature.Async (.NET 10+)
- Add ExprContainsAsyncHelpersAwaitCall function that walks TAST expression tree
- Detects calls to System.Runtime.CompilerServices.AsyncHelpers.Await (any overload)
- Handles all Expr forms: Let, LetRec, Sequential, Lambda, App, Match, Obj, etc.
- In GenMethodForBinding: hasAsyncImplFlag = fromAttr || ExprContainsAsyncHelpersAwaitCall body
- Propagates async flag through inlined CE methods
- Add UnwrapTaskLikeType helper: extracts T from Task<T>/ValueTask<T>, unit from Task/ValueTask
- In TcNormalizedBinding: when method has MethodImpl(0x2000) and returns Task<T>,
  body is type-checked against T (not Task<T>)
- Method's declared return type in IL remains Task<T> unchanged
- Runtime handles wrapping T -> Task<T> for the caller
- Create src/FSharp.Core/runtimeAsync.fs with RuntimeAsyncAttribute
- Attribute available on all TFMs (netstandard2.0, netstandard2.1)
- Meaningful on .NET 10.0+ where runtime-async is supported
- Add file to FSharp.Core.fsproj compilation order (before tasks.fsi)
- Create src/FSharp.Core/runtimeTasks.fs with RuntimeTaskBuilder
- All builder members are inline (no state machine generated)
- Run method has [<MethodImplAttribute(enum<MethodImplOptions> 0x2000)>]
- RuntimeTaskBuilderUnsafe.cast bridges T -> Task<T> type mismatch
- runtimeTask CE instance exposed via [<AutoOpen>] module
- Entire builder gated with #if NET10_0_OR_GREATER (AsyncHelpers only on .NET 10+)
- Add file to FSharp.Core.fsproj after runtimeAsync.fs
- Add RuntimeAsync IL baseline tests (verify 'cil managed async' in IL output)
- Add RuntimeAsync validation error tests (async+synchronized, non-Task return, langversion gating)
- Add checkLanguageFeatureAndRecover for proper langversion:preview gating
- Remove tcRuntimeAsyncNotSupported check (runtime support check)
- Fix FSharp.Core.fsproj: revert net10.0 TFM (breaks bootstrap build)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… in CheckExpressions

Strip SynExpr.Typed wrapper, use fresh bodyExprTy, and pre-unify overallPatTy with Task<T> before type inference to avoid type mismatch errors.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… keyword positioning in IL

IlxGen.fs: discard unit value before ret for non-generic Task/ValueTask return types. ilprint.fs: fix async keyword positioning in IL output.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…oral tests

Compiler.fs: add runNewProcess and compileExeAndRunNewProcess helpers for out-of-process test execution. MethodImplAttribute.fs: update behavioral tests to use compileExeAndRunNewProcess and set DOTNET_RuntimeAsync env var in host process.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
- Add DOTNET_RuntimeAsync=1 to TestFramework.executeProcess so child
  processes inherit the runtime-async feature flag
- Fix IlxGen.fs: do not propagate async flag from NoDynamicInvocation
  methods (their bodies are replaced with 'throw' at runtime, so the
  async flag would cause CLR to reject the type)
…impl detail

- RuntimeTaskBuilder.Run is the ONLY method with [<MethodImplAttribute(0x2000)>]
- Consumer API functions need NO attribute (addFromTaskAndValueTask, etc.)
- Delay returns thunk (unit -> 'T), Run has inline + 0x2000 + cast -> Task<'T>
- Fix ExprContainsAsyncHelpersAwaitCall to stop at lambda boundaries,
  preventing double-wrapping when consumer calls Run (both would be async)
- CheckExpressions.fs: skip special async handling for inline methods with 0x2000
- PostInferenceChecks.fs: allow InlineIfLambda on runtime-async method params

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…nalysis

Update ExprContainsAsyncHelpersAwaitCall (IlxGen) and exprContainsAsyncHelpersAwait (Optimizer) to also match AwaitAwaiter and UnsafeAwaitAwaiter method names on AsyncHelpers, not just Await. This ensures functions using the generic awaitable path (e.g. Task.Yield via UnsafeAwaitAwaiter) are correctly detected for cil managed async flag propagation and cross-module anti-inlining.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ield support

RuntimeTaskBuilder: add intrinsic Bind overloads for ConfiguredTaskAwaitable, ConfiguredTaskAwaitable<T>, ConfiguredValueTaskAwaitable, ConfiguredValueTaskAwaitable<T> using optimized AsyncHelpers.Await. Add generic SRTP extension Bind for any awaitable with GetAwaiter() via AsyncHelpers.UnsafeAwaitAwaiter — enables Task.Yield() and custom awaitables.

Api.fs: replace Task.Delay(0) with real Task.Yield() in taskDelayYieldAndRun, add configureAwaitExample (.ConfigureAwait(false)), add inlineNestedRuntimeTask (nesting via helper functions).

Program.fs: call and print new examples (11 total, all verified passing).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…amples

Reflect 11 examples (up from 9), generic SRTP Bind for any awaitable, ConfiguredTaskAwaitable Bind overloads, Task.Yield() support, updated expected output and IL verification notes.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… RuntimeAsyncAttribute

Task 4: IlxGen.fs - hasAsyncImplFlag now requires enclosing entity to have [<RuntimeAsync>].
Both the explicit [<MethodImpl(0x2000)>] path and the body-analysis (ExprContainsAsyncHelpersAwaitCall)
path are gated behind enclosingEntityHasRuntimeAsync (via v.TryDeclaringEntity).

Task 5: Optimizer.fs - cut function now only blocks inlining of AsyncHelpers.Await-containing
lambdas when the enclosing type has [<RuntimeAsync>]. Module-level functions (MemberInfo = None)
are allowed to inline freely.
…ctions

Remove the enclosingHasRuntimeAsync guard from Optimizer.fs cut function so
that ALL functions containing AsyncHelpers.Await calls get UnknownValue (no
cross-module inlining), not just those in RuntimeAsync-annotated types.

Previously, consumer functions in plain modules (e.g. Api.consumeOlderTaskCE,
Api.taskDelayYieldAndRun) were being inlined into main by the optimizer because
their enclosing module lacked [<RuntimeAsync>]. This caused NullReferenceException
at runtime because the cast trick only works inside 'cil managed async' methods.

Also:
- Fix TcGlobals.fs: attrib_RuntimeAsyncAttribute now uses mk_MFControl_attrib
  (RuntimeAsyncAttribute lives in Microsoft.FSharp.Control, not Core)
- Fix TcGlobals.fs: correct indentation on attrib_RuntimeAsyncAttribute line
- Fix IlxGen.fs: remove enclosingEntityHasRuntimeAsync guard from body-analysis
  path so consumer functions in plain modules get 'cil managed async'
- Fix IlxGen.fs: set withinSEH=true for hasAsyncImplFlagEarly methods to
  suppress tail calls (required for 'cil managed async' suspension to work)
- Fix CheckExpressions.fs: use NewTyparsOK + shared domain types for Run<T>
  so generic return types like Task<'T> type-check correctly
- Update RuntimeTaskBuilder.fs: use [<RuntimeAsync>] on builder type (implicit
  NoDynamicInvocation), rename module to RuntimeTaskBuilderHelpers, remove
  explicit [<NoDynamicInvocation>] from individual members
- Update sample .fsproj files: use repo-built FSharp.Core explicitly
…ecture

- RuntimeTasks.fs: update preamble to use [<RuntimeAsync; Sealed>] on builder,
  remove explicit [<NoDynamicInvocation>] from Bind members (implicit from [<RuntimeAsync>]),
  Run now returns Task<'T> with Await sentinel + cast, remove [<MethodImplAttribute(0x2000)>]
  from all consumer functions, add 2 new tests (inline-nested via separate functions,
  no MethodImpl needed)
- MethodImplAttribute.fs: add 3 new tests for [<RuntimeAsync>] builder behavior:
  implicit NoDynamicInvocation (IL body replaced with throw), consumer gets cil managed async
  without MethodImpl attribute, behavioral test (consumer executes correctly)
- README.md: update Key Design section to show [<RuntimeAsync; Sealed>], RuntimeTaskBuilderHelpers,
  no explicit NoDynamicInvocation; add RuntimeAsync Attribute subsection explaining design entry point
… RuntimeAsyncAttribute

Gate both the IlxGen.fs body-analysis 'cil managed async' path and the Optimizer.fs
anti-inlining guard behind [<RuntimeAsync>] on the enclosing entity.

IlxGen.fs: Add enclosingEntityHasRuntimeAsync check using v.TryDeclaringEntity.
Both hasAsyncImplFlagFromAttr and body-analysis paths now require RuntimeAsync on
the enclosing entity. This enables the non-inline Run architecture: only Run (in
RuntimeTaskBuilder with [<RuntimeAsync>]) gets 'cil managed async', not consumer
functions in plain modules.

Optimizer.fs: cut function now checks vref.MemberInfo for RuntimeAsync before
returning UnknownValue. Functions in plain modules (without [<RuntimeAsync>]) can
be cross-module inlined normally.

MethodImplAttribute.fs: Add [<RuntimeAsync>] to module TestModule in all tests
that use explicit [<MethodImpl(0x2000)>] or AsyncHelpers.Await directly, since
the gating now requires RuntimeAsync on the enclosing entity.
…EntityHasRuntimeAsync gate from body-analysis, block all Await-containing functions from cross-module inlining, revert Run to cast(f())
…inline Run with Await(f()), true inline-nested CEs

- Add cloIsAsync field to IlxClosureInfo (ilx.fs/ilx.fsi): marks closure Invoke as cil managed async
- Add ilBodyContainsAsyncHelpersAwait in IlxGen.fs: detects Await calls in closure IL body
- Add isILTypeTaskLike in EraseClosures.fs: guards cil managed async to Task-returning closures
- EraseClosures.fs: emit Invoke as cil managed async when cloIsAsync && isILTypeTaskLike
- RuntimeTaskBuilder.fs: Delay returns unit->Task<T> (closure is cil managed async via cloIsAsync)
  Run is non-inline with [<MethodImplAttribute(0x2000)>], takes unit->Task<T>, does AsyncHelpers.Await(f())
- Api.fs: add trueInlineNestedRuntimeTask example (12th example, inline-nested CEs work)
- Program.fs: call trueInlineNestedRuntimeTask, print result
- README.md: update to describe non-inline Run + async closures architecture, add Fix 3
…ey Design section, add trailing newline

- IlxGen.fs: fix 1 extra leading space on .WithAsync(hasAsyncImplFlag) in GenMethodForBinding and GenAbstractBinding
- README.md: update Key Design code block to show correct Delay (unit->Task<T> with sentinel+cast) and Run (unit->Task<T> with Await(f()))
- MethodImplAttribute.fs: add trailing newline at end of file
@github-actions
Copy link
Contributor

❗ Release notes required

@TheAngryByrd,

Caution

No release notes found for the changed paths (see table below).

Please make sure to add an entry with an informative description of the change as well as link to this pull request, issue and language suggestion if applicable. Release notes for this repository are based on Keep A Changelog format.

The following format is recommended for this repository:

* <Informative description>. ([PR #XXXXX](https://github.com/dotnet/fsharp/pull/XXXXX))

See examples in the files, listed in the table below or in th full documentation at https://fsharp.github.io/fsharp-compiler-docs/release-notes/About.html.

If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

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

Change path Release notes path Description
src/FSharp.Core docs/release-notes/.FSharp.Core/11.0.100.md No release notes found or release notes format is not correct
src/Compiler docs/release-notes/.FSharp.Compiler.Service/11.0.100.md No release notes found or release notes format is not correct
LanguageFeatures.fsi docs/release-notes/.Language/preview.md No release notes found or release notes format is not correct

Comment on lines +59 to +66
/// Delay creates a closure that wraps the CE body.
/// [<InlineIfLambda>] on f inlines the CE body into the Delay closure, so there is only ONE
/// closure containing all AsyncHelpers.Await calls. The compiler automatically injects a
/// sentinel to ensure the Delay closure is always 'cil managed async', even when the CE body
/// has no let!/do! bindings. The compiler also handles the 'T → Task<'T> bridging automatically
/// for [<RuntimeAsync>] builders, so no cast is needed.
member inline _.Delay([<InlineIfLambda>] f: unit -> 'T) : unit -> Task<'T> =
fun () -> f()
Copy link
Contributor

Choose a reason for hiding this comment

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

This is maybe the one thing that makes me uneasy.
This introduces a special case of something like a type-driven return type conversion.
I wonder if it would be possible to have a special function instead, let's say:

val __asyncReturn: 't -> Task<'t>

compiled to no-op or whatever dotnet expects for the async runtime feature.

Copy link
Member

Choose a reason for hiding this comment

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

As public API in FSharp.Core ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess so, similar to current StateMachineHelpers. Although I gave no thought how this should behave with ns20.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like very much that the feature resides at the level of computation expressions.
IMO it should be possible to write a working low-level runtime async method using dotnet's AsyncHelpers and some FSharp.Core helpers. Ideally runtime async CEs should compile to such code without any special treatment in CheckComputationExpressions.fs. I don't know if it's possible.


/// Check if the builder type has [<RuntimeAsync>] attribute.
/// Used to determine whether to inject the AsyncHelpers.Await sentinel into Delay closures.
let builderHasRuntimeAsync ceenv =
Copy link
Member

Choose a reason for hiding this comment

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

IMO worth updating from main (this is freshly added) and doing a fast enum check:
#19394

// NOTE: This feature is gated by RuntimeAsync language feature in preview
// Emit a feature-gate error if the attribute is used without --langversion:preview
if HasMethodImplAsyncAttribute g valAttribs then
checkLanguageFeatureAndRecover g.langVersion LanguageFeature.RuntimeAsync mBinding
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Missing runtime feature check — only language version is gated, not RuntimeFeature.Async

checkLanguageFeatureAndRecover is called here but checkLanguageFeatureRuntimeAndRecover is never called for RuntimeAsync.

InfoReader.fs:860 correctly defines isRuntimeFeatureAsyncSupported and maps RuntimeAsync to it at line 927, but this check is never invoked anywhere in the compiler.

A user targeting netstandard2.0 with --langversion:preview can use [<MethodImpl(0x2000)>] without a compile-time error. The method gets the 0x2000 IL flag but the runtime doesn't understand it — runtime failure instead of compile-time error.

Compare with DefaultInterfaceMemberConsumption and InterfacesWithAbstractStaticMembers which DO call checkLanguageFeatureRuntimeAndRecover (ConstraintSolver.fs:3684, MethodCalls.fs:1260, MethodOverrides.fs:369,985).

Suggested fix — add after this line:

checkLanguageFeatureRuntimeAndRecover cenv.infoReader LanguageFeature.RuntimeAsync mBinding

check body
| Expr.Link eref ->
check eref.Value
| Expr.DebugPoint (_, innerExpr) ->
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] ExprContainsAsyncHelpersAwaitCall false-positive for Expr.Obj overrides

This function walks into Expr.Obj override bodies (the overrides and iimpls lists), but object expression overrides compile as separate IL methods via GenObjectExprMethod. They are NOT part of the enclosing method's IL body — the enclosing method just emits I_newobj.

If an Await call appears in an object expression override, the enclosing method would incorrectly get 0x2000. This is inconsistent with Expr.Lambda which correctly returns false at line ~9284 ("Lambda bodies become separate closure classes").

Additionally, the overrides themselves never get body-analysis 0x2000 (they aren't Val bindings processed by GenMethodForBinding), so we'd get a double fault: wrong method marked, correct method unmarked.

Suggested fix:

| Expr.Obj (_, _, _, basecall, _, _, _) ->
    check basecall  // only basecall is part of the enclosing method's IL

// preventing suspension. Setting withinSEH=true reuses the existing tail-call suppression
// mechanism without affecting self-recursive branch calls (which use a separate code path).
let eenvForMeth =
if hasAsyncImplFlagEarly then { eenvForMeth with withinSEH = true }
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Architectural gap: tail-call suppression not applied for body-analysis async methods

hasAsyncImplFlagEarly (line ~9444) only detects explicit [<MethodImpl(0x2000)>], so eenvForMeth.withinSEH stays false for methods that get 0x2000 via body analysis (ExprContainsAsyncHelpersAwaitCall at line ~9594). Since the IL body is generated before body analysis runs, CanTailcall (line 4533: not withinSEH) may return Tailcall for calls in tail position.

Validated that AsyncHelpers.Await calls are independently protected by makesNoCriticalTailcalls=true (line 5462 for non-virtual IL calls), so the suspension points themselves won't get tail. prefix. Only F# function calls after all Await points could theoretically get tail. — low practical risk with current builders (sample library Run is non-inline).

Suggested defense-in-depth fix — hoist the body-analysis check before IL codegen:

let bodyContainsAwait =
    not hasNoDynamicInvocation && returnsTaskLikeType
    && ExprContainsAsyncHelpersAwaitCall body
let eenvForMeth =
    if hasAsyncImplFlagEarly || bodyContainsAwait then
        { eenvForMeth with withinSEH = true }
    else eenvForMeth

|| tyconRefEq g (tcrefOfAppTy g returnTy) g.system_GenericTask_tcref
|| tyconRefEq g (tcrefOfAppTy g returnTy) g.system_ValueTask_tcref
|| tyconRefEq g (tcrefOfAppTy g returnTy) g.system_GenericValueTask_tcref)) then
mkWoNullAppTy g.system_GenericTask_tcref [returnTy]
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Closure return type fallback always wraps as Task<T>, never ValueTask<T>

This fallback path uses mkWoNullAppTy g.system_GenericTask_tcref [returnTy] — always Task<T>, regardless of the builder's actual return type. A builder using ValueTask<T> would get the wrong closure base class.

The primary path (declaredRetTyOpt at lines ~7110-7123) correctly preserves the original Task/ValueTask type, so this only fires when declaredRetTyOpt is None AND the body has Await calls — a rare edge case. But worth a // TODO or handling ValueTask<T> for completeness.


/// Create the sentinel expression: AsyncHelpers.Await(ValueTask.CompletedTask)
/// This is a no-op at runtime but its IL presence forces cloIsAsync = true for the enclosing closure.
let mkRuntimeAsyncSentinelExpr (m: range) =
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Sentinel adds real runtime overhead; consider setting cloIsAsync directly in IlxGen

AsyncHelpers.Await(ValueTask.CompletedTask) is described as a "no-op at runtime" but it IS a real method call — ValueTask.CompletedTask creates a struct, Await checks IsCompleted, returns. In a tight while loop each iteration pays this cost. The optimizer marks TOp.ILCall as having side effects, so it cannot eliminate the sentinel even when redundant.

An alternative approach: set cloIsAsync = true directly in IlxGen.GenClosureTypeDefs based on whether the enclosing CE builder has [<RuntimeAsync>], without injecting any sentinel. The typed tree flows through IlxGen with full type info — the builder attribute is accessible there.

If keeping the sentinel approach, consider at minimum suppressing the debug point (see next comment) and documenting the per-invocation cost.

let wrapWithRuntimeAsyncSentinel (bodyExpr: SynExpr) =
let m = bodyExpr.Range.MakeSynthetic()

SynExpr.Sequential(
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Sentinel debug point not suppressed — debugger may stop on invisible no-op

SynExpr.Sequential here uses DebugPointAtSequential.SuppressNeither. The debug infrastructure may emit a sequence point for the sentinel. A user stepping through code could land on the invisible AsyncHelpers.Await(ValueTask.CompletedTask).

Compare with line ~1537 where the WhileBang internal rewrite uses SuppressBoth for synthetic sequential expressions.

Use SuppressBoth or at minimum SuppressStmt to suppress the sentinel half.


/// Check if the builder type has [<RuntimeAsync>] attribute.
/// Used to determine whether to inject the AsyncHelpers.Await sentinel into Delay closures.
let builderHasRuntimeAsync ceenv =
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] builderHasRuntimeAsync called 6 times per CE without caching

This function does tryTcrefOfAppTy + TryFindFSharpAttribute on every call, and is invoked at 6 separate Delay injection points within a single CE translation. Since ceenv.builderTy never changes during CE translation, the result is invariant for the entire CE.

Consider caching the result in ceenv at construction time:

type CompExprTranslationEnv = {
    ...
    isRuntimeAsync: bool
}

This eliminates 5 redundant attribute lookups per CE.

// With NewTyparsOK, a fresh type parameter 'T2 is created and later unified with
// the actual 'T from overallPatTy via UnifyTypes, giving the correct result.
// Use DiscardErrorsLogger to suppress any diagnostic errors from TcTypeOrMeasure.
let retTyOpt =
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] DiscardErrorsLogger silences all diagnostics during return type re-resolution

TcTypeOrMeasure is called with DiscardErrorsLogger, which suppresses ALL diagnostics — not just the expected "unknown type parameter" errors. If a genuine error exists in the return type annotation (e.g. misspelled type name), it would be swallowed here.

Because the SynExpr.Typed wrapper is stripped by stripTypedFromInnermostLambda, the annotation is never re-checked via TcExprTypeAnnotated. For non-recursive bindings where the Val type starts as a fresh inference variable, this creates a diagnostic gap.

Consider replacing with a targeted catch of the specific recoverable exception, or at minimum adding a comment documenting why discarding all errors is safe in this position.

// Strip the SynExpr.Typed wrapper to avoid type mismatch from the return type annotation.
// Strip the function type from overallPatTy to get the return type.
// Then unwrap if task-like (e.g., Task<'T> -> 'T). Build body type as unit -> 'T.
let _, declaredRetTy = stripFunTy g overallPatTy
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Fallback path creates disconnected domain types

When TcTypeOrMeasure fails (retTyOpt = None), this fallback creates NEW inference types via NewInferenceType g that are disconnected from overallPatTy. Unlike the primary path (lines ~11402-11408) which creates shared domainTys, unifies them with overallPatTy via UnifyTypes, then reuses them in bodyTy — this fallback:

  1. Never calls UnifyTypes to pre-unify overallPatTy
  2. Creates fresh domain types not shared with the Val's declared type
  3. Strips SynExpr.Typed (line 11424) so the annotation is not re-checked

For generic methods like Run<'T>(f: unit -> 'T) : Task<'T>, the 'T in the parameter type and return type may not unify through the body type.

Consider either: (a) not stripping SynExpr.Typed in the fallback and letting normal type-checking handle it, or (b) performing pre-unification analogous to the primary path.

// Check argument types
for arg in syntacticArgs do
if arg.InlineIfLambda && (not inlined || not (isFunTy g arg.Type || isFSharpDelegateTy g arg.Type)) then
// Allow [<InlineIfLambda>] on parameters of runtime-async methods (MethodImplOptions.Async = 0x2000).
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] InlineIfLambda bypass allows a no-op attribute without warning

This isRuntimeAsyncMember check bypasses the InlineIfLambda diagnostic for runtime-async methods. However, for non-inline runtime-async methods (ValInline.Never from ComputeInlineFlag at CheckExpressions.fs:2458), InlineIfLambda has no semantic effect — the optimizer never inlines such methods, so lambda arguments are never substituted inline.

For inline runtime-async methods (ValInline.Always), v.ShouldInline = true so inlined = true and the check passes WITHOUT needing this bypass.

The bypass silently allows users to write [<InlineIfLambda>] on parameters of non-inline methods with no effect — this is misleading. Consider removing the bypass and letting the existing warning inform users, or updating the comment to explain why this is intentional.

/// because the unsafe cast (T -> Task<T>) only works with runtime-async wrapping.
let rec private exprContainsAsyncHelpersAwait expr =
match expr with
| Expr.Op (TOp.ILCall (_, _, _, _, _, _, _, ilMethRef, _, _, _), _, args, _) ->
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] String-based AsyncHelpers detection is fragile and triplicated

This is the first of three copies of the same magic-string matching:

  1. Here in Optimizer.fs:exprContainsAsyncHelpersAwait (lines ~2343-2345)
  2. IlxGen.fs:ExprContainsAsyncHelpersAwaitCall (lines ~9269-9272)
  3. IlxGen.fs:ilBodyContainsAsyncHelpersAwait (lines ~6698-6703)

All three hard-code "System.Runtime.CompilerServices.AsyncHelpers" and method names "Await", "AwaitAwaiter", "UnsafeAwaitAwaiter".

Risks:

  • A user creating their own type named AsyncHelpers with an Await method would trigger false-positive inlining suppression
  • If the BCL adds new overloads, all three must be updated
  • Any typo in one copy silently breaks the check in that location

Consider extracting a shared isAsyncHelpersAwaitMethod predicate into a common location.

Also: this version walks through Expr.Lambda bodies (line 2355), unlike IlxGen.ExprContainsAsyncHelpersAwaitCall which returns false at lambda boundaries (line ~9284). This means higher-order functions returning async closures (let makeCallback () = fun () -> Await(...)) are unnecessarily blocked from cross-module inlining. The inner closure gets its own IL method with its own 0x2000 flag — the outer function does not need to be blocked.

elif fvs.FreeLocals.ToArray() |> Seq.fold(fun acc v -> if not acc then v.Accessibility.IsPrivate else acc) false then
// Discarding lambda for binding because uses private members
UnknownValue
elif exprContainsAsyncHelpersAwait body then
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Inline RuntimeAsync methods bypass cut — correctness depends on IlxGen propagation

ValInline.Always methods bypass the cut function entirely (line ~4226: if vref.ShouldInline || vref.InlineIfLambda then einfo), so the exprContainsAsyncHelpersAwait check never fires for them. Their full CurriedLambdaValue body — including Await calls — is exported for cross-module inlining.

Correctness depends on IlxGen's body-analysis path (line ~9594) propagating 0x2000 to the consumer function. This works IF:

  1. The consumer returns a Task-like type (returnsTaskLikeType check), AND
  2. The consumer is NOT marked NoDynamicInvocation

If a consumer does not return a Task-like type but has Await calls inlined into it (edge case), the 0x2000 flag will not be set and the Await calls will fail at runtime.

Consider adding a diagnostic or assertion when an inline RuntimeAsync member's body is inlined into a non-Task-returning context.

3884,tcRuntimeAsyncMethodMustReturnTask,"Methods marked with MethodImplOptions.Async must return Task, Task<'T>, ValueTask, or ValueTask<'T>. The actual return type is '%s'."
3885,tcRuntimeAsyncCannotBeSynchronized,"Methods marked with MethodImplOptions.Async cannot also use MethodImplOptions.Synchronized."
3886,tcRuntimeAsyncCannotReturnByref,"Methods marked with MethodImplOptions.Async cannot return byref types."
3887,tcRuntimeAsyncNotSupported,"Methods marked with MethodImplOptions.Async are not supported in this context."
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Error 3887 (tcRuntimeAsyncNotSupported) is defined but never emitted

grep -rn 'tcRuntimeAsyncNotSupported' src/Compiler/**/*.fs returns zero hits. This error is defined here and translated in 13 .xlf files but no compiler code path emits it. Dead code adding translation burden.

Either wire it up at a call site (e.g., 0x2000 on properties, constructors, abstract members) or remove it.

featureImplicitDIMCoverage,"Implicit dispatch slot coverage for default interface member implementations"
featurePreprocessorElif,"#elif preprocessor directive"
featureRuntimeAsync,"runtime async"
3884,tcRuntimeAsyncMethodMustReturnTask,"Methods marked with MethodImplOptions.Async must return Task, Task<'T>, ValueTask, or ValueTask<'T>. The actual return type is '%s'."
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Error codes 3884-3887 break numeric ordering

These new codes (3884-3887) appear at lines 1812-1815, BEFORE existing codes 3880-3883 at lines 1816-1819. FSComp.txt maintains monotonically increasing error codes by convention. Reorder so 3880-3883 come first, then 3884-3887.

/// compiled (non-inline) form, preventing unsafe dynamic invocation.
/// (3) Optimizer anti-inlining — the F# optimizer does not cross-module inline functions from
/// <c>[&lt;RuntimeAsync&gt;]</c>-marked types, preserving the <c>cil managed async</c> contract.
[<AttributeUsage(AttributeTargets.Class, AllowMultiple = false)>]
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] No .fsi signature file for new FSharp.Core public API

RuntimeAsyncAttribute is a permanent public type in Microsoft.FSharp.Control, but neither runtimeAsync.fs nor runtimeTasks.fs has a corresponding .fsi signature file. FSharp.Core convention uses .fsi files to define the public API contract explicitly (see async.fs/async.fsi, tasks.fs/tasks.fsi, resumable.fs/resumable.fsi).

/// Methods using this builder will have the async IL flag (0x2000) emitted.
/// All members are inline to produce flat method bodies (no state machine).
[<Sealed>]
type RuntimeTaskBuilder() =
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] If shipped in FSharp.Core, RuntimeTaskBuilder needs [<RuntimeAsync>]

Currently this code is never compiled — FSharp.Core targets netstandard2.0/netstandard2.1, so NET10_0_OR_GREATER is never set. But if net10.0 is ever added as a TFM for FSharp.Core:

  1. RuntimeTaskBuilder lacks [<RuntimeAsync>], so builderHasRuntimeAsync (CheckComputationExpressions.fs:90) would return false — no sentinel injection into Delay closures
  2. Run uses RuntimeTaskBuilderUnsafe.cast (f()) — a raw (# "" a : 'b #) reinterpret cast from 'T to Task<'T> — which is only safe when the 0x2000 async flag is active and the runtime wraps the return value
  3. Run is member inline with [<MethodImpl(0x2000)>], and the Delay returns 'T not unit -> Task<'T> (contrast with the sample library builder which returns unit -> Task<'T>)

For runtimeTask { return 42 } with this builder: after inlining Run/Delay/Return, the consumer body is cast(42) with no Await calls, so no 0x2000 flag, so the raw cast executes without runtime-async wrapping — undefined behavior.

The sample library builder at docs/samples/.../RuntimeTaskBuilder.fs correctly has [<RuntimeAsync; Sealed>] and uses a non-inline Run with AsyncHelpers.Await(f()). Consider either:

  • Adding [<RuntimeAsync>] here and redesigning to match the sample library pattern
  • Or removing this prototype if the sample library is the canonical design

// Even with no AsyncHelpers.Await in the user CE body, the compiler-injected
// sentinel (AsyncHelpers.Await(ValueTask.CompletedTask)) forces cloIsAsync=true,
// so the Delay closure Invoke is still emitted as 'cil managed async'.
"""cil managed async"""
Copy link
Member

Choose a reason for hiding this comment

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

[<Agentic-Review>] Test coverage gaps for runtime-async

Several important scenarios have zero test coverage:

1. No test for actual async suspension (HIGH)
All behavioral tests use Task.FromResult / Task.CompletedTask / ValueTask.FromResult — already-completed tasks that never suspend. No test verifies correct suspension/resumption when awaiting a genuinely delayed task. This is the core value proposition of runtime-async.

Suggested test:

[<MethodImpl(enum<MethodImplOptions> 0x2000)>]
let actualSuspension() : Task<int> =
    AsyncHelpers.Await(Task.Delay(50))
    42
// Verify: t.Result = 42 (after real async suspension)

2. No negative test for error 3886 — tcRuntimeAsyncCannotReturnByref (HIGH)
Error 3886 is emitted at CheckExpressions.fs:11498 but has zero test coverage.

3. No cancellation test (HIGH)
No test verifies CancellationToken / TaskCanceledException propagation through runtime-async methods.

4. No test for ValueTask<T> or ValueTask as declared method return type (HIGH)
All methods return Task<int> or Task. Error 3884 accepts ValueTask/ValueTask<T> — should have positive coverage.

5. No IL test verifying tail-call suppression (MEDIUM)
Runtime spec forbids tail. prefix in 0x2000 methods. No verifyIL test checks for absence of tail. instructions.

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.

net10.0: Support MethodImpl.Async

3 participants