Conversation
…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
❗ Release notes requiredCaution 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:
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
|
| /// 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I guess so, similar to current StateMachineHelpers. Although I gave no thought how this should behave with ns20.
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[<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) -> |
There was a problem hiding this comment.
[<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 } |
There was a problem hiding this comment.
[<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] |
There was a problem hiding this comment.
[<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) = |
There was a problem hiding this comment.
[<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( |
There was a problem hiding this comment.
[<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 = |
There was a problem hiding this comment.
[<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 = |
There was a problem hiding this comment.
[<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 |
There was a problem hiding this comment.
[<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:
- Never calls
UnifyTypesto pre-unifyoverallPatTy - Creates fresh domain types not shared with the Val's declared type
- 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). |
There was a problem hiding this comment.
[<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, _) -> |
There was a problem hiding this comment.
[<Agentic-Review>] String-based AsyncHelpers detection is fragile and triplicated
This is the first of three copies of the same magic-string matching:
- Here in
Optimizer.fs:exprContainsAsyncHelpersAwait(lines ~2343-2345) IlxGen.fs:ExprContainsAsyncHelpersAwaitCall(lines ~9269-9272)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
AsyncHelperswith anAwaitmethod 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 |
There was a problem hiding this comment.
[<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:
- The consumer returns a Task-like type (
returnsTaskLikeTypecheck), AND - 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." |
There was a problem hiding this comment.
[<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'." |
There was a problem hiding this comment.
[<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>[<RuntimeAsync>]</c>-marked types, preserving the <c>cil managed async</c> contract. | ||
| [<AttributeUsage(AttributeTargets.Class, AllowMultiple = false)>] |
There was a problem hiding this comment.
[<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() = |
There was a problem hiding this comment.
[<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:
RuntimeTaskBuilderlacks[<RuntimeAsync>], sobuilderHasRuntimeAsync(CheckComputationExpressions.fs:90) would returnfalse— no sentinel injection into Delay closuresRunusesRuntimeTaskBuilderUnsafe.cast (f())— a raw(# "" a : 'b #)reinterpret cast from'TtoTask<'T>— which is only safe when the0x2000async flag is active and the runtime wraps the return valueRunismember inlinewith[<MethodImpl(0x2000)>], and theDelayreturns'Tnotunit -> Task<'T>(contrast with the sample library builder which returnsunit -> 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""" |
There was a problem hiding this comment.
[<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.
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.mdis 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: